package com.peachpasswords.sync import kotlinx.coroutines.async import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Assert.assertNull import org.junit.Assert.fail import org.junit.Test import kotlin.system.measureTimeMillis /** * Item 2 (REVIEW_DEFERRED_ITEMS_BRIEF.md): receiver inline-snapshot happy-path. * * The `sourceCanSendSnapshot` branch in `runRelayAdoptionReceiverSession` was * already reached by `RelayAdoptionHostTest` but only down the * catch-and-fallback path (source did not send a snapshot). What had no * coverage was the success path: a snapshot-capable source that actually * sends entries / tombstones / duress, the receiver applying them inline, * sending `ADOPT_SNAPSHOT_APPLIED`, and `markSynced` firing. * * These three tests cover the three sub-branches: * 1. snapshot_capable=true AND source sends a real snapshot -> applySnapshot * called, SnapshotApplied sent, markSynced fired. * 2. snapshot_capable=true AND source sends nothing -> applySnapshot NOT * called, session still returns paired device (sync fallback). * 3. snapshot_capable=false -> 15s wait is skipped, applySnapshot NOT called, * session returns paired device quickly. */ class RelayAdoptionReceiverTest { private class MemoryRelaySocket( private val inbound: Channel, private val outbound: Channel, ) : RelayBinarySocket { override fun send(frame: ByteArray) { check(outbound.trySend(frame.copyOf()).isSuccess) } override suspend fun receive(): ByteArray = inbound.receive() override fun close(code: Int, reason: String) { outbound.close() } override fun closeAfterQueuedFrames(code: Int, reason: String) { outbound.close() } } private fun socketPair(): Pair { val leftToRight = Channel(Channel.UNLIMITED) val rightToLeft = Channel(Channel.UNLIMITED) return MemoryRelaySocket(rightToLeft, leftToRight) to MemoryRelaySocket(leftToRight, rightToLeft) } private fun identity(name: String, publicKey: ByteArray, signingPublicKeyHex: String = "cd".repeat(32)) = LocalDeviceIdentity( deviceId = "$name-id", deviceName = name, deviceType = DeviceType.ANDROID, longTermPublicKeyHex = Rendezvous.bytesToHex(publicKey), signingPublicKeyHex = signingPublicKeyHex, wsPort = 49142, relayUrl = "wss://relay.example.test", ) private fun smallSnapshot(): Pair, Map> { val entries = mapOf("id-0" to "ciphertext-0", "id-1" to "ciphertext-1") val tombstones = mapOf("dead-0" to 1L) return entries to tombstones } @Test fun `receiver applies an inline snapshot when the source is snapshot capable`() = runBlocking { val (sourceSocket, receiverSocket) = socketPair() val token = "AAECAwQFBgcICQoLDA0ODw" val relay = "wss://relay.example.test" val root = ByteArray(32) { (it + 3).toByte() } val sourceKeys = localSyncKeypair(randomLocalIdentityCredentials("source-id")) val receiverKeys = localSyncKeypair(randomLocalIdentityCredentials("receiver-id")) val sourceIdentity = identity("source", sourceKeys.publicKey) val receiverIdentity = identity("receiver", receiverKeys.publicKey) val (entries, tombstones) = smallSnapshot() val duressJson = "{\"duress\":true}" var sourceStored: PairedDevice? = null var receiverStored: PairedDevice? = null var sourceMarkedSynced = false var receiverAppliedEntries: Map? = null var receiverAppliedTombstones: Map? = null var receiverAppliedDuress: String? = null var receiverMarkedSynced = false val sourceJob = async { runRelayAdoptionSourceSession( sourceSocket, token, relay, sourceKeys.secretKey, sourceKeys.publicKey, sourceIdentity, object : AdoptionSourceV2Deps { override suspend fun rotateSourceIdentity() = sourceIdentity override fun hasDeviceIdentityCollision(device: PairedDevice) = false override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true override suspend fun completePendingIdentity(device: PairedDevice) = true override fun snapshotCapable() = true override val sourceIdentity = sourceIdentity override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root) override suspend fun vaultId() = computeVaultId(root) override suspend fun license() = "PPK-test" override suspend fun onConsent(request: AdoptionFrame.Request) = true override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true override suspend fun upsertPairedDevice(device: PairedDevice) { sourceStored = device } override suspend fun vaultSnapshot() = smallSnapshot() override suspend fun duressSlotJson() = duressJson override suspend fun markSynced(deviceId: String) { sourceMarkedSynced = true } }, confirm = { _, _ -> true }, ) } val received = runRelayAdoptionReceiverSession( receiverSocket, PairingInvitation( relay, token, noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey), ), object : AdoptionReceiverV2Deps { override fun localIdentity() = receiverIdentity override suspend fun rotateLocalIdentity() = localIdentity() override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {} override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false override val deviceName = "receiver" override val deviceType = DeviceType.ANDROID override fun generateDeviceId() = "session-only-request" override suspend fun confirmSas() = true override suspend fun createVaultFromRootKey(rootKeyHex: String): String = computeVaultId(hexToPubkey(rootKeyHex)) override suspend fun deriveLocalIdentity() = receiverIdentity override suspend fun upsertPairedDevice(device: PairedDevice) { receiverStored = device } override suspend fun applySnapshot( entries: Map, tombstones: Map, duressSlot: String?, ) { receiverAppliedEntries = entries receiverAppliedTombstones = tombstones receiverAppliedDuress = duressSlot } override suspend fun markSynced(deviceId: String) { receiverMarkedSynced = true } override fun onError(error: String) = throw AssertionError(error) }, confirm = { true }, ) assertNotNull(received) assertNotNull(sourceJob.await()) assertEquals(entries, receiverAppliedEntries) assertEquals(tombstones, receiverAppliedTombstones) assertEquals(duressJson, receiverAppliedDuress) assertTrue("receiver should mark synced after applying snapshot", receiverMarkedSynced) assertTrue("source should mark synced after SnapshotApplied", sourceMarkedSynced) assertNotNull(receiverStored) assertNotNull(sourceStored) } @Test fun `receiver falls back to sync when the source sends no snapshot`() = runBlocking { val (sourceSocket, receiverSocket) = socketPair() val token = "AAECAwQFBgcICQoLDA0ODw" val relay = "wss://relay.example.test" val root = ByteArray(32) { (it + 5).toByte() } val sourceKeys = localSyncKeypair(randomLocalIdentityCredentials("source-id")) val receiverKeys = localSyncKeypair(randomLocalIdentityCredentials("receiver-id")) val sourceIdentity = identity("source", sourceKeys.publicKey) val receiverIdentity = identity("receiver", receiverKeys.publicKey) var sourceStored: PairedDevice? = null var receiverStored: PairedDevice? = null var receiverApplied = false var receiverMarkedSynced = false val sourceJob = async { runRelayAdoptionSourceSession( sourceSocket, token, relay, sourceKeys.secretKey, sourceKeys.publicKey, sourceIdentity, object : AdoptionSourceV2Deps { override suspend fun rotateSourceIdentity() = sourceIdentity override fun hasDeviceIdentityCollision(device: PairedDevice) = false override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true override suspend fun completePendingIdentity(device: PairedDevice) = true override fun snapshotCapable() = true override val sourceIdentity = sourceIdentity override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root) override suspend fun vaultId() = computeVaultId(root) override suspend fun license() = "PPK-test" override suspend fun onConsent(request: AdoptionFrame.Request) = true override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true override suspend fun upsertPairedDevice(device: PairedDevice) { sourceStored = device } // vaultSnapshot() returns null -> source does not send a snapshot. override suspend fun vaultSnapshot() = null }, confirm = { _, _ -> true }, ) } val received = runRelayAdoptionReceiverSession( receiverSocket, PairingInvitation( relay, token, noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey), ), object : AdoptionReceiverV2Deps { override fun localIdentity() = receiverIdentity override suspend fun rotateLocalIdentity() = localIdentity() override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {} override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false override val deviceName = "receiver" override val deviceType = DeviceType.ANDROID override fun generateDeviceId() = "session-only-request" override suspend fun confirmSas() = true override suspend fun createVaultFromRootKey(rootKeyHex: String): String = computeVaultId(hexToPubkey(rootKeyHex)) override suspend fun deriveLocalIdentity() = receiverIdentity override suspend fun upsertPairedDevice(device: PairedDevice) { receiverStored = device } override suspend fun applySnapshot( entries: Map, tombstones: Map, duressSlot: String?, ) { receiverApplied = true } override suspend fun markSynced(deviceId: String) { receiverMarkedSynced = true } override fun onError(error: String) = throw AssertionError(error) }, confirm = { true }, ) assertNotNull(received) assertNotNull(sourceJob.await()) assertFalse("applySnapshot must NOT be called when source sends none", receiverApplied) assertFalse("markSynced must NOT be called when no snapshot was applied", receiverMarkedSynced) assertNotNull(receiverStored) assertNotNull(sourceStored) } @Test fun `receiver skips the inline wait when the grant is not snapshot capable`() = runBlocking { val (sourceSocket, receiverSocket) = socketPair() val token = "AAECAwQFBgcICQoLDA0ODw" val relay = "wss://relay.example.test" val root = ByteArray(32) { (it + 7).toByte() } val sourceKeys = localSyncKeypair(randomLocalIdentityCredentials("old-source-id")) val receiverKeys = localSyncKeypair(randomLocalIdentityCredentials("receiver-id")) val sourceIdentity = identity("old-source", sourceKeys.publicKey) val receiverIdentity = identity("receiver", receiverKeys.publicKey) var receiverApplied = false val sourceJob = async { sourceSocket.receive() sourceSocket.send( org.json.JSONObject().put("type", "PEACH_HELLO_ACK") .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(sourceKeys.publicKey)) .toString().toByteArray(), ) val noise = noiseNKResponder( sourceKeys.secretKey, sourceKeys.publicKey, sourceSocket::send, sourceSocket::receive, ) try { val channel = object : PairingTransport { override suspend fun send(msg: ByteArray) = sourceSocket.send(noise.encrypt(msg)) override suspend fun receive(): ByteArray = noise.decrypt(sourceSocket.receive()) override suspend fun close() = sourceSocket.close() } assertTrue(receiveFrame(channel) is AdoptionFrame.RequestV2) sendFrame(channel, AdoptionFrame.ConfirmRequired) assertTrue(receiveFrame(channel) is AdoptionFrame.Confirm) sendFrame(channel, AdoptionFrame.Prepare( deviceId = sourceIdentity.deviceId, deviceName = sourceIdentity.deviceName, deviceType = sourceIdentity.deviceType.wireValue, longTermPublicKeyHex = sourceIdentity.longTermPublicKeyHex, signingPublicKeyHex = sourceIdentity.signingPublicKeyHex, vaultId = computeVaultId(root), license = "PPK-test", snapshotCapable = false, )) assertTrue(receiveFrame(channel) is AdoptionFrame.Ready) // The v2 grant carries no source identity, so the peer writes it // with its own serializer (see AdoptGrantV2). channel.send( AdoptJson.encodeToString( AdoptGrantV2.serializer(), AdoptGrantV2( type = "ADOPT_GRANT", rootKeyHex = Rendezvous.bytesToHex(root), vaultId = computeVaultId(root), license = "PPK-test", ), ).toByteArray(Charsets.UTF_8), ) assertTrue(receiveFrame(channel) is AdoptionFrame.Applied) sendFrame(channel, AdoptionFrame.Complete) } finally { noise.close() } } var received: PairedDevice? = null val elapsedMs = measureTimeMillis { received = runRelayAdoptionReceiverSession( receiverSocket, PairingInvitation( relay, token, noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey), ), object : AdoptionReceiverV2Deps { override fun localIdentity() = receiverIdentity override suspend fun rotateLocalIdentity() = localIdentity() override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {} override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false override val deviceName = "receiver" override val deviceType = DeviceType.ANDROID override fun generateDeviceId() = "session-only-request" override suspend fun confirmSas() = true override suspend fun createVaultFromRootKey(rootKeyHex: String) = computeVaultId(hexToPubkey(rootKeyHex)) override suspend fun deriveLocalIdentity() = receiverIdentity override suspend fun upsertPairedDevice(device: PairedDevice) = Unit override suspend fun applySnapshot( entries: Map, tombstones: Map, duressSlot: String?, ) { receiverApplied = true } override fun onError(error: String) = throw AssertionError(error) }, confirm = { true }, ) } assertNotNull(received) sourceJob.await() assertFalse(receiverApplied) assertTrue("legacy grant should skip the 15s snapshot wait", elapsedMs < 5_000) } @Test fun `receiver graduates into the mesh when the source sends a device manifest`() = runBlocking { // Mesh propagation: a 3rd device pairs with a pool of 2 already-paired // devices. The third must graduate into the full mesh during the same // adoption session, not just learn about the source. Verifies the // adoption-receiver apply path wires applyIncomingDeviceManifest to // the adapter and the adapter puts the vouched peers into the local // registry. val (sourceSocket, receiverSocket) = socketPair() val token = "AAECAwQFBgcICQoLDA0ODw" val relay = "wss://relay.example.test" val root = ByteArray(32) { (it + 13).toByte() } val sourceCredentials = randomLocalIdentityCredentials("source-id") val sourceKeys = localSyncKeypair(sourceCredentials) val receiverKeys = localSyncKeypair(randomLocalIdentityCredentials("receiver-id")) val sourceSigning = localSigningKeypair(sourceCredentials) val sourceIdentity = identity("source", sourceKeys.publicKey, pubkeyToHex(sourceSigning.publicKeyRaw)) val receiverIdentity = identity("receiver", receiverKeys.publicKey) // The source's pool already contains two direct-trust peers (B and C). // The signed manifest is what the source transmits during adoption. val manifest = buildSignedDeviceManifest( issuerDeviceId = "source-id", devices = listOf( ManifestDeviceEntry( deviceId = "peer-b", deviceName = "Pool B", deviceType = "extension", longTermPublicKeyHex = "11".repeat(32), signingPublicKeyHex = "22".repeat(32), relayUrl = "wss://relay.example.test", pairedAt = 1L, ), ManifestDeviceEntry( deviceId = "peer-c", deviceName = "Pool C", deviceType = "android", longTermPublicKeyHex = "aa".repeat(32), signingPublicKeyHex = "bb".repeat(32), relayUrl = "wss://relay.example.test", pairedAt = 2L, ), ), signingPrivateKey = sourceSigning.privateKey, ) // Receiver's in-memory registry. Starts empty; the source gets added // by the Grant path during adoption, and the two vouched peers get // appended when the manifest is applied. val receiverRegistry = mutableMapOf() val receiverPending = mutableListOf() val receiverRegistryStore = object : PairedDeviceRegistry { override fun getAll(): List = receiverRegistry.values.toList() override fun get(id: String): PairedDevice? = receiverRegistry[id] override fun getByPubkey(pubkeyHex: String): PairedDevice? = receiverRegistry.values.firstOrNull { it.longTermPublicKeyHex?.lowercase() == pubkeyHex.lowercase() } override fun upsert(device: PairedDevice) { receiverRegistry[device.deviceId] = device } override fun remove(deviceId: String) { receiverRegistry.remove(deviceId) } override fun listRevocations(): List = emptyList() override fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean { batch.removals.forEach { receiverRegistry.remove(it) } batch.upserts.forEach { receiverRegistry[it.deviceId] = it } return true } override fun mergeRevocations(incoming: List): List = emptyList() override fun isRevoked(deviceId: String, longTermPublicKeyHex: String?): Boolean = false override fun clearRevocation(deviceId: String) {} } val receiverPendingStore = object : PendingVouchedRegistry { override fun getAll(): List = receiverPending.toList() override fun upsert(device: com.peachpasswords.sync.PendingVouchedDevice) { receiverPending += device } override fun remove(id: String) { receiverPending.removeAll { it.deviceId == id } } override fun removeBatch(ids: Set): Boolean { receiverPending.removeAll { it.deviceId in ids } return true } override fun restoreBatch(devices: List): Boolean = true } var receiverAppliedManifest: SignedDeviceManifest? = null val sourceJob = async { runRelayAdoptionSourceSession( sourceSocket, token, relay, sourceKeys.secretKey, sourceKeys.publicKey, sourceIdentity, object : AdoptionSourceV2Deps { override suspend fun rotateSourceIdentity() = sourceIdentity override fun hasDeviceIdentityCollision(device: PairedDevice) = false override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true override suspend fun completePendingIdentity(device: PairedDevice) = true override fun snapshotCapable() = true override val sourceIdentity = sourceIdentity override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root) override suspend fun vaultId() = computeVaultId(root) override suspend fun license() = "PPK-test" override suspend fun onConsent(request: AdoptionFrame.Request) = true override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true override suspend fun upsertPairedDevice(device: PairedDevice) {} override suspend fun vaultSnapshot() = smallSnapshot() override suspend fun duressSlotJson() = null override suspend fun outgoingDeviceManifest() = manifest override suspend fun markSynced(deviceId: String) {} }, confirm = { _, _ -> true }, ) } val received = runRelayAdoptionReceiverSession( receiverSocket, PairingInvitation( relay, token, noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey), ), object : AdoptionReceiverV2Deps { override fun localIdentity() = receiverIdentity override suspend fun rotateLocalIdentity() = localIdentity() override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {} override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false override val deviceName = "receiver" override val deviceType = DeviceType.ANDROID override fun generateDeviceId() = "session-only-request" override suspend fun confirmSas() = true override suspend fun createVaultFromRootKey(rootKeyHex: String) = computeVaultId(hexToPubkey(rootKeyHex)) override suspend fun deriveLocalIdentity() = receiverIdentity override suspend fun upsertPairedDevice(device: PairedDevice) { receiverRegistry[device.deviceId] = device } override suspend fun applySnapshot( entries: Map, tombstones: Map, duressSlot: String?, ) {} override suspend fun applyIncomingDeviceManifest( manifest: SignedDeviceManifest, authenticatedRemoteStaticPublicKey: ByteArray, ) { receiverAppliedManifest = manifest val adapter = VaultSyncAdapter( repo = createReceiverVault(root), ourDeviceId = "receiver-id", pairedDevices = receiverRegistryStore, pendingVouched = receiverPendingStore, localCredentials = randomLocalIdentityCredentials("receiver-id"), ) adapter.applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) } override suspend fun markSynced(deviceId: String) {} override fun onError(error: String) = throw AssertionError(error) }, confirm = { true }, ) assertNotNull(received) sourceJob.await() assertNotNull("source must have delivered the manifest frame", receiverAppliedManifest) // After the adoption + manifest apply, the receiver's registry must // contain the source (added by Grant) AND the two vouched peers. val deviceIds = receiverRegistry.keys.toSet() assertEquals( "receiver should see the source + both vouched peers", setOf("source-id", "peer-b", "peer-c"), deviceIds, ) // The two vouched peers arrive with trustSource=vouched, not direct. assertEquals("vouched", receiverRegistry["peer-b"]?.trustSource) assertEquals("vouched", receiverRegistry["peer-c"]?.trustSource) // The pending store is drained after a successful commit. assertTrue("pending vouched should be cleared", receiverPending.isEmpty()) } private suspend fun createReceiverVault(root: ByteArray): com.peachpasswords.vault.VaultRepository { val repo = com.peachpasswords.vault.VaultRepository(ReceiverFakeStorage()) repo.createVault(root.joinToString("") { "%02x".format(it) }) return repo } private class ReceiverFakeStorage : com.peachpasswords.vault.VaultStorage { private var slotA: com.peachpasswords.vault.StoredVault? = null private var slotB: String? = null private var decoy: com.peachpasswords.vault.StoredVault? = null private var ver: Long = 0 private var settings = com.peachpasswords.vault.VaultSettings() override fun loadSlotA() = slotA override fun hasVault() = slotA != null override fun writeSlotA(slot: com.peachpasswords.vault.StoredVault): Boolean { slotA = slot; return true } override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true } override fun loadDecoySlot() = decoy override fun writeDecoySlot(slot: com.peachpasswords.vault.StoredVault): Boolean { decoy = slot; return true } override fun hasDecoySlot() = decoy != null override fun clearDecoySlot() { decoy = null } override fun version() = ver override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver } override fun loadSettings() = settings override fun writeSettings(s: com.peachpasswords.vault.VaultSettings): Boolean { settings = s; return true } override fun clearVault(): Boolean { slotA = null; slotB = null; ver = 0; settings = com.peachpasswords.vault.VaultSettings(); return true } } } /** * PB-26-103: the invitation pins the responder's Noise static so the scanner * can authenticate the cleartext PEACH_HELLO_ACK before running Noise. Without * the pin a relay can terminate both sessions and present its own key to each * side, which is only detectable today by a human comparing the SAS. * * These cover both failure shapes in the receiver direction, matching the * phone-source direction tests in RelayAdoptionInitiatorSourceTest. */ class RelayAdoptionInvitationPinTest { /** * Time-boxed so these adversarial tests fail rather than hang: without the * pin check the receiver completes a handshake against the substituted key * and then stalls on a protocol step the attacker never performs. */ private val HANDSHAKE_TIMEOUT_MS = 5_000L private class MemoryRelaySocket( private val inbound: Channel, private val outbound: Channel, ) : RelayBinarySocket { override fun send(frame: ByteArray) { check(outbound.trySend(frame.copyOf()).isSuccess) } override suspend fun receive(): ByteArray = inbound.receive() override fun close(code: Int, reason: String) { outbound.close() } override fun closeAfterQueuedFrames(code: Int, reason: String) { outbound.close() } } private fun socketPair(): Pair { val leftToRight = Channel(Channel.UNLIMITED) val rightToLeft = Channel(Channel.UNLIMITED) return MemoryRelaySocket(rightToLeft, leftToRight) to MemoryRelaySocket(leftToRight, rightToLeft) } /** * Every protocol member throws: reaching any of them means we got past the * pin check. `onError` only records, so the positive control below can end * the session cleanly at the SAS gate. */ private fun abortingDeps( confirmSas: Boolean = false, generateDeviceId: () -> String = { error("past the pin check") }, ) = object : AdoptionReceiverV2Deps { override fun localIdentity() = LocalDeviceIdentity(generateDeviceId(), deviceName, deviceType, "", "") override suspend fun rotateLocalIdentity() = localIdentity() override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {} override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false override val deviceName = "phone" override val deviceType = DeviceType.ANDROID override fun generateDeviceId() = generateDeviceId() override suspend fun confirmSas() = confirmSas override suspend fun createVaultFromRootKey(rootKeyHex: String): String = error("past the pin check") override suspend fun deriveLocalIdentity(): LocalDeviceIdentity = error("past the pin check") override suspend fun upsertPairedDevice(device: PairedDevice) = error("past the pin check") override fun onError(error: String) = Unit } private fun ack(publicKey: ByteArray) = org.json.JSONObject() .put("type", "PEACH_HELLO_ACK") .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(publicKey)) .toString() .toByteArray() @Test fun `receiver rejects an ACK static that does not match the pinned key`() = runBlocking { val (phoneSocket, mitmSocket) = socketPair() val root = ByteArray(32) { (it + 3).toByte() } val sourceKeys = localSyncKeypair(randomLocalIdentityCredentials("source-id")) val attackerKeys = localSyncKeypair(randomLocalIdentityCredentials("attacker-id")) // The result is captured inside the job: an exception escaping a child // coroutine cancels the parent scope before await() can observe it. val phoneJob = async { try { runRelayAdoptionReceiverSession( socket = phoneSocket, invitation = PairingInvitation( "wss://relay.example.test", "AAECAwQFBgcICQoLDA0ODw", VaultStatus.POPULATED, Rendezvous.bytesToHex(sourceKeys.publicKey), ), receiverDeps = abortingDeps(), confirm = { error("past the pin check") }, ) "completed" } catch (expected: IllegalArgumentException) { expected.message } } mitmSocket.receive() // PEACH_HELLO mitmSocket.send(ack(attackerKeys.publicKey)) val outcome = withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() } phoneJob.cancel() assertEquals("pairing_static_key_mismatch", outcome) } @Test fun `receiver rejects an invitation that pins no static key`() = runBlocking { val (phoneSocket, mitmSocket) = socketPair() val root = ByteArray(32) { (it + 13).toByte() } val attackerKeys = localSyncKeypair(randomLocalIdentityCredentials("attacker-id")) val phoneJob = async { try { runRelayAdoptionReceiverSession( socket = phoneSocket, // A pre-fix invitation. Accepting it here reopens the hole. invitation = PairingInvitation( "wss://relay.example.test", "AAECAwQFBgcICQoLDA0ODw", VaultStatus.POPULATED, ), receiverDeps = abortingDeps(), confirm = { error("past the pin check") }, ) "completed" } catch (expected: IllegalArgumentException) { expected.message } } mitmSocket.receive() // PEACH_HELLO mitmSocket.send(ack(attackerKeys.publicKey)) val outcome = withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() } phoneJob.cancel() assertEquals("pairing_invitation_missing_static_key", outcome) } // Positive control: the honest source sends exactly the pinned static, so // the check must let the handshake through to the SAS gate. @Test fun `receiver proceeds when the ACK static matches the pinned key`() = runBlocking { val (phoneSocket, sourceSocket) = socketPair() val root = ByteArray(32) { (it + 17).toByte() } val sourceKeys = localSyncKeypair(randomLocalIdentityCredentials("source-id")) val reached = java.util.concurrent.atomic.AtomicBoolean(false) val phoneJob = async { runRelayAdoptionReceiverSession( socket = phoneSocket, invitation = PairingInvitation( "wss://relay.example.test", "AAECAwQFBgcICQoLDA0ODw", VaultStatus.POPULATED, Rendezvous.bytesToHex(sourceKeys.publicKey), ), receiverDeps = abortingDeps( confirmSas = false, // requestAdoption mints the device id to build its frame, so // reaching this call proves the handshake ran. generateDeviceId = { reached.set(true); "session-only" }, ), confirm = { true }, ) } sourceSocket.receive() // PEACH_HELLO sourceSocket.send(ack(sourceKeys.publicKey)) val noise = noiseNKResponder( sourceKeys.secretKey, sourceKeys.publicKey, sourceSocket::send, sourceSocket::receive, ) try { val channel = object : PairingTransport { override suspend fun send(msg: ByteArray) = sourceSocket.send(noise.encrypt(msg)) override suspend fun receive(): ByteArray = noise.decrypt(sourceSocket.receive()) override suspend fun close() = sourceSocket.close() } // Read and discard the receiver's request: the handshake completing // at all is what this control asserts. receiveFrame(channel) } finally { noise.close() } // Declining at the SAS gate ends the session without pairing, which // proves the pin check let the matching static through. val outcome = withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() } // runBlocking waits for its children, and the receiver side may still // be parked on the protocol. phoneJob.cancel() assertNull(outcome) assertTrue( "the matching static must reach the adoption request, not be refused", reached.get(), ) } }