RelayAdoptionReceiverTest.kt

37.2 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 = localSyncKeypair(randomLocalIdentityCredentials("source-id"))82        val receiverKeys = localSyncKeypair(randomLocalIdentityCredentials("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 : AdoptionSourceV2Deps {104        override suspend fun rotateSourceIdentity() = sourceIdentity105        override fun hasDeviceIdentityCollision(device: PairedDevice) = false106        override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true107        override suspend fun completePendingIdentity(device: PairedDevice) = true108        override fun snapshotCapable() = true109110                    override val sourceIdentity = sourceIdentity111                    override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)112                    override suspend fun vaultId() = computeVaultId(root)113                    override suspend fun license() = "PPK-test"114                    override suspend fun onConsent(request: AdoptionFrame.Request) = true115                    override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true116                    override suspend fun upsertPairedDevice(device: PairedDevice) {117                        sourceStored = device118                    }119                    override suspend fun vaultSnapshot() = smallSnapshot()120                    override suspend fun duressSlotJson() = duressJson121                    override suspend fun markSynced(deviceId: String) {122                        sourceMarkedSynced = true123                    }124                },125                confirm = { _, _ -> true },126            )127        }128129        val received = runRelayAdoptionReceiverSession(130            receiverSocket,131            PairingInvitation(132                relay,133                token,134                noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),135            ),136            object : AdoptionReceiverV2Deps {137        override fun localIdentity() = receiverIdentity138        override suspend fun rotateLocalIdentity() = localIdentity()139        override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {}140        override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false141142                override val deviceName = "receiver"143                override val deviceType = DeviceType.ANDROID144                override fun generateDeviceId() = "session-only-request"145                override suspend fun confirmSas() = true146                override suspend fun createVaultFromRootKey(rootKeyHex: String): String =147                    computeVaultId(hexToPubkey(rootKeyHex))148                override suspend fun deriveLocalIdentity() = receiverIdentity149                override suspend fun upsertPairedDevice(device: PairedDevice) {150                    receiverStored = device151                }152                override suspend fun applySnapshot(153                    entries: Map<String, String>,154                    tombstones: Map<String, Long>,155                    duressSlot: String?,156                ) {157                    receiverAppliedEntries = entries158                    receiverAppliedTombstones = tombstones159                    receiverAppliedDuress = duressSlot160                }161                override suspend fun markSynced(deviceId: String) {162                    receiverMarkedSynced = true163                }164                override fun onError(error: String) = throw AssertionError(error)165            },166            confirm = { true },167        )168169        assertNotNull(received)170        assertNotNull(sourceJob.await())171        assertEquals(entries, receiverAppliedEntries)172        assertEquals(tombstones, receiverAppliedTombstones)173        assertEquals(duressJson, receiverAppliedDuress)174        assertTrue("receiver should mark synced after applying snapshot", receiverMarkedSynced)175        assertTrue("source should mark synced after SnapshotApplied", sourceMarkedSynced)176        assertNotNull(receiverStored)177        assertNotNull(sourceStored)178    }179180    @Test fun `receiver falls back to sync when the source sends no snapshot`() = runBlocking {181        val (sourceSocket, receiverSocket) = socketPair()182        val token = "AAECAwQFBgcICQoLDA0ODw"183        val relay = "wss://relay.example.test"184        val root = ByteArray(32) { (it + 5).toByte() }185        val sourceKeys = localSyncKeypair(randomLocalIdentityCredentials("source-id"))186        val receiverKeys = localSyncKeypair(randomLocalIdentityCredentials("receiver-id"))187        val sourceIdentity = identity("source", sourceKeys.publicKey)188        val receiverIdentity = identity("receiver", receiverKeys.publicKey)189        var sourceStored: PairedDevice? = null190        var receiverStored: PairedDevice? = null191        var receiverApplied = false192        var receiverMarkedSynced = false193194        val sourceJob = async {195            runRelayAdoptionSourceSession(196                sourceSocket,197                token,198                relay,199                sourceKeys.secretKey,200                sourceKeys.publicKey,201                sourceIdentity,202                object : AdoptionSourceV2Deps {203        override suspend fun rotateSourceIdentity() = sourceIdentity204        override fun hasDeviceIdentityCollision(device: PairedDevice) = false205        override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true206        override suspend fun completePendingIdentity(device: PairedDevice) = true207        override fun snapshotCapable() = true208209                    override val sourceIdentity = sourceIdentity210                    override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)211                    override suspend fun vaultId() = computeVaultId(root)212                    override suspend fun license() = "PPK-test"213                    override suspend fun onConsent(request: AdoptionFrame.Request) = true214                    override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true215                    override suspend fun upsertPairedDevice(device: PairedDevice) {216                        sourceStored = device217                    }218                    // vaultSnapshot() returns null -> source does not send a snapshot.219                    override suspend fun vaultSnapshot() = null220                },221                confirm = { _, _ -> true },222            )223        }224225        val received = runRelayAdoptionReceiverSession(226            receiverSocket,227            PairingInvitation(228                relay,229                token,230                noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),231            ),232            object : AdoptionReceiverV2Deps {233        override fun localIdentity() = receiverIdentity234        override suspend fun rotateLocalIdentity() = localIdentity()235        override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {}236        override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false237238                override val deviceName = "receiver"239                override val deviceType = DeviceType.ANDROID240                override fun generateDeviceId() = "session-only-request"241                override suspend fun confirmSas() = true242                override suspend fun createVaultFromRootKey(rootKeyHex: String): String =243                    computeVaultId(hexToPubkey(rootKeyHex))244                override suspend fun deriveLocalIdentity() = receiverIdentity245                override suspend fun upsertPairedDevice(device: PairedDevice) {246                    receiverStored = device247                }248                override suspend fun applySnapshot(249                    entries: Map<String, String>,250                    tombstones: Map<String, Long>,251                    duressSlot: String?,252                ) {253                    receiverApplied = true254                }255                override suspend fun markSynced(deviceId: String) {256                    receiverMarkedSynced = true257                }258                override fun onError(error: String) = throw AssertionError(error)259            },260            confirm = { true },261        )262263        assertNotNull(received)264        assertNotNull(sourceJob.await())265        assertFalse("applySnapshot must NOT be called when source sends none", receiverApplied)266        assertFalse("markSynced must NOT be called when no snapshot was applied", receiverMarkedSynced)267        assertNotNull(receiverStored)268        assertNotNull(sourceStored)269    }270271    @Test fun `receiver skips the inline wait when the grant is not snapshot capable`() = runBlocking {272        val (sourceSocket, receiverSocket) = socketPair()273        val token = "AAECAwQFBgcICQoLDA0ODw"274        val relay = "wss://relay.example.test"275        val root = ByteArray(32) { (it + 7).toByte() }276        val sourceKeys = localSyncKeypair(randomLocalIdentityCredentials("old-source-id"))277        val receiverKeys = localSyncKeypair(randomLocalIdentityCredentials("receiver-id"))278        val sourceIdentity = identity("old-source", sourceKeys.publicKey)279        val receiverIdentity = identity("receiver", receiverKeys.publicKey)280        var receiverApplied = false281282        val sourceJob = async {283            sourceSocket.receive()284            sourceSocket.send(285                org.json.JSONObject().put("type", "PEACH_HELLO_ACK")286                    .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(sourceKeys.publicKey))287                    .toString().toByteArray(),288            )289            val noise = noiseNKResponder(290                sourceKeys.secretKey,291                sourceKeys.publicKey,292                sourceSocket::send,293                sourceSocket::receive,294            )295            try {296                val channel = object : PairingTransport {297                    override suspend fun send(msg: ByteArray) = sourceSocket.send(noise.encrypt(msg))298                    override suspend fun receive(): ByteArray = noise.decrypt(sourceSocket.receive())299                    override suspend fun close() = sourceSocket.close()300                }301                assertTrue(receiveFrame(channel) is AdoptionFrame.RequestV2)302                sendFrame(channel, AdoptionFrame.ConfirmRequired)303                assertTrue(receiveFrame(channel) is AdoptionFrame.Confirm)304                sendFrame(channel, AdoptionFrame.Prepare(305                    deviceId = sourceIdentity.deviceId,306                    deviceName = sourceIdentity.deviceName,307                    deviceType = sourceIdentity.deviceType.wireValue,308                    longTermPublicKeyHex = sourceIdentity.longTermPublicKeyHex,309                    signingPublicKeyHex = sourceIdentity.signingPublicKeyHex,310                    vaultId = computeVaultId(root),311                    license = "PPK-test",312                    snapshotCapable = false,313                ))314                assertTrue(receiveFrame(channel) is AdoptionFrame.Ready)315                // The v2 grant carries no source identity, so the peer writes it316                // with its own serializer (see AdoptGrantV2).317                channel.send(318                    AdoptJson.encodeToString(319                        AdoptGrantV2.serializer(),320                        AdoptGrantV2(321                            type = "ADOPT_GRANT",322                            rootKeyHex = Rendezvous.bytesToHex(root),323                            vaultId = computeVaultId(root),324                            license = "PPK-test",325                        ),326                    ).toByteArray(Charsets.UTF_8),327                )328                assertTrue(receiveFrame(channel) is AdoptionFrame.Applied)329                sendFrame(channel, AdoptionFrame.Complete)330            } finally {331                noise.close()332            }333        }334335        var received: PairedDevice? = null336        val elapsedMs = measureTimeMillis {337            received = runRelayAdoptionReceiverSession(338                receiverSocket,339                PairingInvitation(340                    relay,341                    token,342                    noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),343                ),344                object : AdoptionReceiverV2Deps {345        override fun localIdentity() = receiverIdentity346        override suspend fun rotateLocalIdentity() = localIdentity()347        override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {}348        override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false349350                    override val deviceName = "receiver"351                    override val deviceType = DeviceType.ANDROID352                    override fun generateDeviceId() = "session-only-request"353                    override suspend fun confirmSas() = true354                    override suspend fun createVaultFromRootKey(rootKeyHex: String) =355                        computeVaultId(hexToPubkey(rootKeyHex))356                    override suspend fun deriveLocalIdentity() = receiverIdentity357                    override suspend fun upsertPairedDevice(device: PairedDevice) = Unit358                    override suspend fun applySnapshot(359                        entries: Map<String, String>,360                        tombstones: Map<String, Long>,361                        duressSlot: String?,362                    ) {363                        receiverApplied = true364                    }365                    override fun onError(error: String) = throw AssertionError(error)366                },367                confirm = { true },368            )369        }370371        assertNotNull(received)372        sourceJob.await()373        assertFalse(receiverApplied)374        assertTrue("legacy grant should skip the 15s snapshot wait", elapsedMs < 5_000)375    }376377    @Test fun `receiver graduates into the mesh when the source sends a device manifest`() = runBlocking {378        // Mesh propagation: a 3rd device pairs with a pool of 2 already-paired379        // devices. The third must graduate into the full mesh during the same380        // adoption session, not just learn about the source. Verifies the381        // adoption-receiver apply path wires applyIncomingDeviceManifest to382        // the adapter and the adapter puts the vouched peers into the local383        // registry.384        val (sourceSocket, receiverSocket) = socketPair()385        val token = "AAECAwQFBgcICQoLDA0ODw"386        val relay = "wss://relay.example.test"387        val root = ByteArray(32) { (it + 13).toByte() }388        val sourceCredentials = randomLocalIdentityCredentials("source-id")389        val sourceKeys = localSyncKeypair(sourceCredentials)390        val receiverKeys = localSyncKeypair(randomLocalIdentityCredentials("receiver-id"))391        val sourceSigning = localSigningKeypair(sourceCredentials)392        val sourceIdentity = identity("source", sourceKeys.publicKey, pubkeyToHex(sourceSigning.publicKeyRaw))393        val receiverIdentity = identity("receiver", receiverKeys.publicKey)394395        // The source's pool already contains two direct-trust peers (B and C).396        // The signed manifest is what the source transmits during adoption.397        val manifest = buildSignedDeviceManifest(398            issuerDeviceId = "source-id",399            devices = listOf(400                ManifestDeviceEntry(401                    deviceId = "peer-b",402                    deviceName = "Pool B",403                    deviceType = "extension",404                    longTermPublicKeyHex = "11".repeat(32),405                    signingPublicKeyHex = "22".repeat(32),406                    relayUrl = "wss://relay.example.test",407                    pairedAt = 1L,408                ),409                ManifestDeviceEntry(410                    deviceId = "peer-c",411                    deviceName = "Pool C",412                    deviceType = "android",413                    longTermPublicKeyHex = "aa".repeat(32),414                    signingPublicKeyHex = "bb".repeat(32),415                    relayUrl = "wss://relay.example.test",416                    pairedAt = 2L,417                ),418            ),419            signingPrivateKey = sourceSigning.privateKey,420        )421422        // Receiver's in-memory registry. Starts empty; the source gets added423        // by the Grant path during adoption, and the two vouched peers get424        // appended when the manifest is applied.425        val receiverRegistry = mutableMapOf<String, PairedDevice>()426        val receiverPending = mutableListOf<com.peachpasswords.sync.PendingVouchedDevice>()427        val receiverRegistryStore = object : PairedDeviceRegistry {428            override fun getAll(): List<PairedDevice> = receiverRegistry.values.toList()429            override fun get(id: String): PairedDevice? = receiverRegistry[id]430            override fun getByPubkey(pubkeyHex: String): PairedDevice? =431                receiverRegistry.values.firstOrNull { it.longTermPublicKeyHex?.lowercase() == pubkeyHex.lowercase() }432            override fun upsert(device: PairedDevice) { receiverRegistry[device.deviceId] = device }433            override fun remove(deviceId: String) { receiverRegistry.remove(deviceId) }434            override fun listRevocations(): List<DeviceRevocation> = emptyList()435            override fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean {436                batch.removals.forEach { receiverRegistry.remove(it) }437                batch.upserts.forEach { receiverRegistry[it.deviceId] = it }438                return true439            }440            override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> = emptyList()441            override fun isRevoked(deviceId: String, longTermPublicKeyHex: String?): Boolean = false442            override fun clearRevocation(deviceId: String) {}443        }444        val receiverPendingStore = object : PendingVouchedRegistry {445            override fun getAll(): List<com.peachpasswords.sync.PendingVouchedDevice> = receiverPending.toList()446            override fun upsert(device: com.peachpasswords.sync.PendingVouchedDevice) { receiverPending += device }447            override fun remove(id: String) { receiverPending.removeAll { it.deviceId == id } }448            override fun removeBatch(ids: Set<String>): Boolean {449                receiverPending.removeAll { it.deviceId in ids }450                return true451            }452            override fun restoreBatch(devices: List<com.peachpasswords.sync.PendingVouchedDevice>): Boolean = true453        }454        var receiverAppliedManifest: SignedDeviceManifest? = null455456        val sourceJob = async {457            runRelayAdoptionSourceSession(458                sourceSocket,459                token,460                relay,461                sourceKeys.secretKey,462                sourceKeys.publicKey,463                sourceIdentity,464                object : AdoptionSourceV2Deps {465        override suspend fun rotateSourceIdentity() = sourceIdentity466        override fun hasDeviceIdentityCollision(device: PairedDevice) = false467        override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) = true468        override suspend fun completePendingIdentity(device: PairedDevice) = true469        override fun snapshotCapable() = true470471                    override val sourceIdentity = sourceIdentity472                    override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)473                    override suspend fun vaultId() = computeVaultId(root)474                    override suspend fun license() = "PPK-test"475                    override suspend fun onConsent(request: AdoptionFrame.Request) = true476                    override suspend fun onConsent(request: AdoptionFrame.RequestV2) = true477                    override suspend fun upsertPairedDevice(device: PairedDevice) {}478                    override suspend fun vaultSnapshot() = smallSnapshot()479                    override suspend fun duressSlotJson() = null480                    override suspend fun outgoingDeviceManifest() = manifest481                    override suspend fun markSynced(deviceId: String) {}482                },483                confirm = { _, _ -> true },484            )485        }486487        val received = runRelayAdoptionReceiverSession(488            receiverSocket,489            PairingInvitation(490                relay,491                token,492                noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),493            ),494            object : AdoptionReceiverV2Deps {495        override fun localIdentity() = receiverIdentity496        override suspend fun rotateLocalIdentity() = localIdentity()497        override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {}498        override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false499500                override val deviceName = "receiver"501                override val deviceType = DeviceType.ANDROID502                override fun generateDeviceId() = "session-only-request"503                override suspend fun confirmSas() = true504                override suspend fun createVaultFromRootKey(rootKeyHex: String) =505                    computeVaultId(hexToPubkey(rootKeyHex))506                override suspend fun deriveLocalIdentity() = receiverIdentity507                override suspend fun upsertPairedDevice(device: PairedDevice) {508                    receiverRegistry[device.deviceId] = device509                }510                override suspend fun applySnapshot(511                    entries: Map<String, String>,512                    tombstones: Map<String, Long>,513                    duressSlot: String?,514                ) {}515                override suspend fun applyIncomingDeviceManifest(516                    manifest: SignedDeviceManifest,517                    authenticatedRemoteStaticPublicKey: ByteArray,518                ) {519                    receiverAppliedManifest = manifest520                    val adapter = VaultSyncAdapter(521                        repo = createReceiverVault(root),522                        ourDeviceId = "receiver-id",523                        pairedDevices = receiverRegistryStore,524                        pendingVouched = receiverPendingStore,525                        localCredentials = randomLocalIdentityCredentials("receiver-id"),526                    )527                    adapter.applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey)528                }529                override suspend fun markSynced(deviceId: String) {}530                override fun onError(error: String) = throw AssertionError(error)531            },532            confirm = { true },533        )534535        assertNotNull(received)536        sourceJob.await()537        assertNotNull("source must have delivered the manifest frame", receiverAppliedManifest)538        // After the adoption + manifest apply, the receiver's registry must539        // contain the source (added by Grant) AND the two vouched peers.540        val deviceIds = receiverRegistry.keys.toSet()541        assertEquals(542            "receiver should see the source + both vouched peers",543            setOf("source-id", "peer-b", "peer-c"),544            deviceIds,545        )546        // The two vouched peers arrive with trustSource=vouched, not direct.547        assertEquals("vouched", receiverRegistry["peer-b"]?.trustSource)548        assertEquals("vouched", receiverRegistry["peer-c"]?.trustSource)549        // The pending store is drained after a successful commit.550        assertTrue("pending vouched should be cleared", receiverPending.isEmpty())551    }552553    private suspend fun createReceiverVault(root: ByteArray): com.peachpasswords.vault.VaultRepository {554        val repo = com.peachpasswords.vault.VaultRepository(ReceiverFakeStorage())555        repo.createVault(root.joinToString("") { "%02x".format(it) })556        return repo557    }558559    private class ReceiverFakeStorage : com.peachpasswords.vault.VaultStorage {560        private var slotA: com.peachpasswords.vault.StoredVault? = null561        private var slotB: String? = null562        private var decoy: com.peachpasswords.vault.StoredVault? = null563        private var ver: Long = 0564        private var settings = com.peachpasswords.vault.VaultSettings()565        override fun loadSlotA() = slotA566        override fun hasVault() = slotA != null567        override fun writeSlotA(slot: com.peachpasswords.vault.StoredVault): Boolean { slotA = slot; return true }568        override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }569        override fun loadDecoySlot() = decoy570        override fun writeDecoySlot(slot: com.peachpasswords.vault.StoredVault): Boolean { decoy = slot; return true }571        override fun hasDecoySlot() = decoy != null572        override fun clearDecoySlot() { decoy = null }573        override fun version() = ver574        override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }575        override fun loadSettings() = settings576        override fun writeSettings(s: com.peachpasswords.vault.VaultSettings): Boolean { settings = s; return true }577        override fun clearVault(): Boolean { slotA = null; slotB = null; ver = 0; settings = com.peachpasswords.vault.VaultSettings(); return true }578    }579}580581/**582 * PB-26-103: the invitation pins the responder's Noise static so the scanner583 * can authenticate the cleartext PEACH_HELLO_ACK before running Noise. Without584 * the pin a relay can terminate both sessions and present its own key to each585 * side, which is only detectable today by a human comparing the SAS.586 *587 * These cover both failure shapes in the receiver direction, matching the588 * phone-source direction tests in RelayAdoptionInitiatorSourceTest.589 */590class RelayAdoptionInvitationPinTest {591592    /**593     * Time-boxed so these adversarial tests fail rather than hang: without the594     * pin check the receiver completes a handshake against the substituted key595     * and then stalls on a protocol step the attacker never performs.596     */597    private val HANDSHAKE_TIMEOUT_MS = 5_000L598599    private class MemoryRelaySocket(600        private val inbound: Channel<ByteArray>,601        private val outbound: Channel<ByteArray>,602    ) : RelayBinarySocket {603        override fun send(frame: ByteArray) {604            check(outbound.trySend(frame.copyOf()).isSuccess)605        }606607        override suspend fun receive(): ByteArray = inbound.receive()608        override fun close(code: Int, reason: String) {609            outbound.close()610        }611612        override fun closeAfterQueuedFrames(code: Int, reason: String) {613            outbound.close()614        }615    }616617    private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {618        val leftToRight = Channel<ByteArray>(Channel.UNLIMITED)619        val rightToLeft = Channel<ByteArray>(Channel.UNLIMITED)620        return MemoryRelaySocket(rightToLeft, leftToRight) to621            MemoryRelaySocket(leftToRight, rightToLeft)622    }623624    /**625     * Every protocol member throws: reaching any of them means we got past the626     * pin check. `onError` only records, so the positive control below can end627     * the session cleanly at the SAS gate.628     */629    private fun abortingDeps(630        confirmSas: Boolean = false,631        generateDeviceId: () -> String = { error("past the pin check") },632    ) = object : AdoptionReceiverV2Deps {633        override fun localIdentity() = LocalDeviceIdentity(generateDeviceId(), deviceName, deviceType, "", "")634        override suspend fun rotateLocalIdentity() = localIdentity()635        override suspend fun validateSource(prepare: AdoptionFrame.Prepare) {}636        override suspend fun hasSourceIdentityCollision(prepare: AdoptionFrame.Prepare) = false637638        override val deviceName = "phone"639        override val deviceType = DeviceType.ANDROID640        override fun generateDeviceId() = generateDeviceId()641        override suspend fun confirmSas() = confirmSas642        override suspend fun createVaultFromRootKey(rootKeyHex: String): String =643            error("past the pin check")644        override suspend fun deriveLocalIdentity(): LocalDeviceIdentity = error("past the pin check")645        override suspend fun upsertPairedDevice(device: PairedDevice) = error("past the pin check")646        override fun onError(error: String) = Unit647    }648649    private fun ack(publicKey: ByteArray) = org.json.JSONObject()650        .put("type", "PEACH_HELLO_ACK")651        .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(publicKey))652        .toString()653        .toByteArray()654655    @Test fun `receiver rejects an ACK static that does not match the pinned key`() = runBlocking {656        val (phoneSocket, mitmSocket) = socketPair()657        val root = ByteArray(32) { (it + 3).toByte() }658        val sourceKeys = localSyncKeypair(randomLocalIdentityCredentials("source-id"))659        val attackerKeys = localSyncKeypair(randomLocalIdentityCredentials("attacker-id"))660661        // The result is captured inside the job: an exception escaping a child662        // coroutine cancels the parent scope before await() can observe it.663        val phoneJob = async {664            try {665                runRelayAdoptionReceiverSession(666                    socket = phoneSocket,667                    invitation = PairingInvitation(668                        "wss://relay.example.test",669                        "AAECAwQFBgcICQoLDA0ODw",670                        VaultStatus.POPULATED,671                        Rendezvous.bytesToHex(sourceKeys.publicKey),672                    ),673                    receiverDeps = abortingDeps(),674                    confirm = { error("past the pin check") },675                )676                "completed"677            } catch (expected: IllegalArgumentException) {678                expected.message679            }680        }681682        mitmSocket.receive() // PEACH_HELLO683        mitmSocket.send(ack(attackerKeys.publicKey))684685        val outcome = withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() }686        phoneJob.cancel()687        assertEquals("pairing_static_key_mismatch", outcome)688    }689690    @Test fun `receiver rejects an invitation that pins no static key`() = runBlocking {691        val (phoneSocket, mitmSocket) = socketPair()692        val root = ByteArray(32) { (it + 13).toByte() }693        val attackerKeys = localSyncKeypair(randomLocalIdentityCredentials("attacker-id"))694695        val phoneJob = async {696            try {697                runRelayAdoptionReceiverSession(698                    socket = phoneSocket,699                    // A pre-fix invitation. Accepting it here reopens the hole.700                    invitation = PairingInvitation(701                        "wss://relay.example.test",702                        "AAECAwQFBgcICQoLDA0ODw",703                        VaultStatus.POPULATED,704                    ),705                    receiverDeps = abortingDeps(),706                    confirm = { error("past the pin check") },707                )708                "completed"709            } catch (expected: IllegalArgumentException) {710                expected.message711            }712        }713714        mitmSocket.receive() // PEACH_HELLO715        mitmSocket.send(ack(attackerKeys.publicKey))716717        val outcome = withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() }718        phoneJob.cancel()719        assertEquals("pairing_invitation_missing_static_key", outcome)720    }721722    // Positive control: the honest source sends exactly the pinned static, so723    // the check must let the handshake through to the SAS gate.724    @Test fun `receiver proceeds when the ACK static matches the pinned key`() = runBlocking {725        val (phoneSocket, sourceSocket) = socketPair()726        val root = ByteArray(32) { (it + 17).toByte() }727        val sourceKeys = localSyncKeypair(randomLocalIdentityCredentials("source-id"))728        val reached = java.util.concurrent.atomic.AtomicBoolean(false)729730        val phoneJob = async {731            runRelayAdoptionReceiverSession(732                socket = phoneSocket,733                invitation = PairingInvitation(734                    "wss://relay.example.test",735                    "AAECAwQFBgcICQoLDA0ODw",736                    VaultStatus.POPULATED,737                    Rendezvous.bytesToHex(sourceKeys.publicKey),738                ),739                receiverDeps = abortingDeps(740                    confirmSas = false,741                    // requestAdoption mints the device id to build its frame, so742                    // reaching this call proves the handshake ran.743                    generateDeviceId = { reached.set(true); "session-only" },744                ),745                confirm = { true },746            )747        }748749        sourceSocket.receive() // PEACH_HELLO750        sourceSocket.send(ack(sourceKeys.publicKey))751        val noise = noiseNKResponder(752            sourceKeys.secretKey,753            sourceKeys.publicKey,754            sourceSocket::send,755            sourceSocket::receive,756        )757        try {758            val channel = object : PairingTransport {759                override suspend fun send(msg: ByteArray) = sourceSocket.send(noise.encrypt(msg))760                override suspend fun receive(): ByteArray = noise.decrypt(sourceSocket.receive())761                override suspend fun close() = sourceSocket.close()762            }763            // Read and discard the receiver's request: the handshake completing764            // at all is what this control asserts.765            receiveFrame(channel)766        } finally {767            noise.close()768        }769770        // Declining at the SAS gate ends the session without pairing, which771        // proves the pin check let the matching static through.772        val outcome = withTimeoutOrNull(HANDSHAKE_TIMEOUT_MS) { phoneJob.await() }773        // runBlocking waits for its children, and the receiver side may still774        // be parked on the protocol.775        phoneJob.cancel()776        assertNull(outcome)777        assertTrue(778            "the matching static must reach the adoption request, not be refused",779            reached.get(),780        )781    }782}