RelayAdoptionReceiverTest.kt

24.6 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(relay, token),123            object : AdoptionReceiverDeps {124                override val deviceName = "receiver"125                override val deviceType = DeviceType.ANDROID126                override fun generateDeviceId() = "session-only-request"127                override suspend fun confirmSas() = true128                override suspend fun createVaultFromRootKey(rootKeyHex: String): String =129                    computeVaultId(hexToPubkey(rootKeyHex))130                override suspend fun deriveLocalIdentity() = receiverIdentity131                override suspend fun upsertPairedDevice(device: PairedDevice) {132                    receiverStored = device133                }134                override suspend fun applySnapshot(135                    entries: Map<String, String>,136                    tombstones: Map<String, Long>,137                    duressSlot: String?,138                ) {139                    receiverAppliedEntries = entries140                    receiverAppliedTombstones = tombstones141                    receiverAppliedDuress = duressSlot142                }143                override suspend fun markSynced(deviceId: String) {144                    receiverMarkedSynced = true145                }146                override fun onError(error: String) = throw AssertionError(error)147            },148            confirm = { true },149        )150151        assertNotNull(received)152        assertNotNull(sourceJob.await())153        assertEquals(entries, receiverAppliedEntries)154        assertEquals(tombstones, receiverAppliedTombstones)155        assertEquals(duressJson, receiverAppliedDuress)156        assertTrue("receiver should mark synced after applying snapshot", receiverMarkedSynced)157        assertTrue("source should mark synced after SnapshotApplied", sourceMarkedSynced)158        assertNotNull(receiverStored)159        assertNotNull(sourceStored)160    }161162    @Test fun `receiver falls back to sync when the source sends no snapshot`() = runBlocking {163        val (sourceSocket, receiverSocket) = socketPair()164        val token = "AAECAwQFBgcICQoLDA0ODw"165        val relay = "wss://relay.example.test"166        val root = ByteArray(32) { (it + 5).toByte() }167        val sourceKeys = deriveLocalSyncKeypair(root, "source-id")168        val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")169        val sourceIdentity = identity("source", sourceKeys.publicKey)170        val receiverIdentity = identity("receiver", receiverKeys.publicKey)171        var sourceStored: PairedDevice? = null172        var receiverStored: PairedDevice? = null173        var receiverApplied = false174        var receiverMarkedSynced = false175176        val sourceJob = async {177            runRelayAdoptionSourceSession(178                sourceSocket,179                token,180                relay,181                sourceKeys.secretKey,182                sourceKeys.publicKey,183                sourceIdentity,184                object : AdoptionSourceDeps {185                    override val sourceIdentity = sourceIdentity186                    override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)187                    override suspend fun vaultId() = computeVaultId(root)188                    override suspend fun license() = "PPK-test"189                    override suspend fun onConsent(request: AdoptionFrame.Request) = true190                    override suspend fun upsertPairedDevice(device: PairedDevice) {191                        sourceStored = device192                    }193                    // vaultSnapshot() returns null -> source does not send a snapshot.194                    override suspend fun vaultSnapshot() = null195                },196                confirm = { _, _ -> true },197            )198        }199200        val received = runRelayAdoptionReceiverSession(201            receiverSocket,202            PairingInvitation(relay, token),203            object : AdoptionReceiverDeps {204                override val deviceName = "receiver"205                override val deviceType = DeviceType.ANDROID206                override fun generateDeviceId() = "session-only-request"207                override suspend fun confirmSas() = true208                override suspend fun createVaultFromRootKey(rootKeyHex: String): String =209                    computeVaultId(hexToPubkey(rootKeyHex))210                override suspend fun deriveLocalIdentity() = receiverIdentity211                override suspend fun upsertPairedDevice(device: PairedDevice) {212                    receiverStored = device213                }214                override suspend fun applySnapshot(215                    entries: Map<String, String>,216                    tombstones: Map<String, Long>,217                    duressSlot: String?,218                ) {219                    receiverApplied = true220                }221                override suspend fun markSynced(deviceId: String) {222                    receiverMarkedSynced = true223                }224                override fun onError(error: String) = throw AssertionError(error)225            },226            confirm = { true },227        )228229        assertNotNull(received)230        assertNotNull(sourceJob.await())231        assertFalse("applySnapshot must NOT be called when source sends none", receiverApplied)232        assertFalse("markSynced must NOT be called when no snapshot was applied", receiverMarkedSynced)233        assertNotNull(receiverStored)234        assertNotNull(sourceStored)235    }236237    @Test fun `receiver skips the inline wait when the grant is not snapshot capable`() = runBlocking {238        val (sourceSocket, receiverSocket) = socketPair()239        val token = "AAECAwQFBgcICQoLDA0ODw"240        val relay = "wss://relay.example.test"241        val root = ByteArray(32) { (it + 7).toByte() }242        val sourceKeys = deriveLocalSyncKeypair(root, "old-source-id")243        val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")244        val sourceIdentity = identity("old-source", sourceKeys.publicKey)245        val receiverIdentity = identity("receiver", receiverKeys.publicKey)246        var receiverApplied = false247248        val sourceJob = async {249            sourceSocket.receive()250            sourceSocket.send(251                org.json.JSONObject().put("type", "PEACH_HELLO_ACK")252                    .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(sourceKeys.publicKey))253                    .toString().toByteArray(),254            )255            val noise = noiseNKResponder(256                sourceKeys.secretKey,257                sourceKeys.publicKey,258                sourceSocket::send,259                sourceSocket::receive,260            )261            try {262                val channel = object : PairingTransport {263                    override suspend fun send(msg: ByteArray) = sourceSocket.send(noise.encrypt(msg))264                    override suspend fun receive(): ByteArray = noise.decrypt(sourceSocket.receive())265                    override suspend fun close() = sourceSocket.close()266                }267                assertTrue(receiveFrame(channel) is AdoptionFrame.Request)268                assertTrue(receiveFrame(channel) is AdoptionFrame.Confirm)269                sendFrame(channel, AdoptionFrame.Grant(270                    rootKeyHex = Rendezvous.bytesToHex(root),271                    vaultId = computeVaultId(root),272                    license = "PPK-test",273                    source = AdoptionFrame.Source(274                        deviceId = sourceIdentity.deviceId,275                        deviceName = sourceIdentity.deviceName,276                        deviceType = sourceIdentity.deviceType.wireValue,277                        longTermPublicKeyHex = sourceIdentity.longTermPublicKeyHex,278                        signingPublicKeyHex = sourceIdentity.signingPublicKeyHex,279                    ),280                    snapshotCapable = false,281                ))282                assertTrue(receiveFrame(channel) is AdoptionFrame.Identity)283                sendFrame(channel, AdoptionFrame.Complete)284                assertTrue(receiveFrame(channel) is AdoptionFrame.Applied)285            } finally {286                noise.close()287            }288        }289290        var received: PairedDevice? = null291        val elapsedMs = measureTimeMillis {292            received = runRelayAdoptionReceiverSession(293                receiverSocket,294                PairingInvitation(relay, token),295                object : AdoptionReceiverDeps {296                    override val deviceName = "receiver"297                    override val deviceType = DeviceType.ANDROID298                    override fun generateDeviceId() = "session-only-request"299                    override suspend fun confirmSas() = true300                    override suspend fun createVaultFromRootKey(rootKeyHex: String) =301                        computeVaultId(hexToPubkey(rootKeyHex))302                    override suspend fun deriveLocalIdentity() = receiverIdentity303                    override suspend fun upsertPairedDevice(device: PairedDevice) = Unit304                    override suspend fun applySnapshot(305                        entries: Map<String, String>,306                        tombstones: Map<String, Long>,307                        duressSlot: String?,308                    ) {309                        receiverApplied = true310                    }311                    override fun onError(error: String) = throw AssertionError(error)312                },313                confirm = { true },314            )315        }316317        assertNotNull(received)318        sourceJob.await()319        assertFalse(receiverApplied)320        assertTrue("legacy grant should skip the 15s snapshot wait", elapsedMs < 5_000)321    }322323    @Test fun `receiver graduates into the mesh when the source sends a device manifest`() = runBlocking {324        // Mesh propagation: a 3rd device pairs with a pool of 2 already-paired325        // devices. The third must graduate into the full mesh during the same326        // adoption session, not just learn about the source. Verifies the327        // adoption-receiver apply path wires applyIncomingDeviceManifest to328        // the adapter and the adapter puts the vouched peers into the local329        // registry.330        val (sourceSocket, receiverSocket) = socketPair()331        val token = "AAECAwQFBgcICQoLDA0ODw"332        val relay = "wss://relay.example.test"333        val root = ByteArray(32) { (it + 13).toByte() }334        val sourceKeys = deriveLocalSyncKeypair(root, "source-id")335        val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")336        val sourceSigning = deriveSyncSigningKeypair(root, "source-id")337        val sourceIdentity = identity("source", sourceKeys.publicKey, pubkeyToHex(sourceSigning.publicKeyRaw))338        val receiverIdentity = identity("receiver", receiverKeys.publicKey)339340        // The source's pool already contains two direct-trust peers (B and C).341        // The signed manifest is what the source transmits during adoption.342        val manifest = buildSignedDeviceManifest(343            issuerDeviceId = "source-id",344            devices = listOf(345                ManifestDeviceEntry(346                    deviceId = "peer-b",347                    deviceName = "Pool B",348                    deviceType = "extension",349                    longTermPublicKeyHex = "11".repeat(32),350                    signingPublicKeyHex = "22".repeat(32),351                    relayUrl = "wss://relay.example.test",352                    pairedAt = 1L,353                ),354                ManifestDeviceEntry(355                    deviceId = "peer-c",356                    deviceName = "Pool C",357                    deviceType = "android",358                    longTermPublicKeyHex = "aa".repeat(32),359                    signingPublicKeyHex = "bb".repeat(32),360                    relayUrl = "wss://relay.example.test",361                    pairedAt = 2L,362                ),363            ),364            signingPrivateKey = sourceSigning.privateKey,365        )366367        // Receiver's in-memory registry. Starts empty; the source gets added368        // by the Grant path during adoption, and the two vouched peers get369        // appended when the manifest is applied.370        val receiverRegistry = mutableMapOf<String, PairedDevice>()371        val receiverPending = mutableListOf<com.peachpasswords.sync.PendingVouchedDevice>()372        val receiverRegistryStore = object : PairedDeviceRegistry {373            override fun getAll(): List<PairedDevice> = receiverRegistry.values.toList()374            override fun get(id: String): PairedDevice? = receiverRegistry[id]375            override fun getByPubkey(pubkeyHex: String): PairedDevice? =376                receiverRegistry.values.firstOrNull { it.longTermPublicKeyHex?.lowercase() == pubkeyHex.lowercase() }377            override fun upsert(device: PairedDevice) { receiverRegistry[device.deviceId] = device }378            override fun remove(deviceId: String) { receiverRegistry.remove(deviceId) }379            override fun listRevocations(): List<DeviceRevocation> = emptyList()380            override fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean {381                batch.removals.forEach { receiverRegistry.remove(it) }382                batch.upserts.forEach { receiverRegistry[it.deviceId] = it }383                return true384            }385            override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> = emptyList()386            override fun isRevoked(deviceId: String, longTermPublicKeyHex: String?): Boolean = false387            override fun clearRevocation(deviceId: String) {}388        }389        val receiverPendingStore = object : PendingVouchedRegistry {390            override fun getAll(): List<com.peachpasswords.sync.PendingVouchedDevice> = receiverPending.toList()391            override fun upsert(device: com.peachpasswords.sync.PendingVouchedDevice) { receiverPending += device }392            override fun remove(id: String) { receiverPending.removeAll { it.deviceId == id } }393            override fun removeBatch(ids: Set<String>): Boolean {394                receiverPending.removeAll { it.deviceId in ids }395                return true396            }397            override fun restoreBatch(devices: List<com.peachpasswords.sync.PendingVouchedDevice>): Boolean = true398        }399        var receiverAppliedManifest: SignedDeviceManifest? = null400401        val sourceJob = async {402            runRelayAdoptionSourceSession(403                sourceSocket,404                token,405                relay,406                sourceKeys.secretKey,407                sourceKeys.publicKey,408                sourceIdentity,409                object : AdoptionSourceDeps {410                    override val sourceIdentity = sourceIdentity411                    override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)412                    override suspend fun vaultId() = computeVaultId(root)413                    override suspend fun license() = "PPK-test"414                    override suspend fun onConsent(request: AdoptionFrame.Request) = true415                    override suspend fun upsertPairedDevice(device: PairedDevice) {}416                    override suspend fun vaultSnapshot() = smallSnapshot()417                    override suspend fun duressSlotJson() = null418                    override suspend fun outgoingDeviceManifest() = manifest419                    override suspend fun markSynced(deviceId: String) {}420                },421                confirm = { _, _ -> true },422            )423        }424425        val received = runRelayAdoptionReceiverSession(426            receiverSocket,427            PairingInvitation(relay, token),428            object : AdoptionReceiverDeps {429                override val deviceName = "receiver"430                override val deviceType = DeviceType.ANDROID431                override fun generateDeviceId() = "session-only-request"432                override suspend fun confirmSas() = true433                override suspend fun createVaultFromRootKey(rootKeyHex: String) =434                    computeVaultId(hexToPubkey(rootKeyHex))435                override suspend fun deriveLocalIdentity() = receiverIdentity436                override suspend fun upsertPairedDevice(device: PairedDevice) {437                    receiverRegistry[device.deviceId] = device438                }439                override suspend fun applySnapshot(440                    entries: Map<String, String>,441                    tombstones: Map<String, Long>,442                    duressSlot: String?,443                ) {}444                override suspend fun applyIncomingDeviceManifest(445                    manifest: SignedDeviceManifest,446                    authenticatedRemoteStaticPublicKey: ByteArray,447                ) {448                    receiverAppliedManifest = manifest449                    val adapter = VaultSyncAdapter(450                        repo = createReceiverVault(root),451                        ourDeviceId = "receiver-id",452                        pairedDevices = receiverRegistryStore,453                        pendingVouched = receiverPendingStore,454                    )455                    adapter.applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey)456                }457                override suspend fun markSynced(deviceId: String) {}458                override fun onError(error: String) = throw AssertionError(error)459            },460            confirm = { true },461        )462463        assertNotNull(received)464        sourceJob.await()465        assertNotNull("source must have delivered the manifest frame", receiverAppliedManifest)466        // After the adoption + manifest apply, the receiver's registry must467        // contain the source (added by Grant) AND the two vouched peers.468        val deviceIds = receiverRegistry.keys.toSet()469        assertEquals(470            "receiver should see the source + both vouched peers",471            setOf("source-id", "peer-b", "peer-c"),472            deviceIds,473        )474        // The two vouched peers arrive with trustSource=vouched, not direct.475        assertEquals("vouched", receiverRegistry["peer-b"]?.trustSource)476        assertEquals("vouched", receiverRegistry["peer-c"]?.trustSource)477        // The pending store is drained after a successful commit.478        assertTrue("pending vouched should be cleared", receiverPending.isEmpty())479    }480481    private suspend fun createReceiverVault(root: ByteArray): com.peachpasswords.vault.VaultRepository {482        val repo = com.peachpasswords.vault.VaultRepository(ReceiverFakeStorage())483        repo.createVault(root.joinToString("") { "%02x".format(it) })484        return repo485    }486487    private class ReceiverFakeStorage : com.peachpasswords.vault.VaultStorage {488        private var slotA: com.peachpasswords.vault.StoredVault? = null489        private var slotB: String? = null490        private var decoy: com.peachpasswords.vault.StoredVault? = null491        private var ver: Long = 0492        private var settings = com.peachpasswords.vault.VaultSettings()493        override fun loadSlotA() = slotA494        override fun hasVault() = slotA != null495        override fun writeSlotA(slot: com.peachpasswords.vault.StoredVault): Boolean { slotA = slot; return true }496        override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }497        override fun loadDecoySlot() = decoy498        override fun writeDecoySlot(slot: com.peachpasswords.vault.StoredVault): Boolean { decoy = slot; return true }499        override fun hasDecoySlot() = decoy != null500        override fun clearDecoySlot() { decoy = null }501        override fun version() = ver502        override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }503        override fun loadSettings() = settings504        override fun writeSettings(s: com.peachpasswords.vault.VaultSettings): Boolean { settings = s; return true }505        override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = com.peachpasswords.vault.VaultSettings() }506    }507}