RelayAdoptionReceiverTest.kt

25.2 KB · raw

1package com.peachpasswords.sync23import kotlinx.coroutines.async4import kotlinx.coroutines.channels.Channel5import kotlinx.coroutines.runBlocking6import org.junit.Assert.assertEquals7import org.junit.Assert.assertFalse8import org.junit.Assert.assertNotNull9import org.junit.Assert.assertTrue10import org.junit.Assert.assertNull11import org.junit.Test12import kotlin.system.measureTimeMillis1314/**15 * Item 2 (REVIEW_DEFERRED_ITEMS_BRIEF.md): receiver inline-snapshot happy-path.16 *17 * The `sourceCanSendSnapshot` branch in `runRelayAdoptionReceiverSession` was18 * already reached by `RelayAdoptionHostTest` but only down the19 * catch-and-fallback path (source did not send a snapshot). What had no20 * coverage was the success path: a snapshot-capable source that actually21 * sends entries / tombstones / duress, the receiver applying them inline,22 * sending `ADOPT_SNAPSHOT_APPLIED`, and `markSynced` firing.23 *24 * These three tests cover the three sub-branches:25 * 1. snapshot_capable=true AND source sends a real snapshot -> applySnapshot26 *    called, SnapshotApplied sent, markSynced fired.27 * 2. snapshot_capable=true AND source sends nothing -> applySnapshot NOT28 *    called, session still returns paired device (sync fallback).29 * 3. snapshot_capable=false -> 15s wait is skipped, applySnapshot NOT called,30 *    session returns paired device quickly.31 */32class RelayAdoptionReceiverTest {33    private class MemoryRelaySocket(34        private val inbound: Channel<ByteArray>,35        private val outbound: Channel<ByteArray>,36    ) : RelayBinarySocket {37        override fun send(frame: ByteArray) {38            check(outbound.trySend(frame.copyOf()).isSuccess)39        }4041        override suspend fun receive(): ByteArray = inbound.receive()42        override fun close(code: Int, reason: String) {43            outbound.close()44        }4546        override fun closeAfterQueuedFrames(code: Int, reason: String) {47            outbound.close()48        }49    }5051    private fun socketPair(): Pair<RelayBinarySocket, RelayBinarySocket> {52        val leftToRight = Channel<ByteArray>(Channel.UNLIMITED)53        val rightToLeft = Channel<ByteArray>(Channel.UNLIMITED)54        return MemoryRelaySocket(rightToLeft, leftToRight) to55            MemoryRelaySocket(leftToRight, rightToLeft)56    }5758    private fun identity(name: String, publicKey: ByteArray, signingPublicKeyHex: String = "cd".repeat(32)) = LocalDeviceIdentity(59        deviceId = "$name-id",60        deviceName = name,61        deviceType = DeviceType.ANDROID,62        longTermPublicKeyHex = Rendezvous.bytesToHex(publicKey),63        signingPublicKeyHex = signingPublicKeyHex,64        wsPort = 49142,65        relayUrl = "wss://relay.example.test",66    )6768    private fun smallSnapshot(): Pair<Map<String, String>, Map<String, Long>> {69        val entries = mapOf("id-0" to "ciphertext-0", "id-1" to "ciphertext-1")70        val tombstones = mapOf("dead-0" to 1L)71        return entries to tombstones72    }7374    @Test fun `receiver applies an inline snapshot when the source is snapshot capable`() = runBlocking {75        val (sourceSocket, receiverSocket) = socketPair()76        val token = "AAECAwQFBgcICQoLDA0ODw"77        val relay = "wss://relay.example.test"78        val root = ByteArray(32) { (it + 3).toByte() }79        val sourceKeys = deriveLocalSyncKeypair(root, "source-id")80        val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")81        val sourceIdentity = identity("source", sourceKeys.publicKey)82        val receiverIdentity = identity("receiver", receiverKeys.publicKey)83        val (entries, tombstones) = smallSnapshot()84        val duressJson = "{\"duress\":true}"85        var sourceStored: PairedDevice? = null86        var receiverStored: PairedDevice? = null87        var sourceMarkedSynced = false88        var receiverAppliedEntries: Map<String, String>? = null89        var receiverAppliedTombstones: Map<String, Long>? = null90        var receiverAppliedDuress: String? = null91        var receiverMarkedSynced = false9293        val sourceJob = async {94            runRelayAdoptionSourceSession(95                sourceSocket,96                token,97                relay,98                sourceKeys.secretKey,99                sourceKeys.publicKey,100                sourceIdentity,101                object : AdoptionSourceDeps {102                    override val sourceIdentity = sourceIdentity103                    override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)104                    override suspend fun vaultId() = computeVaultId(root)105                    override suspend fun license() = "PPK-test"106                    override suspend fun onConsent(request: AdoptionFrame.Request) = true107                    override suspend fun upsertPairedDevice(device: PairedDevice) {108                        sourceStored = device109                    }110                    override suspend fun vaultSnapshot() = smallSnapshot()111                    override suspend fun duressSlotJson() = duressJson112                    override suspend fun markSynced(deviceId: String) {113                        sourceMarkedSynced = true114                    }115                },116                confirm = { _, _ -> true },117            )118        }119120        val received = runRelayAdoptionReceiverSession(121            receiverSocket,122            PairingInvitation(123                relay,124                token,125                noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),126            ),127            object : AdoptionReceiverDeps {128                override val deviceName = "receiver"129                override val deviceType = DeviceType.ANDROID130                override fun generateDeviceId() = "session-only-request"131                override suspend fun confirmSas() = true132                override suspend fun createVaultFromRootKey(rootKeyHex: String): String =133                    computeVaultId(hexToPubkey(rootKeyHex))134                override suspend fun deriveLocalIdentity() = receiverIdentity135                override suspend fun upsertPairedDevice(device: PairedDevice) {136                    receiverStored = device137                }138                override suspend fun applySnapshot(139                    entries: Map<String, String>,140                    tombstones: Map<String, Long>,141                    duressSlot: String?,142                ) {143                    receiverAppliedEntries = entries144                    receiverAppliedTombstones = tombstones145                    receiverAppliedDuress = duressSlot146                }147                override suspend fun markSynced(deviceId: String) {148                    receiverMarkedSynced = true149                }150                override fun onError(error: String) = throw AssertionError(error)151            },152            confirm = { true },153        )154155        assertNotNull(received)156        assertNotNull(sourceJob.await())157        assertEquals(entries, receiverAppliedEntries)158        assertEquals(tombstones, receiverAppliedTombstones)159        assertEquals(duressJson, receiverAppliedDuress)160        assertTrue("receiver should mark synced after applying snapshot", receiverMarkedSynced)161        assertTrue("source should mark synced after SnapshotApplied", sourceMarkedSynced)162        assertNotNull(receiverStored)163        assertNotNull(sourceStored)164    }165166    @Test fun `receiver falls back to sync when the source sends no snapshot`() = runBlocking {167        val (sourceSocket, receiverSocket) = socketPair()168        val token = "AAECAwQFBgcICQoLDA0ODw"169        val relay = "wss://relay.example.test"170        val root = ByteArray(32) { (it + 5).toByte() }171        val sourceKeys = deriveLocalSyncKeypair(root, "source-id")172        val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")173        val sourceIdentity = identity("source", sourceKeys.publicKey)174        val receiverIdentity = identity("receiver", receiverKeys.publicKey)175        var sourceStored: PairedDevice? = null176        var receiverStored: PairedDevice? = null177        var receiverApplied = false178        var receiverMarkedSynced = false179180        val sourceJob = async {181            runRelayAdoptionSourceSession(182                sourceSocket,183                token,184                relay,185                sourceKeys.secretKey,186                sourceKeys.publicKey,187                sourceIdentity,188                object : AdoptionSourceDeps {189                    override val sourceIdentity = sourceIdentity190                    override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)191                    override suspend fun vaultId() = computeVaultId(root)192                    override suspend fun license() = "PPK-test"193                    override suspend fun onConsent(request: AdoptionFrame.Request) = true194                    override suspend fun upsertPairedDevice(device: PairedDevice) {195                        sourceStored = device196                    }197                    // vaultSnapshot() returns null -> source does not send a snapshot.198                    override suspend fun vaultSnapshot() = null199                },200                confirm = { _, _ -> true },201            )202        }203204        val received = runRelayAdoptionReceiverSession(205            receiverSocket,206            PairingInvitation(207                relay,208                token,209                noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),210            ),211            object : AdoptionReceiverDeps {212                override val deviceName = "receiver"213                override val deviceType = DeviceType.ANDROID214                override fun generateDeviceId() = "session-only-request"215                override suspend fun confirmSas() = true216                override suspend fun createVaultFromRootKey(rootKeyHex: String): String =217                    computeVaultId(hexToPubkey(rootKeyHex))218                override suspend fun deriveLocalIdentity() = receiverIdentity219                override suspend fun upsertPairedDevice(device: PairedDevice) {220                    receiverStored = device221                }222                override suspend fun applySnapshot(223                    entries: Map<String, String>,224                    tombstones: Map<String, Long>,225                    duressSlot: String?,226                ) {227                    receiverApplied = true228                }229                override suspend fun markSynced(deviceId: String) {230                    receiverMarkedSynced = true231                }232                override fun onError(error: String) = throw AssertionError(error)233            },234            confirm = { true },235        )236237        assertNotNull(received)238        assertNotNull(sourceJob.await())239        assertFalse("applySnapshot must NOT be called when source sends none", receiverApplied)240        assertFalse("markSynced must NOT be called when no snapshot was applied", receiverMarkedSynced)241        assertNotNull(receiverStored)242        assertNotNull(sourceStored)243    }244245    @Test fun `receiver skips the inline wait when the grant is not snapshot capable`() = runBlocking {246        val (sourceSocket, receiverSocket) = socketPair()247        val token = "AAECAwQFBgcICQoLDA0ODw"248        val relay = "wss://relay.example.test"249        val root = ByteArray(32) { (it + 7).toByte() }250        val sourceKeys = deriveLocalSyncKeypair(root, "old-source-id")251        val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")252        val sourceIdentity = identity("old-source", sourceKeys.publicKey)253        val receiverIdentity = identity("receiver", receiverKeys.publicKey)254        var receiverApplied = false255256        val sourceJob = async {257            sourceSocket.receive()258            sourceSocket.send(259                org.json.JSONObject().put("type", "PEACH_HELLO_ACK")260                    .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(sourceKeys.publicKey))261                    .toString().toByteArray(),262            )263            val noise = noiseNKResponder(264                sourceKeys.secretKey,265                sourceKeys.publicKey,266                sourceSocket::send,267                sourceSocket::receive,268            )269            try {270                val channel = object : PairingTransport {271                    override suspend fun send(msg: ByteArray) = sourceSocket.send(noise.encrypt(msg))272                    override suspend fun receive(): ByteArray = noise.decrypt(sourceSocket.receive())273                    override suspend fun close() = sourceSocket.close()274                }275                assertTrue(receiveFrame(channel) is AdoptionFrame.Request)276                assertTrue(receiveFrame(channel) is AdoptionFrame.Confirm)277                sendFrame(channel, AdoptionFrame.Grant(278                    rootKeyHex = Rendezvous.bytesToHex(root),279                    vaultId = computeVaultId(root),280                    license = "PPK-test",281                    source = AdoptionFrame.Source(282                        deviceId = sourceIdentity.deviceId,283                        deviceName = sourceIdentity.deviceName,284                        deviceType = sourceIdentity.deviceType.wireValue,285                        longTermPublicKeyHex = sourceIdentity.longTermPublicKeyHex,286                        signingPublicKeyHex = sourceIdentity.signingPublicKeyHex,287                    ),288                    snapshotCapable = false,289                ))290                assertTrue(receiveFrame(channel) is AdoptionFrame.Identity)291                sendFrame(channel, AdoptionFrame.Complete)292                assertTrue(receiveFrame(channel) is AdoptionFrame.Applied)293            } finally {294                noise.close()295            }296        }297298        var received: PairedDevice? = null299        val elapsedMs = measureTimeMillis {300            received = runRelayAdoptionReceiverSession(301                receiverSocket,302                PairingInvitation(303                    relay,304                    token,305                    noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),306                ),307                object : AdoptionReceiverDeps {308                    override val deviceName = "receiver"309                    override val deviceType = DeviceType.ANDROID310                    override fun generateDeviceId() = "session-only-request"311                    override suspend fun confirmSas() = true312                    override suspend fun createVaultFromRootKey(rootKeyHex: String) =313                        computeVaultId(hexToPubkey(rootKeyHex))314                    override suspend fun deriveLocalIdentity() = receiverIdentity315                    override suspend fun upsertPairedDevice(device: PairedDevice) = Unit316                    override suspend fun applySnapshot(317                        entries: Map<String, String>,318                        tombstones: Map<String, Long>,319                        duressSlot: String?,320                    ) {321                        receiverApplied = true322                    }323                    override fun onError(error: String) = throw AssertionError(error)324                },325                confirm = { true },326            )327        }328329        assertNotNull(received)330        sourceJob.await()331        assertFalse(receiverApplied)332        assertTrue("legacy grant should skip the 15s snapshot wait", elapsedMs < 5_000)333    }334335    @Test fun `receiver graduates into the mesh when the source sends a device manifest`() = runBlocking {336        // Mesh propagation: a 3rd device pairs with a pool of 2 already-paired337        // devices. The third must graduate into the full mesh during the same338        // adoption session, not just learn about the source. Verifies the339        // adoption-receiver apply path wires applyIncomingDeviceManifest to340        // the adapter and the adapter puts the vouched peers into the local341        // registry.342        val (sourceSocket, receiverSocket) = socketPair()343        val token = "AAECAwQFBgcICQoLDA0ODw"344        val relay = "wss://relay.example.test"345        val root = ByteArray(32) { (it + 13).toByte() }346        val sourceKeys = deriveLocalSyncKeypair(root, "source-id")347        val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")348        val sourceSigning = deriveSyncSigningKeypair(root, "source-id")349        val sourceIdentity = identity("source", sourceKeys.publicKey, pubkeyToHex(sourceSigning.publicKeyRaw))350        val receiverIdentity = identity("receiver", receiverKeys.publicKey)351352        // The source's pool already contains two direct-trust peers (B and C).353        // The signed manifest is what the source transmits during adoption.354        val manifest = buildSignedDeviceManifest(355            issuerDeviceId = "source-id",356            devices = listOf(357                ManifestDeviceEntry(358                    deviceId = "peer-b",359                    deviceName = "Pool B",360                    deviceType = "extension",361                    longTermPublicKeyHex = "11".repeat(32),362                    signingPublicKeyHex = "22".repeat(32),363                    relayUrl = "wss://relay.example.test",364                    pairedAt = 1L,365                ),366                ManifestDeviceEntry(367                    deviceId = "peer-c",368                    deviceName = "Pool C",369                    deviceType = "android",370                    longTermPublicKeyHex = "aa".repeat(32),371                    signingPublicKeyHex = "bb".repeat(32),372                    relayUrl = "wss://relay.example.test",373                    pairedAt = 2L,374                ),375            ),376            signingPrivateKey = sourceSigning.privateKey,377        )378379        // Receiver's in-memory registry. Starts empty; the source gets added380        // by the Grant path during adoption, and the two vouched peers get381        // appended when the manifest is applied.382        val receiverRegistry = mutableMapOf<String, PairedDevice>()383        val receiverPending = mutableListOf<com.peachpasswords.sync.PendingVouchedDevice>()384        val receiverRegistryStore = object : PairedDeviceRegistry {385            override fun getAll(): List<PairedDevice> = receiverRegistry.values.toList()386            override fun get(id: String): PairedDevice? = receiverRegistry[id]387            override fun getByPubkey(pubkeyHex: String): PairedDevice? =388                receiverRegistry.values.firstOrNull { it.longTermPublicKeyHex?.lowercase() == pubkeyHex.lowercase() }389            override fun upsert(device: PairedDevice) { receiverRegistry[device.deviceId] = device }390            override fun remove(deviceId: String) { receiverRegistry.remove(deviceId) }391            override fun listRevocations(): List<DeviceRevocation> = emptyList()392            override fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean {393                batch.removals.forEach { receiverRegistry.remove(it) }394                batch.upserts.forEach { receiverRegistry[it.deviceId] = it }395                return true396            }397            override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> = emptyList()398            override fun isRevoked(deviceId: String, longTermPublicKeyHex: String?): Boolean = false399            override fun clearRevocation(deviceId: String) {}400        }401        val receiverPendingStore = object : PendingVouchedRegistry {402            override fun getAll(): List<com.peachpasswords.sync.PendingVouchedDevice> = receiverPending.toList()403            override fun upsert(device: com.peachpasswords.sync.PendingVouchedDevice) { receiverPending += device }404            override fun remove(id: String) { receiverPending.removeAll { it.deviceId == id } }405            override fun removeBatch(ids: Set<String>): Boolean {406                receiverPending.removeAll { it.deviceId in ids }407                return true408            }409            override fun restoreBatch(devices: List<com.peachpasswords.sync.PendingVouchedDevice>): Boolean = true410        }411        var receiverAppliedManifest: SignedDeviceManifest? = null412413        val sourceJob = async {414            runRelayAdoptionSourceSession(415                sourceSocket,416                token,417                relay,418                sourceKeys.secretKey,419                sourceKeys.publicKey,420                sourceIdentity,421                object : AdoptionSourceDeps {422                    override val sourceIdentity = sourceIdentity423                    override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)424                    override suspend fun vaultId() = computeVaultId(root)425                    override suspend fun license() = "PPK-test"426                    override suspend fun onConsent(request: AdoptionFrame.Request) = true427                    override suspend fun upsertPairedDevice(device: PairedDevice) {}428                    override suspend fun vaultSnapshot() = smallSnapshot()429                    override suspend fun duressSlotJson() = null430                    override suspend fun outgoingDeviceManifest() = manifest431                    override suspend fun markSynced(deviceId: String) {}432                },433                confirm = { _, _ -> true },434            )435        }436437        val received = runRelayAdoptionReceiverSession(438            receiverSocket,439            PairingInvitation(440                relay,441                token,442                noiseStaticPublicKeyHex = Rendezvous.bytesToHex(sourceKeys.publicKey),443            ),444            object : AdoptionReceiverDeps {445                override val deviceName = "receiver"446                override val deviceType = DeviceType.ANDROID447                override fun generateDeviceId() = "session-only-request"448                override suspend fun confirmSas() = true449                override suspend fun createVaultFromRootKey(rootKeyHex: String) =450                    computeVaultId(hexToPubkey(rootKeyHex))451                override suspend fun deriveLocalIdentity() = receiverIdentity452                override suspend fun upsertPairedDevice(device: PairedDevice) {453                    receiverRegistry[device.deviceId] = device454                }455                override suspend fun applySnapshot(456                    entries: Map<String, String>,457                    tombstones: Map<String, Long>,458                    duressSlot: String?,459                ) {}460                override suspend fun applyIncomingDeviceManifest(461                    manifest: SignedDeviceManifest,462                    authenticatedRemoteStaticPublicKey: ByteArray,463                ) {464                    receiverAppliedManifest = manifest465                    val adapter = VaultSyncAdapter(466                        repo = createReceiverVault(root),467                        ourDeviceId = "receiver-id",468                        pairedDevices = receiverRegistryStore,469                        pendingVouched = receiverPendingStore,470                    )471                    adapter.applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey)472                }473                override suspend fun markSynced(deviceId: String) {}474                override fun onError(error: String) = throw AssertionError(error)475            },476            confirm = { true },477        )478479        assertNotNull(received)480        sourceJob.await()481        assertNotNull("source must have delivered the manifest frame", receiverAppliedManifest)482        // After the adoption + manifest apply, the receiver's registry must483        // contain the source (added by Grant) AND the two vouched peers.484        val deviceIds = receiverRegistry.keys.toSet()485        assertEquals(486            "receiver should see the source + both vouched peers",487            setOf("source-id", "peer-b", "peer-c"),488            deviceIds,489        )490        // The two vouched peers arrive with trustSource=vouched, not direct.491        assertEquals("vouched", receiverRegistry["peer-b"]?.trustSource)492        assertEquals("vouched", receiverRegistry["peer-c"]?.trustSource)493        // The pending store is drained after a successful commit.494        assertTrue("pending vouched should be cleared", receiverPending.isEmpty())495    }496497    private suspend fun createReceiverVault(root: ByteArray): com.peachpasswords.vault.VaultRepository {498        val repo = com.peachpasswords.vault.VaultRepository(ReceiverFakeStorage())499        repo.createVault(root.joinToString("") { "%02x".format(it) })500        return repo501    }502503    private class ReceiverFakeStorage : com.peachpasswords.vault.VaultStorage {504        private var slotA: com.peachpasswords.vault.StoredVault? = null505        private var slotB: String? = null506        private var decoy: com.peachpasswords.vault.StoredVault? = null507        private var ver: Long = 0508        private var settings = com.peachpasswords.vault.VaultSettings()509        override fun loadSlotA() = slotA510        override fun hasVault() = slotA != null511        override fun writeSlotA(slot: com.peachpasswords.vault.StoredVault): Boolean { slotA = slot; return true }512        override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }513        override fun loadDecoySlot() = decoy514        override fun writeDecoySlot(slot: com.peachpasswords.vault.StoredVault): Boolean { decoy = slot; return true }515        override fun hasDecoySlot() = decoy != null516        override fun clearDecoySlot() { decoy = null }517        override fun version() = ver518        override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }519        override fun loadSettings() = settings520        override fun writeSettings(s: com.peachpasswords.vault.VaultSettings): Boolean { settings = s; return true }521        override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = com.peachpasswords.vault.VaultSettings() }522    }523}