1package com.peachpasswords.sync23import kotlinx.coroutines.async4import kotlinx.coroutines.channels.Channel5import kotlinx.coroutines.runBlocking6import org.junit.Assert.assertEquals7import org.junit.Assert.assertFalse8import org.junit.Assert.assertNotNull9import org.junit.Assert.assertTrue10import org.junit.Test11import kotlin.system.measureTimeMillis1213141516171819202122232425262728293031class RelayAdoptionReceiverTest {32 private class MemoryRelaySocket(33 private val inbound: Channel<ByteArray>,34 private val outbound: Channel<ByteArray>,35 ) : RelayBinarySocket {36 override fun send(frame: ByteArray) {37 check(outbound.trySend(frame.copyOf()).isSuccess)38 }3940 override suspend fun receive(): ByteArray = inbound.receive()41 override fun close(code: Int, reason: String) {42 outbound.close()43 }4445 override fun closeAfterQueuedFrames(code: Int, reason: String) {46 outbound.close()47 }48 }4950 private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {51 val leftToRight = Channel<ByteArray>(Channel.UNLIMITED)52 val rightToLeft = Channel<ByteArray>(Channel.UNLIMITED)53 return MemoryRelaySocket(rightToLeft, leftToRight) to54 MemoryRelaySocket(leftToRight, rightToLeft)55 }5657 private fun identity(name: String, publicKey: ByteArray) = LocalDeviceIdentity(58 deviceId = "$name-id",59 deviceName = name,60 deviceType = DeviceType.ANDROID,61 longTermPublicKeyHex = Rendezvous.bytesToHex(publicKey),62 signingPublicKeyHex = "cd".repeat(32),63 wsPort = 49142,64 relayUrl = "wss://relay.example.test",65 )6667 private fun smallSnapshot(): Pair<Map<String, String>, Map<String, Long>> {68 val entries = mapOf("id-0" to "ciphertext-0", "id-1" to "ciphertext-1")69 val tombstones = mapOf("dead-0" to 1L)70 return entries to tombstones71 }7273 @Test fun `receiver applies an inline snapshot when the source is snapshot capable`() = runBlocking {74 val (sourceSocket, receiverSocket) = socketPair()75 val token = "AAECAwQFBgcICQoLDA0ODw"76 val relay = "wss://relay.example.test"77 val root = ByteArray(32) { (it + 3).toByte() }78 val sourceKeys = deriveLocalSyncKeypair(root, "source-id")79 val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")80 val sourceIdentity = identity("source", sourceKeys.publicKey)81 val receiverIdentity = identity("receiver", receiverKeys.publicKey)82 val (entries, tombstones) = smallSnapshot()83 val duressJson = "{\"duress\":true}"84 var sourceStored: PairedDevice? = null85 var receiverStored: PairedDevice? = null86 var sourceMarkedSynced = false87 var receiverAppliedEntries: Map<String, String>? = null88 var receiverAppliedTombstones: Map<String, Long>? = null89 var receiverAppliedDuress: String? = null90 var receiverMarkedSynced = false9192 val sourceJob = async {93 runRelayAdoptionSourceSession(94 sourceSocket,95 token,96 relay,97 sourceKeys.secretKey,98 sourceKeys.publicKey,99 sourceIdentity,100 object : AdoptionSourceDeps {101 override val sourceIdentity = sourceIdentity102 override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)103 override suspend fun vaultId() = computeVaultId(root)104 override suspend fun license() = "PPK-test"105 override suspend fun onConsent(request: AdoptionFrame.Request) = true106 override suspend fun upsertPairedDevice(device: PairedDevice) {107 sourceStored = device108 }109 override suspend fun vaultSnapshot() = smallSnapshot()110 override suspend fun duressSlotJson() = duressJson111 override suspend fun markSynced(deviceId: String) {112 sourceMarkedSynced = true113 }114 },115 confirm = { _, _ -> true },116 )117 }118119 val received = runRelayAdoptionReceiverSession(120 receiverSocket,121 PairingInvitation(relay, token),122 object : AdoptionReceiverDeps {123 override val deviceName = "receiver"124 override val deviceType = DeviceType.ANDROID125 override fun generateDeviceId() = "session-only-request"126 override suspend fun confirmSas() = true127 override suspend fun createVaultFromRootKey(rootKeyHex: String): String =128 computeVaultId(hexToPubkey(rootKeyHex))129 override suspend fun deriveLocalIdentity() = receiverIdentity130 override suspend fun upsertPairedDevice(device: PairedDevice) {131 receiverStored = device132 }133 override suspend fun applySnapshot(134 entries: Map<String, String>,135 tombstones: Map<String, Long>,136 duressSlot: String?,137 ) {138 receiverAppliedEntries = entries139 receiverAppliedTombstones = tombstones140 receiverAppliedDuress = duressSlot141 }142 override suspend fun markSynced(deviceId: String) {143 receiverMarkedSynced = true144 }145 override fun onError(error: String) = throw AssertionError(error)146 },147 confirm = { true },148 )149150 assertNotNull(received)151 assertNotNull(sourceJob.await())152 assertEquals(entries, receiverAppliedEntries)153 assertEquals(tombstones, receiverAppliedTombstones)154 assertEquals(duressJson, receiverAppliedDuress)155 assertTrue("receiver should mark synced after applying snapshot", receiverMarkedSynced)156 assertTrue("source should mark synced after SnapshotApplied", sourceMarkedSynced)157 assertNotNull(receiverStored)158 assertNotNull(sourceStored)159 }160161 @Test fun `receiver falls back to sync when the source sends no snapshot`() = runBlocking {162 val (sourceSocket, receiverSocket) = socketPair()163 val token = "AAECAwQFBgcICQoLDA0ODw"164 val relay = "wss://relay.example.test"165 val root = ByteArray(32) { (it + 5).toByte() }166 val sourceKeys = deriveLocalSyncKeypair(root, "source-id")167 val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")168 val sourceIdentity = identity("source", sourceKeys.publicKey)169 val receiverIdentity = identity("receiver", receiverKeys.publicKey)170 var sourceStored: PairedDevice? = null171 var receiverStored: PairedDevice? = null172 var receiverApplied = false173 var receiverMarkedSynced = false174175 val sourceJob = async {176 runRelayAdoptionSourceSession(177 sourceSocket,178 token,179 relay,180 sourceKeys.secretKey,181 sourceKeys.publicKey,182 sourceIdentity,183 object : AdoptionSourceDeps {184 override val sourceIdentity = sourceIdentity185 override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)186 override suspend fun vaultId() = computeVaultId(root)187 override suspend fun license() = "PPK-test"188 override suspend fun onConsent(request: AdoptionFrame.Request) = true189 override suspend fun upsertPairedDevice(device: PairedDevice) {190 sourceStored = device191 }192 193 override suspend fun vaultSnapshot() = null194 },195 confirm = { _, _ -> true },196 )197 }198199 val received = runRelayAdoptionReceiverSession(200 receiverSocket,201 PairingInvitation(relay, token),202 object : AdoptionReceiverDeps {203 override val deviceName = "receiver"204 override val deviceType = DeviceType.ANDROID205 override fun generateDeviceId() = "session-only-request"206 override suspend fun confirmSas() = true207 override suspend fun createVaultFromRootKey(rootKeyHex: String): String =208 computeVaultId(hexToPubkey(rootKeyHex))209 override suspend fun deriveLocalIdentity() = receiverIdentity210 override suspend fun upsertPairedDevice(device: PairedDevice) {211 receiverStored = device212 }213 override suspend fun applySnapshot(214 entries: Map<String, String>,215 tombstones: Map<String, Long>,216 duressSlot: String?,217 ) {218 receiverApplied = true219 }220 override suspend fun markSynced(deviceId: String) {221 receiverMarkedSynced = true222 }223 override fun onError(error: String) = throw AssertionError(error)224 },225 confirm = { true },226 )227228 assertNotNull(received)229 assertNotNull(sourceJob.await())230 assertFalse("applySnapshot must NOT be called when source sends none", receiverApplied)231 assertFalse("markSynced must NOT be called when no snapshot was applied", receiverMarkedSynced)232 assertNotNull(receiverStored)233 assertNotNull(sourceStored)234 }235236 @Test fun `receiver skips the inline wait when the grant is not snapshot capable`() = runBlocking {237 val (sourceSocket, receiverSocket) = socketPair()238 val token = "AAECAwQFBgcICQoLDA0ODw"239 val relay = "wss://relay.example.test"240 val root = ByteArray(32) { (it + 7).toByte() }241 val sourceKeys = deriveLocalSyncKeypair(root, "old-source-id")242 val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")243 val sourceIdentity = identity("old-source", sourceKeys.publicKey)244 val receiverIdentity = identity("receiver", receiverKeys.publicKey)245 var receiverApplied = false246247 val sourceJob = async {248 sourceSocket.receive()249 sourceSocket.send(250 org.json.JSONObject().put("type", "PEACH_HELLO_ACK")251 .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(sourceKeys.publicKey))252 .toString().toByteArray(),253 )254 val noise = noiseNKResponder(255 sourceKeys.secretKey,256 sourceKeys.publicKey,257 sourceSocket::send,258 sourceSocket::receive,259 )260 try {261 val channel = object : PairingTransport {262 override suspend fun send(msg: ByteArray) = sourceSocket.send(noise.encrypt(msg))263 override suspend fun receive(): ByteArray = noise.decrypt(sourceSocket.receive())264 override suspend fun close() = sourceSocket.close()265 }266 assertTrue(receiveFrame(channel) is AdoptionFrame.Request)267 assertTrue(receiveFrame(channel) is AdoptionFrame.Confirm)268 sendFrame(channel, AdoptionFrame.Grant(269 rootKeyHex = Rendezvous.bytesToHex(root),270 vaultId = computeVaultId(root),271 license = "PPK-test",272 source = AdoptionFrame.Source(273 deviceId = sourceIdentity.deviceId,274 deviceName = sourceIdentity.deviceName,275 deviceType = sourceIdentity.deviceType.wireValue,276 longTermPublicKeyHex = sourceIdentity.longTermPublicKeyHex,277 signingPublicKeyHex = sourceIdentity.signingPublicKeyHex,278 ),279 snapshotCapable = false,280 ))281 assertTrue(receiveFrame(channel) is AdoptionFrame.Identity)282 sendFrame(channel, AdoptionFrame.Complete)283 assertTrue(receiveFrame(channel) is AdoptionFrame.Applied)284 } finally {285 noise.close()286 }287 }288289 var received: PairedDevice? = null290 val elapsedMs = measureTimeMillis {291 received = runRelayAdoptionReceiverSession(292 receiverSocket,293 PairingInvitation(relay, token),294 object : AdoptionReceiverDeps {295 override val deviceName = "receiver"296 override val deviceType = DeviceType.ANDROID297 override fun generateDeviceId() = "session-only-request"298 override suspend fun confirmSas() = true299 override suspend fun createVaultFromRootKey(rootKeyHex: String) =300 computeVaultId(hexToPubkey(rootKeyHex))301 override suspend fun deriveLocalIdentity() = receiverIdentity302 override suspend fun upsertPairedDevice(device: PairedDevice) = Unit303 override suspend fun applySnapshot(304 entries: Map<String, String>,305 tombstones: Map<String, Long>,306 duressSlot: String?,307 ) {308 receiverApplied = true309 }310 override fun onError(error: String) = throw AssertionError(error)311 },312 confirm = { true },313 )314 }315316 assertNotNull(received)317 sourceJob.await()318 assertFalse(receiverApplied)319 assertTrue("legacy grant should skip the 15s snapshot wait", elapsedMs < 5_000)320 }321}