RelayAdoptionReceiverTest.kt

33.4 KB · raw

1package com.peachpasswords.sync23import kotlinx.coroutines.async4import kotlinx.coroutines.withTimeoutOrNull5import kotlinx.coroutines.channels.Channel6import kotlinx.coroutines.runBlocking7import org.junit.Assert.assertEquals8import org.junit.Assert.assertFalse9import org.junit.Assert.assertNotNull10import org.junit.Assert.assertTrue11import org.junit.Assert.assertNull12import org.junit.Assert.fail13import org.junit.Test14import kotlin.system.measureTimeMillis1516/**17 * Item 2 (REVIEW_DEFERRED_ITEMS_BRIEF.md): receiver inline-snapshot happy-path.18 *19 * The `sourceCanSendSnapshot` branch in `runRelayAdoptionReceiverSession` was20 * already reached by `RelayAdoptionHostTest` but only down the21 * catch-and-fallback path (source did not send a snapshot). What had no22 * coverage was the success path: a snapshot-capable source that actually23 * sends entries / tombstones / duress, the receiver applying them inline,24 * sending `ADOPT_SNAPSHOT_APPLIED`, and `markSynced` firing.25 *26 * These three tests cover the three sub-branches:27 * 1. snapshot_capable=true AND source sends a real snapshot -> applySnapshot28 *    called, SnapshotApplied sent, markSynced fired.29 * 2. snapshot_capable=true AND source sends nothing -> applySnapshot NOT30 *    called, session still returns paired device (sync fallback).31 * 3. snapshot_capable=false -> 15s wait is skipped, applySnapshot NOT called,32 *    session returns paired device quickly.33 */34class RelayAdoptionReceiverTest {35    private class MemoryRelaySocket(36        private val inbound: Channel<ByteArray>,37        private val outbound: Channel<ByteArray>,38    ) : RelayBinarySocket {39        override fun send(frame: ByteArray) {40            check(outbound.trySend(frame.copyOf()).isSuccess)41        }4243        override suspend fun receive(): ByteArray = inbound.receive()44        override fun close(code: Int, reason: String) {45            outbound.close()46        }4748        override fun closeAfterQueuedFrames(code: Int, reason: String) {49            outbound.close()50        }51    }5253    private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {54        val leftToRight = Channel<ByteArray>(Channel.UNLIMITED)55        val rightToLeft = Channel<ByteArray>(Channel.UNLIMITED)56        return MemoryRelaySocket(rightToLeft, leftToRight) to57            MemoryRelaySocket(leftToRight, rightToLeft)58    }5960    private fun identity(name: String, publicKey: ByteArray, signingPublicKeyHex: String = "cd".repeat(32)) = LocalDeviceIdentity(61        deviceId = "$name-id",62        deviceName = name,63        deviceType = DeviceType.ANDROID,64        longTermPublicKeyHex = Rendezvous.bytesToHex(publicKey),65        signingPublicKeyHex = signingPublicKeyHex,66        wsPort = 49142,67        relayUrl = "wss://relay.example.test",68    )6970    private fun smallSnapshot(): Pair<Map<String, String>, Map<String, Long>> {71        val entries = mapOf("id-0" to "ciphertext-0", "id-1" to "ciphertext-1")72        val tombstones = mapOf("dead-0" to 1L)73        return entries to tombstones74    }7576    @Test fun `receiver applies an inline snapshot when the source is snapshot capable`() = runBlocking {77        val (sourceSocket, receiverSocket) = socketPair()78        val token = "AAECAwQFBgcICQoLDA0ODw"79        val relay = "wss://relay.example.test"80        val root = ByteArray(32) { (it + 3).toByte() }81        val sourceKeys = deriveLocalSyncKeypair(root, "source-id")82        val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")83        val sourceIdentity = identity("source", sourceKeys.publicKey)84        val receiverIdentity = identity("receiver", receiverKeys.publicKey)85        val (entries, tombstones) = smallSnapshot()86        val duressJson = "{\"duress\":true}"87        var sourceStored: PairedDevice? = null88        var receiverStored: PairedDevice? = null89        var sourceMarkedSynced = false90        var receiverAppliedEntries: Map<String, String>? = null91        var receiverAppliedTombstones: Map<String, Long>? = null92        var receiverAppliedDuress: String? = null93        var receiverMarkedSynced = false9495        val sourceJob = async {96            runRelayAdoptionSourceSession(97                sourceSocket,98                token,99                relay,100                sourceKeys.secretKey,101                sourceKeys.publicKey,102                sourceIdentity,103                object : AdoptionSourceDeps {104                    override val sourceIdentity = sourceIdentity105                    override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)106                    override suspend fun vaultId() = computeVaultId(root)107                    override suspend fun license() = "PPK-test"108                    override suspend fun onConsent(request: AdoptionFrame.Request) = true109                    override suspend fun upsertPairedDevice(device: PairedDevice) {110                        sourceStored = device111                    }112                    override suspend fun vaultSnapshot() = smallSnapshot()113                    override suspend fun duressSlotJson() = duressJson114                    override suspend fun markSynced(deviceId: String) {115                        sourceMarkedSynced = true116                    }117                },118                confirm = { _, _ -> true },119            )120        }121122        val received = runRelayAdoptionReceiverSession(123            receiverSocket,124            PairingInvitation(125                relay,126                token,127                noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),128            ),129            object : AdoptionReceiverDeps {130                override val deviceName = "receiver"131                override val deviceType = DeviceType.ANDROID132                override fun generateDeviceId() = "session-only-request"133                override suspend fun confirmSas() = true134                override suspend fun createVaultFromRootKey(rootKeyHex: String): String =135                    computeVaultId(hexToPubkey(rootKeyHex))136                override suspend fun deriveLocalIdentity() = receiverIdentity137                override suspend fun upsertPairedDevice(device: PairedDevice) {138                    receiverStored = device139                }140                override suspend fun applySnapshot(141                    entries: Map<String, String>,142                    tombstones: Map<String, Long>,143                    duressSlot: String?,144                ) {145                    receiverAppliedEntries = entries146                    receiverAppliedTombstones = tombstones147                    receiverAppliedDuress = duressSlot148                }149                override suspend fun markSynced(deviceId: String) {150                    receiverMarkedSynced = true151                }152                override fun onError(error: String) = throw AssertionError(error)153            },154            confirm = { true },155        )156157        assertNotNull(received)158        assertNotNull(sourceJob.await())159        assertEquals(entries, receiverAppliedEntries)160        assertEquals(tombstones, receiverAppliedTombstones)161        assertEquals(duressJson, receiverAppliedDuress)162        assertTrue("receiver should mark synced after applying snapshot", receiverMarkedSynced)163        assertTrue("source should mark synced after SnapshotApplied", sourceMarkedSynced)164        assertNotNull(receiverStored)165        assertNotNull(sourceStored)166    }167168    @Test fun `receiver falls back to sync when the source sends no snapshot`() = runBlocking {169        val (sourceSocket, receiverSocket) = socketPair()170        val token = "AAECAwQFBgcICQoLDA0ODw"171        val relay = "wss://relay.example.test"172        val root = ByteArray(32) { (it + 5).toByte() }173        val sourceKeys = deriveLocalSyncKeypair(root, "source-id")174        val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")175        val sourceIdentity = identity("source", sourceKeys.publicKey)176        val receiverIdentity = identity("receiver", receiverKeys.publicKey)177        var sourceStored: PairedDevice? = null178        var receiverStored: PairedDevice? = null179        var receiverApplied = false180        var receiverMarkedSynced = false181182        val sourceJob = async {183            runRelayAdoptionSourceSession(184                sourceSocket,185                token,186                relay,187                sourceKeys.secretKey,188                sourceKeys.publicKey,189                sourceIdentity,190                object : AdoptionSourceDeps {191                    override val sourceIdentity = sourceIdentity192                    override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)193                    override suspend fun vaultId() = computeVaultId(root)194                    override suspend fun license() = "PPK-test"195                    override suspend fun onConsent(request: AdoptionFrame.Request) = true196                    override suspend fun upsertPairedDevice(device: PairedDevice) {197                        sourceStored = device198                    }199                    // vaultSnapshot() returns null -> source does not send a snapshot.200                    override suspend fun vaultSnapshot() = null201                },202                confirm = { _, _ -> true },203            )204        }205206        val received = runRelayAdoptionReceiverSession(207            receiverSocket,208            PairingInvitation(209                relay,210                token,211                noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),212            ),213            object : AdoptionReceiverDeps {214                override val deviceName = "receiver"215                override val deviceType = DeviceType.ANDROID216                override fun generateDeviceId() = "session-only-request"217                override suspend fun confirmSas() = true218                override suspend fun createVaultFromRootKey(rootKeyHex: String): String =219                    computeVaultId(hexToPubkey(rootKeyHex))220                override suspend fun deriveLocalIdentity() = receiverIdentity221                override suspend fun upsertPairedDevice(device: PairedDevice) {222                    receiverStored = device223                }224                override suspend fun applySnapshot(225                    entries: Map<String, String>,226                    tombstones: Map<String, Long>,227                    duressSlot: String?,228                ) {229                    receiverApplied = true230                }231                override suspend fun markSynced(deviceId: String) {232                    receiverMarkedSynced = true233                }234                override fun onError(error: String) = throw AssertionError(error)235            },236            confirm = { true },237        )238239        assertNotNull(received)240        assertNotNull(sourceJob.await())241        assertFalse("applySnapshot must NOT be called when source sends none", receiverApplied)242        assertFalse("markSynced must NOT be called when no snapshot was applied", receiverMarkedSynced)243        assertNotNull(receiverStored)244        assertNotNull(sourceStored)245    }246247    @Test fun `receiver skips the inline wait when the grant is not snapshot capable`() = runBlocking {248        val (sourceSocket, receiverSocket) = socketPair()249        val token = "AAECAwQFBgcICQoLDA0ODw"250        val relay = "wss://relay.example.test"251        val root = ByteArray(32) { (it + 7).toByte() }252        val sourceKeys = deriveLocalSyncKeypair(root, "old-source-id")253        val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")254        val sourceIdentity = identity("old-source", sourceKeys.publicKey)255        val receiverIdentity = identity("receiver", receiverKeys.publicKey)256        var receiverApplied = false257258        val sourceJob = async {259            sourceSocket.receive()260            sourceSocket.send(261                org.json.JSONObject().put("type", "PEACH_HELLO_ACK")262                    .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(sourceKeys.publicKey))263                    .toString().toByteArray(),264            )265            val noise = noiseNKResponder(266                sourceKeys.secretKey,267                sourceKeys.publicKey,268                sourceSocket::send,269                sourceSocket::receive,270            )271            try {272                val channel = object : PairingTransport {273                    override suspend fun send(msg: ByteArray) = sourceSocket.send(noise.encrypt(msg))274                    override suspend fun receive(): ByteArray = noise.decrypt(sourceSocket.receive())275                    override suspend fun close() = sourceSocket.close()276                }277                assertTrue(receiveFrame(channel) is AdoptionFrame.Request)278                assertTrue(receiveFrame(channel) is AdoptionFrame.Confirm)279                sendFrame(channel, AdoptionFrame.Grant(280                    rootKeyHex = Rendezvous.bytesToHex(root),281                    vaultId = computeVaultId(root),282                    license = "PPK-test",283                    source = AdoptionFrame.Source(284                        deviceId = sourceIdentity.deviceId,285                        deviceName = sourceIdentity.deviceName,286                        deviceType = sourceIdentity.deviceType.wireValue,287                        longTermPublicKeyHex = sourceIdentity.longTermPublicKeyHex,288                        signingPublicKeyHex = sourceIdentity.signingPublicKeyHex,289                    ),290                    snapshotCapable = false,291                ))292                assertTrue(receiveFrame(channel) is AdoptionFrame.Identity)293                sendFrame(channel, AdoptionFrame.Complete)294                assertTrue(receiveFrame(channel) is AdoptionFrame.Applied)295            } finally {296                noise.close()297            }298        }299300        var received: PairedDevice? = null301        val elapsedMs = measureTimeMillis {302            received = runRelayAdoptionReceiverSession(303                receiverSocket,304                PairingInvitation(305                    relay,306                    token,307                    noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),308                ),309                object : AdoptionReceiverDeps {310                    override val deviceName = "receiver"311                    override val deviceType = DeviceType.ANDROID312                    override fun generateDeviceId() = "session-only-request"313                    override suspend fun confirmSas() = true314                    override suspend fun createVaultFromRootKey(rootKeyHex: String) =315                        computeVaultId(hexToPubkey(rootKeyHex))316                    override suspend fun deriveLocalIdentity() = receiverIdentity317                    override suspend fun upsertPairedDevice(device: PairedDevice) = Unit318                    override suspend fun applySnapshot(319                        entries: Map<String, String>,320                        tombstones: Map<String, Long>,321                        duressSlot: String?,322                    ) {323                        receiverApplied = true324                    }325                    override fun onError(error: String) = throw AssertionError(error)326                },327                confirm = { true },328            )329        }330331        assertNotNull(received)332        sourceJob.await()333        assertFalse(receiverApplied)334        assertTrue("legacy grant should skip the 15s snapshot wait", elapsedMs < 5_000)335    }336337    @Test fun `receiver graduates into the mesh when the source sends a device manifest`() = runBlocking {338        // Mesh propagation: a 3rd device pairs with a pool of 2 already-paired339        // devices. The third must graduate into the full mesh during the same340        // adoption session, not just learn about the source. Verifies the341        // adoption-receiver apply path wires applyIncomingDeviceManifest to342        // the adapter and the adapter puts the vouched peers into the local343        // registry.344        val (sourceSocket, receiverSocket) = socketPair()345        val token = "AAECAwQFBgcICQoLDA0ODw"346        val relay = "wss://relay.example.test"347        val root = ByteArray(32) { (it + 13).toByte() }348        val sourceKeys = deriveLocalSyncKeypair(root, "source-id")349        val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")350        val sourceSigning = deriveSyncSigningKeypair(root, "source-id")351        val sourceIdentity = identity("source", sourceKeys.publicKey, pubkeyToHex(sourceSigning.publicKeyRaw))352        val receiverIdentity = identity("receiver", receiverKeys.publicKey)353354        // The source's pool already contains two direct-trust peers (B and C).355        // The signed manifest is what the source transmits during adoption.356        val manifest = buildSignedDeviceManifest(357            issuerDeviceId = "source-id",358            devices = listOf(359                ManifestDeviceEntry(360                    deviceId = "peer-b",361                    deviceName = "Pool B",362                    deviceType = "extension",363                    longTermPublicKeyHex = "11".repeat(32),364                    signingPublicKeyHex = "22".repeat(32),365                    relayUrl = "wss://relay.example.test",366                    pairedAt = 1L,367                ),368                ManifestDeviceEntry(369                    deviceId = "peer-c",370                    deviceName = "Pool C",371                    deviceType = "android",372                    longTermPublicKeyHex = "aa".repeat(32),373                    signingPublicKeyHex = "bb".repeat(32),374                    relayUrl = "wss://relay.example.test",375                    pairedAt = 2L,376                ),377            ),378            signingPrivateKey = sourceSigning.privateKey,379        )380381        // Receiver's in-memory registry. Starts empty; the source gets added382        // by the Grant path during adoption, and the two vouched peers get383        // appended when the manifest is applied.384        val receiverRegistry = mutableMapOf<String, PairedDevice>()385        val receiverPending = mutableListOf<com.peachpasswords.sync.PendingVouchedDevice>()386        val receiverRegistryStore = object : PairedDeviceRegistry {387            override fun getAll(): List<PairedDevice> = receiverRegistry.values.toList()388            override fun get(id: String): PairedDevice? = receiverRegistry[id]389            override fun getByPubkey(pubkeyHex: String): PairedDevice? =390                receiverRegistry.values.firstOrNull { it.longTermPublicKeyHex?.lowercase() == pubkeyHex.lowercase() }391            override fun upsert(device: PairedDevice) { receiverRegistry[device.deviceId] = device }392            override fun remove(deviceId: String) { receiverRegistry.remove(deviceId) }393            override fun listRevocations(): List<DeviceRevocation> = emptyList()394            override fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean {395                batch.removals.forEach { receiverRegistry.remove(it) }396                batch.upserts.forEach { receiverRegistry[it.deviceId] = it }397                return true398            }399            override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> = emptyList()400            override fun isRevoked(deviceId: String, longTermPublicKeyHex: String?): Boolean = false401            override fun clearRevocation(deviceId: String) {}402        }403        val receiverPendingStore = object : PendingVouchedRegistry {404            override fun getAll(): List<com.peachpasswords.sync.PendingVouchedDevice> = receiverPending.toList()405            override fun upsert(device: com.peachpasswords.sync.PendingVouchedDevice) { receiverPending += device }406            override fun remove(id: String) { receiverPending.removeAll { it.deviceId == id } }407            override fun removeBatch(ids: Set<String>): Boolean {408                receiverPending.removeAll { it.deviceId in ids }409                return true410            }411            override fun restoreBatch(devices: List<com.peachpasswords.sync.PendingVouchedDevice>): Boolean = true412        }413        var receiverAppliedManifest: SignedDeviceManifest? = null414415        val sourceJob = async {416            runRelayAdoptionSourceSession(417                sourceSocket,418                token,419                relay,420                sourceKeys.secretKey,421                sourceKeys.publicKey,422                sourceIdentity,423                object : AdoptionSourceDeps {424                    override val sourceIdentity = sourceIdentity425                    override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)426                    override suspend fun vaultId() = computeVaultId(root)427                    override suspend fun license() = "PPK-test"428                    override suspend fun onConsent(request: AdoptionFrame.Request) = true429                    override suspend fun upsertPairedDevice(device: PairedDevice) {}430                    override suspend fun vaultSnapshot() = smallSnapshot()431                    override suspend fun duressSlotJson() = null432                    override suspend fun outgoingDeviceManifest() = manifest433                    override suspend fun markSynced(deviceId: String) {}434                },435                confirm = { _, _ -> true },436            )437        }438439        val received = runRelayAdoptionReceiverSession(440            receiverSocket,441            PairingInvitation(442                relay,443                token,444                noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),445            ),446            object : AdoptionReceiverDeps {447                override val deviceName = "receiver"448                override val deviceType = DeviceType.ANDROID449                override fun generateDeviceId() = "session-only-request"450                override suspend fun confirmSas() = true451                override suspend fun createVaultFromRootKey(rootKeyHex: String) =452                    computeVaultId(hexToPubkey(rootKeyHex))453                override suspend fun deriveLocalIdentity() = receiverIdentity454                override suspend fun upsertPairedDevice(device: PairedDevice) {455                    receiverRegistry[device.deviceId] = device456                }457                override suspend fun applySnapshot(458                    entries: Map<String, String>,459                    tombstones: Map<String, Long>,460                    duressSlot: String?,461                ) {}462                override suspend fun applyIncomingDeviceManifest(463                    manifest: SignedDeviceManifest,464                    authenticatedRemoteStaticPublicKey: ByteArray,465                ) {466                    receiverAppliedManifest = manifest467                    val adapter = VaultSyncAdapter(468                        repo = createReceiverVault(root),469                        ourDeviceId = "receiver-id",470                        pairedDevices = receiverRegistryStore,471                        pendingVouched = receiverPendingStore,472                    )473                    adapter.applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey)474                }475                override suspend fun markSynced(deviceId: String) {}476                override fun onError(error: String) = throw AssertionError(error)477            },478            confirm = { true },479        )480481        assertNotNull(received)482        sourceJob.await()483        assertNotNull("source must have delivered the manifest frame", receiverAppliedManifest)484        // After the adoption + manifest apply, the receiver's registry must485        // contain the source (added by Grant) AND the two vouched peers.486        val deviceIds = receiverRegistry.keys.toSet()487        assertEquals(488            "receiver should see the source + both vouched peers",489            setOf("source-id", "peer-b", "peer-c"),490            deviceIds,491        )492        // The two vouched peers arrive with trustSource=vouched, not direct.493        assertEquals("vouched", receiverRegistry["peer-b"]?.trustSource)494        assertEquals("vouched", receiverRegistry["peer-c"]?.trustSource)495        // The pending store is drained after a successful commit.496        assertTrue("pending vouched should be cleared", receiverPending.isEmpty())497    }498499    private suspend fun createReceiverVault(root: ByteArray): com.peachpasswords.vault.VaultRepository {500        val repo = com.peachpasswords.vault.VaultRepository(ReceiverFakeStorage())501        repo.createVault(root.joinToString("") { "%02x".format(it) })502        return repo503    }504505    private class ReceiverFakeStorage : com.peachpasswords.vault.VaultStorage {506        private var slotA: com.peachpasswords.vault.StoredVault? = null507        private var slotB: String? = null508        private var decoy: com.peachpasswords.vault.StoredVault? = null509        private var ver: Long = 0510        private var settings = com.peachpasswords.vault.VaultSettings()511        override fun loadSlotA() = slotA512        override fun hasVault() = slotA != null513        override fun writeSlotA(slot: com.peachpasswords.vault.StoredVault): Boolean { slotA = slot; return true }514        override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }515        override fun loadDecoySlot() = decoy516        override fun writeDecoySlot(slot: com.peachpasswords.vault.StoredVault): Boolean { decoy = slot; return true }517        override fun hasDecoySlot() = decoy != null518        override fun clearDecoySlot() { decoy = null }519        override fun version() = ver520        override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }521        override fun loadSettings() = settings522        override fun writeSettings(s: com.peachpasswords.vault.VaultSettings): Boolean { settings = s; return true }523        override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = com.peachpasswords.vault.VaultSettings() }524    }525}526527/**528 * PB-26-103: the invitation pins the responder's Noise static so the scanner529 * can authenticate the cleartext PEACH_HELLO_ACK before running Noise. Without530 * the pin a relay can terminate both sessions and present its own key to each531 * side, which is only detectable today by a human comparing the SAS.532 *533 * These cover both failure shapes in the receiver direction, matching the534 * phone-source direction tests in RelayAdoptionInitiatorSourceTest.535 */536class RelayAdoptionInvitationPinTest {537538    /**539     * Time-boxed so these adversarial tests fail rather than hang: without the540     * pin check the receiver completes a handshake against the substituted key541     * and then stalls on a protocol step the attacker never performs.542     */543    private val HANDSHAKE_TIMEOUT_MS = 5_000L544545    private class MemoryRelaySocket(546        private val inbound: Channel<ByteArray>,547        private val outbound: Channel<ByteArray>,548    ) : RelayBinarySocket {549        override fun send(frame: ByteArray) {550            check(outbound.trySend(frame.copyOf()).isSuccess)551        }552553        override suspend fun receive(): ByteArray = inbound.receive()554        override fun close(code: Int, reason: String) {555            outbound.close()556        }557558        override fun closeAfterQueuedFrames(code: Int, reason: String) {559            outbound.close()560        }561    }562563    private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {564        val leftToRight = Channel<ByteArray>(Channel.UNLIMITED)565        val rightToLeft = Channel<ByteArray>(Channel.UNLIMITED)566        return MemoryRelaySocket(rightToLeft, leftToRight) to567            MemoryRelaySocket(leftToRight, rightToLeft)568    }569570    /**571     * Every protocol member throws: reaching any of them means we got past the572     * pin check. `onError` only records, so the positive control below can end573     * the session cleanly at the SAS gate.574     */575    private fun abortingDeps(576        confirmSas: Boolean = false,577        generateDeviceId: () -> String = { error("past the pin check") },578    ) = object : AdoptionReceiverDeps {579        override val deviceName = "phone"580        override val deviceType = DeviceType.ANDROID581        override fun generateDeviceId() = generateDeviceId()582        override suspend fun confirmSas() = confirmSas583        override suspend fun createVaultFromRootKey(rootKeyHex: String): String =584            error("past the pin check")585        override suspend fun deriveLocalIdentity(): LocalDeviceIdentity = error("past the pin check")586        override suspend fun upsertPairedDevice(device: PairedDevice) = error("past the pin check")587        override fun onError(error: String) = Unit588    }589590    private fun ack(publicKey: ByteArray) = org.json.JSONObject()591        .put("type", "PEACH_HELLO_ACK")592        .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(publicKey))593        .toString()594        .toByteArray()595596    @Test fun `receiver rejects an ACK static that does not match the pinned key`() = runBlocking {597        val (phoneSocket, mitmSocket) = socketPair()598        val root = ByteArray(32) { (it + 3).toByte() }599        val sourceKeys = deriveLocalSyncKeypair(root, "source-id")600        val attackerKeys = deriveLocalSyncKeypair(root, "attacker-id")601602        // The result is captured inside the job: an exception escaping a child603        // coroutine cancels the parent scope before await() can observe it.604        val phoneJob = async {605            try {606                runRelayAdoptionReceiverSession(607                    socket = phoneSocket,608                    invitation = PairingInvitation(609                        "wss://relay.example.test",610                        "AAECAwQFBgcICQoLDA0ODw",611                        VaultStatus.POPULATED,612                        Rendezvous.bytesToHex(sourceKeys.publicKey),613                    ),614                    receiverDeps = abortingDeps(),615                    confirm = { error("past the pin check") },616                )617                "completed"618            } catch (expected: IllegalArgumentException) {619                expected.message620            }621        }622623        mitmSocket.receive() // PEACH_HELLO624        mitmSocket.send(ack(attackerKeys.publicKey))625626        val outcome = withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() }627        phoneJob.cancel()628        assertEquals("pairing_static_key_mismatch", outcome)629    }630631    @Test fun `receiver rejects an invitation that pins no static key`() = runBlocking {632        val (phoneSocket, mitmSocket) = socketPair()633        val root = ByteArray(32) { (it + 13).toByte() }634        val attackerKeys = deriveLocalSyncKeypair(root, "attacker-id")635636        val phoneJob = async {637            try {638                runRelayAdoptionReceiverSession(639                    socket = phoneSocket,640                    // A pre-fix invitation. Accepting it here reopens the hole.641                    invitation = PairingInvitation(642                        "wss://relay.example.test",643                        "AAECAwQFBgcICQoLDA0ODw",644                        VaultStatus.POPULATED,645                    ),646                    receiverDeps = abortingDeps(),647                    confirm = { error("past the pin check") },648                )649                "completed"650            } catch (expected: IllegalArgumentException) {651                expected.message652            }653        }654655        mitmSocket.receive() // PEACH_HELLO656        mitmSocket.send(ack(attackerKeys.publicKey))657658        val outcome = withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() }659        phoneJob.cancel()660        assertEquals("pairing_invitation_missing_static_key", outcome)661    }662663    // Positive control: the honest source sends exactly the pinned static, so664    // the check must let the handshake through to the SAS gate.665    @Test fun `receiver proceeds when the ACK static matches the pinned key`() = runBlocking {666        val (phoneSocket, sourceSocket) = socketPair()667        val root = ByteArray(32) { (it + 17).toByte() }668        val sourceKeys = deriveLocalSyncKeypair(root, "source-id")669        val reached = java.util.concurrent.atomic.AtomicBoolean(false)670671        val phoneJob = async {672            runRelayAdoptionReceiverSession(673                socket = phoneSocket,674                invitation = PairingInvitation(675                    "wss://relay.example.test",676                    "AAECAwQFBgcICQoLDA0ODw",677                    VaultStatus.POPULATED,678                    Rendezvous.bytesToHex(sourceKeys.publicKey),679                ),680                receiverDeps = abortingDeps(681                    confirmSas = false,682                    // requestAdoption mints the device id to build its frame, so683                    // reaching this call proves the handshake ran.684                    generateDeviceId = { reached.set(true); "session-only" },685                ),686                confirm = { true },687            )688        }689690        sourceSocket.receive() // PEACH_HELLO691        sourceSocket.send(ack(sourceKeys.publicKey))692        val noise = noiseNKResponder(693            sourceKeys.secretKey,694            sourceKeys.publicKey,695            sourceSocket::send,696            sourceSocket::receive,697        )698        try {699            val channel = object : PairingTransport {700                override suspend fun send(msg: ByteArray) = sourceSocket.send(noise.encrypt(msg))701                override suspend fun receive(): ByteArray = noise.decrypt(sourceSocket.receive())702                override suspend fun close() = sourceSocket.close()703            }704            // Read and discard the receiver's request: the handshake completing705            // at all is what this control asserts.706            receiveFrame(channel)707        } finally {708            noise.close()709        }710711        // Declining at the SAS gate ends the session without pairing, which712        // proves the pin check let the matching static through.713        val outcome = withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() }714        // runBlocking waits for its children, and the receiver side may still715        // be parked on the protocol.716        phoneJob.cancel()717        assertNull(outcome)718        assertTrue(719            "the matching static must reach the adoption request, not be refused",720            reached.get(),721        )722    }723}