VaultSyncAdapterTest.kt

34.3 KB · raw

1package com.peachpasswords.sync23import com.peachpasswords.vault.StoredVault4import com.peachpasswords.vault.VaultEntry5import com.peachpasswords.vault.VaultRepository6import com.peachpasswords.vault.VaultSettings7import com.peachpasswords.vault.VaultStorage8import kotlinx.coroutines.runBlocking9import org.junit.Assert.assertEquals10import org.junit.Assert.assertFalse11import org.junit.Assert.assertNull12import org.junit.Assert.assertTrue13import org.junit.Test1415private class FakeStorage : VaultStorage {16    private var slotA: StoredVault? = null17    private var slotB: String? = null18    private var ver: Long = 019    private var settings = VaultSettings()20    override fun loadSlotA() = slotA21    override fun hasVault() = slotA != null22    override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }23    override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }24    private var decoy: StoredVault? = null25    override fun loadDecoySlot() = decoy26    override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }27    override fun hasDecoySlot() = decoy != null28    override fun clearDecoySlot() { decoy = null }29    override fun version() = ver30    override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }31    override fun loadSettings() = settings32    override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }33    override fun clearVault(): Boolean { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); return true }34}3536class VaultSyncAdapterTest {37    private val meshRelay = "wss://relay.peachpasswords.com"38    private class FakeDeviceRegistry(39        devices: List<PairedDevice> = emptyList(),40    ) : PairedDeviceRegistry {41        var failManifestCommit = false42        private val byId = devices.associateBy { it.deviceId }.toMutableMap()43        private val revocations = mutableListOf<DeviceRevocation>()44        override fun getAll(): List<PairedDevice> = byId.values.toList()45        override fun get(id: String): PairedDevice? = byId[id]46        override fun upsert(device: PairedDevice) { byId[device.deviceId] = device }47        override fun remove(deviceId: String) { byId.remove(deviceId) }48        override fun listRevocations(): List<DeviceRevocation> = revocations.toList()49        override fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean {50            if (failManifestCommit) return false51            val nextRevocations = mergeRevocationRecords(revocations, batch.incomingRevocations).records.toMutableList()52            val nextDevices = byId.toMutableMap()53            batch.removals.forEach(nextDevices::remove)54            batch.upserts.forEach { nextDevices[it.deviceId] = it }55            revocations.clear()56            revocations += nextRevocations57            byId.clear()58            byId.putAll(nextDevices)59            return true60        }61        override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> {62            val merged = mergeRevocationRecords(revocations, incoming)63            revocations.clear()64            revocations += merged.records65            return merged.admitted.map { it.deviceId }66        }67        override fun clearRevocation(deviceId: String) {68            revocations.removeAll { it.deviceId == deviceId }69        }70    }7172    private class FakePendingRegistry : PendingVouchedRegistry {73        val devices = mutableListOf<PendingVouchedDevice>()74        override fun getAll(): List<PendingVouchedDevice> = devices.toList()75        override fun upsert(device: PendingVouchedDevice) {76            devices.removeAll { it.deviceId == device.deviceId }77            devices += device78        }79        override fun remove(id: String) { devices.removeAll { it.deviceId == id } }80    }8182    private fun localCredentials(deviceId: String) = randomLocalIdentityCredentials(deviceId)8384    private fun newAdapter(): Pair<VaultRepository, VaultSyncAdapter> {85        val repo = VaultRepository(FakeStorage())86        runBlocking { repo.createVault("correct horse") }87        return repo to VaultSyncAdapter(88            repo,89            ourDeviceId = "android-test",90            localCredentials = localCredentials("android-test"),91        )92    }9394    @Test fun manifest_and_vault_id_reflect_the_vault() = runBlocking {95        val (repo, adapter) = newAdapter()96        val e = repo.addEntry(VaultEntry(id = "", title = "GitHub", createdAt = 0, modifiedAt = 0, password = "p"))97        val manifest = adapter.buildEntryManifest()98        assertEquals(setOf(e.id), manifest.keys)99        assertTrue(adapter.computeVaultId().length == 64) // blake3-256 hex100    }101102    @Test fun envelope_round_trips_within_the_vault() = runBlocking {103        val (repo, adapter) = newAdapter()104        val e = repo.addEntry(VaultEntry(id = "", title = "Mail", createdAt = 0, modifiedAt = 0, password = "secret"))105        val envelopes = adapter.getEncryptedEntries(listOf(e.id))106        assertTrue(envelopes.containsKey(e.id))107        // Re-applying the same envelope is a safe no-op (same mtime, no overwrite).108        adapter.applyEntries(envelopes)109        assertEquals(1, repo.getEntries().size)110    }111112    @Test fun swapped_envelope_is_rejected_by_id_binding() = runBlocking {113        val (repo, adapter) = newAdapter()114        val e = repo.addEntry(VaultEntry(id = "", title = "Bank", createdAt = 0, modifiedAt = 0, password = "x"))115        val blob = adapter.getEncryptedEntries(listOf(e.id)).getValue(e.id)116        // Present the ciphertext under a different id — the decrypted entry.id117        // won't match, so it must be dropped, not poison a new slot.118        adapter.applyEntries(mapOf("attacker-chosen-id" to blob))119        assertNull(repo.getEntry("attacker-chosen-id"))120        assertEquals(1, repo.getEntries().size)121    }122123    // HKDF separator (coordinated with peach-browser): an envelope minted under124    // the OLD, unseparated info (`entryId + suffix`) must no longer decrypt. This125    // locks in the conscious wire break and asserts byte-for-byte parity with the126    // browser's matching `legacy_info_envelope_fails_closed` test.127    @Test fun legacy_info_envelope_fails_closed() = runBlocking {128        val (repo, adapter) = newAdapter()129        val master = repo.syncMasterKey()!!130        val entryId = "11111111-2222-4333-8444-555555555555"131        val entry = VaultEntry(id = entryId, title = "LegacyOnly", createdAt = 0, modifiedAt = 0, password = "pw")132133        // Legacy info: no U+0000 separator.134        val legacyInfo = (entryId + "sync-entry-v1").toByteArray(Charsets.UTF_8)135        val gen = org.bouncycastle.crypto.generators.HKDFBytesGenerator(org.bouncycastle.crypto.digests.SHA256Digest())136        gen.init(org.bouncycastle.crypto.params.HKDFParameters(master, ByteArray(0), legacyInfo))137        val key = ByteArray(32).also { gen.generateBytes(it, 0, it.size) }138139        val json = kotlinx.serialization.json.Json { encodeDefaults = false; ignoreUnknownKeys = true }140        val plaintext = json.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)141        val nonce = ByteArray(12).also { java.security.SecureRandom().nextBytes(it) }142        val cipher = javax.crypto.Cipher.getInstance("AES/GCM/NoPadding")143        cipher.init(144            javax.crypto.Cipher.ENCRYPT_MODE,145            javax.crypto.spec.SecretKeySpec(key, "AES"),146            javax.crypto.spec.GCMParameterSpec(128, nonce),147        )148        cipher.updateAAD("peach-sync-entry-v1".toByteArray(Charsets.UTF_8))149        val ct = cipher.doFinal(plaintext)150        val legacyEnvelope = java.util.Base64.getEncoder().encodeToString(nonce + ct)151152        adapter.applyEntries(mapOf(entryId to legacyEnvelope))153        // Patched adapter derives with the separated info → decrypt fails → dropped.154        assertNull(repo.getEntry(entryId))155    }156157    @Test fun signed_device_manifest_round_trips_through_canonical_signature() = runBlocking {158        val master = ByteArray(32) { it.toByte() }159        val signing = deriveSyncSigningKeypair(master, "extension-issuer")160        val manifest = buildSignedDeviceManifest(161            issuerDeviceId = "extension-issuer",162            devices = listOf(163                ManifestDeviceEntry(164                    deviceId = "android-peer",165                    deviceName = "Phone",166                    deviceType = "android",167                    longTermPublicKeyHex = "aa".repeat(32),168                    signingPublicKeyHex = "bb".repeat(32),169                    pairedAt = 123L,170                ),171            ),172            signingPrivateKey = signing.privateKey,173            nowMs = 1_000L,174        )175176        val verified = verifyDeviceManifest(177            manifest,178            signing.publicKeyRaw,179            { setOf(pubkeyToHex(signing.publicKeyRaw)) },180            nowMs = 1_000L,181        )182183        assertEquals("extension-issuer", verified?.issuerDeviceId)184        assertEquals("android-peer", verified?.devices?.single()?.deviceId)185        assertTrue(verifyDeviceManifest(manifest.copy(signatureHex = "00".repeat(64)), signing.publicKeyRaw, { setOf(pubkeyToHex(signing.publicKeyRaw)) }, nowMs = 1_000L) == null)186    }187188    @Test fun `vouched peer revocation evicts the device and permanently blocks re-vouching`() = runBlocking {189        val repo = VaultRepository(FakeStorage())190        repo.createVault("correct horse")191        val master = repo.syncMasterKey()!!192        val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")193        val paired = FakeDeviceRegistry(194            listOf(195                PairedDevice(196                    deviceId = "extension-issuer",197                    deviceName = "Peach Browser",198                    deviceType = "extension",199                    longTermPublicKeyHex = "11".repeat(32),200                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),201                    trustSource = "vouched",202                    pairedAt = 1L,203                ),204                PairedDevice(205                    deviceId = "stolen-laptop",206                    deviceName = "Old Laptop",207                    deviceType = "extension",208                    longTermPublicKeyHex = "dd".repeat(32),209                    signingPublicKeyHex = "ee".repeat(32),210                    trustSource = "direct",211                    pairedAt = 2L,212                ),213            ),214        )215        val pending = FakePendingRegistry()216        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending, localCredentials = localCredentials("android-self"))217218        val revocation = buildSignedDeviceManifest(219            issuerDeviceId = "extension-issuer",220            devices = emptyList(),221            signingPrivateKey = issuerSigning.privateKey,222            revoked = listOf(223                DeviceRevocation(224                    deviceId = "stolen-laptop",225                    longTermPublicKeyHex = "dd".repeat(32),226                    revokedAt = System.currentTimeMillis(),227                    revokedByDeviceId = "extension-issuer",228                ),229            ),230        )231        adapter.applyIncomingDeviceManifest(revocation, ByteArray(32) { 0x11 })232233        assertNull(paired.get("stolen-laptop"))234235        // A later manifest vouches it back with a fresh pairedAt — exactly the236        // resurrection a timestamp-ordered design would allow.237        val revouch = buildSignedDeviceManifest(238            issuerDeviceId = "extension-issuer",239            devices = listOf(240                ManifestDeviceEntry(241                    deviceId = "stolen-laptop",242                    deviceName = "Old Laptop",243                    deviceType = "extension",244                    longTermPublicKeyHex = "dd".repeat(32),245                    signingPublicKeyHex = "ee".repeat(32),246                    pairedAt = System.currentTimeMillis() + 60_000L,247                ),248            ),249            signingPrivateKey = issuerSigning.privateKey,250        )251        adapter.applyIncomingDeviceManifest(revouch, ByteArray(32) { 0x11 })252253        assertNull(paired.get("stolen-laptop"))254        assertEquals(0, pending.devices.size)255        master.fill(0)256    }257258    @Test fun `a peer cannot revoke this device`() = runBlocking {259        val repo = VaultRepository(FakeStorage())260        repo.createVault("correct horse")261        val master = repo.syncMasterKey()!!262        val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")263        val paired = FakeDeviceRegistry(264            listOf(265                PairedDevice(266                    deviceId = "extension-issuer",267                    deviceName = "Peach Browser",268                    deviceType = "extension",269                    longTermPublicKeyHex = "11".repeat(32),270                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),271                    trustSource = "direct",272                    pairedAt = 1L,273                ),274            ),275        )276        val pending = FakePendingRegistry()277        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending, localCredentials = localCredentials("android-self"))278279        val selfRevoke = buildSignedDeviceManifest(280            issuerDeviceId = "extension-issuer",281            devices = emptyList(),282            signingPrivateKey = issuerSigning.privateKey,283            revoked = listOf(284                DeviceRevocation(285                    deviceId = "android-self",286                    longTermPublicKeyHex = "ff".repeat(32),287                    revokedAt = System.currentTimeMillis(),288                    revokedByDeviceId = "extension-issuer",289                ),290            ),291        )292        adapter.applyIncomingDeviceManifest(selfRevoke, ByteArray(32) { 0x11 })293294        assertTrue(paired.listRevocations().none { it.deviceId == "android-self" })295        master.fill(0)296    }297298    @Test fun incoming_extension_manifest_auto_admits_routable_same_vault_peer() = runBlocking {299        val repo = VaultRepository(FakeStorage())300        repo.createVault("correct horse")301        val master = repo.syncMasterKey()!!302        val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")303        val paired = FakeDeviceRegistry(304            listOf(305                PairedDevice(306                    deviceId = "extension-issuer",307                    deviceName = "Peach Browser",308                    deviceType = "extension",309                    longTermPublicKeyHex = "11".repeat(32),310                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),311                    trustSource = "direct",312                    pairedAt = 1L,313                ),314            ),315        )316        val pending = FakePendingRegistry()317        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending, localCredentials = localCredentials("android-self"))318        val manifest = buildSignedDeviceManifest(319            issuerDeviceId = "extension-issuer",320            devices = listOf(321                ManifestDeviceEntry(322                    deviceId = "new-phone",323                    deviceName = "June's Phone",324                    deviceType = "android",325                    longTermPublicKeyHex = "aa".repeat(32),326                    signingPublicKeyHex = "bb".repeat(32),327                    relayUrl = meshRelay,328                    pairedAt = 44L,329                ),330            ),331            signingPrivateKey = issuerSigning.privateKey,332        )333334        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })335336        val admitted = paired.get("new-phone")337        assertEquals("vouched", admitted?.trustSource)338        assertEquals(meshRelay, admitted?.relayUrl)339        assertTrue(admitted?.rendezvousSecretHex?.matches(Regex("[0-9a-f]{64}")) == true)340        assertTrue(pending.devices.isEmpty())341        master.fill(0)342    }343344    @Test fun failed_manifest_commit_restores_pending_before_trust_can_land() = runBlocking {345        val repo = VaultRepository(FakeStorage())346        repo.createVault("correct horse")347        val master = repo.syncMasterKey()!!348        val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")349        val paired = FakeDeviceRegistry(350            listOf(351                PairedDevice(352                    deviceId = "extension-issuer",353                    deviceName = "Peach Browser",354                    deviceType = "extension",355                    longTermPublicKeyHex = "11".repeat(32),356                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),357                    trustSource = "direct",358                    pairedAt = 1L,359                ),360            ),361        ).also { it.failManifestCommit = true }362        val pending = FakePendingRegistry().also {363            it.upsert(364                PendingVouchedDevice(365                    deviceId = "new-phone",366                    deviceName = "June's Phone",367                    deviceType = "android",368                    longTermPublicKeyHex = "aa".repeat(32),369                    signingPublicKeyHex = "bb".repeat(32),370                    pairedAt = 44L,371                    receivedAt = 45L,372                    vouchedByDeviceId = "extension-issuer",373                ),374            )375        }376        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending, localCredentials = localCredentials("android-self"))377        val manifest = buildSignedDeviceManifest(378            issuerDeviceId = "extension-issuer",379            devices = listOf(380                ManifestDeviceEntry(381                    deviceId = "new-phone",382                    deviceName = "June's Phone",383                    deviceType = "android",384                    longTermPublicKeyHex = "aa".repeat(32),385                    signingPublicKeyHex = "bb".repeat(32),386                    relayUrl = meshRelay,387                    pairedAt = 44L,388                ),389            ),390            signingPrivateKey = issuerSigning.privateKey,391        )392393        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })394395        assertNull("failed registry commit must not install trust", paired.get("new-phone"))396        assertEquals("pending introduction is restored for retry", 1, pending.devices.size)397        master.fill(0)398    }399    /**400     * Browser parity (`applyVerifiedPeerManifest`): trust is keyed on the issuer401     * being DIRECTLY paired, not on it being a browser. Android used to require402     * deviceType == "extension", which silently killed mesh propagation between403     * native devices — the case the mesh exists for now that phones are the404     * listening side.405     */406    @Test fun `a manifest from a directly paired phone is accepted`() = runBlocking {407        val repo = VaultRepository(FakeStorage())408        repo.createVault("correct horse")409        val master = repo.syncMasterKey()!!410        val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")411        val paired = FakeDeviceRegistry(412            listOf(413                PairedDevice(414                    deviceId = "phone-issuer",415                    deviceName = "Other Phone",416                    deviceType = "android",417                    longTermPublicKeyHex = "11".repeat(32),418                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),419                    trustSource = "direct",420                    pairedAt = 1L,421                ),422            ),423        )424        val pending = FakePendingRegistry()425        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending, localCredentials = localCredentials("android-self"))426        val manifest = buildSignedDeviceManifest(427            issuerDeviceId = "phone-issuer",428            devices = listOf(429                ManifestDeviceEntry(430                    deviceId = "laptop-b",431                    deviceName = "Laptop B",432                    deviceType = "extension",433                    longTermPublicKeyHex = "aa".repeat(32),434                    signingPublicKeyHex = "bb".repeat(32),435                    relayUrl = meshRelay,436                    pairedAt = 44L,437                ),438            ),439            signingPrivateKey = issuerSigning.privateKey,440        )441442        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })443444        assertEquals("vouched", paired.get("laptop-b")?.trustSource)445        assertEquals(meshRelay, paired.get("laptop-b")?.relayUrl)446        assertTrue(pending.devices.isEmpty())447        master.fill(0)448    }449450    /** A vouched peer cannot extend the trust graph beyond one hop. */451    @Test fun `a vouched issuer cannot introduce further mesh devices`() = runBlocking {452        val repo = VaultRepository(FakeStorage())453        repo.createVault("correct horse")454        val master = repo.syncMasterKey()!!455        val issuerSigning = deriveSyncSigningKeypair(master, "vouched-issuer")456        val paired = FakeDeviceRegistry(457            listOf(458                PairedDevice(459                    deviceId = "vouched-issuer",460                    deviceName = "Vouched Device",461                    deviceType = "extension",462                    longTermPublicKeyHex = "11".repeat(32),463                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),464                    trustSource = "vouched",465                    vouchDepth = MAX_VOUCH_DEPTH,466                    pairedAt = 1L,467                ),468            ),469        )470        val pending = FakePendingRegistry()471        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending, localCredentials = localCredentials("android-self"))472        val manifest = buildSignedDeviceManifest(473            issuerDeviceId = "vouched-issuer",474            devices = listOf(475                ManifestDeviceEntry(476                    deviceId = "onward-mesh-device",477                    deviceName = "Onward mesh device",478                    deviceType = "extension",479                    longTermPublicKeyHex = "aa".repeat(32),480                    signingPublicKeyHex = "bb".repeat(32),481                    relayUrl = meshRelay,482                    pairedAt = 44L,483                ),484            ),485            signingPrivateKey = issuerSigning.privateKey,486        )487488        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })489490        assertNull(paired.get("onward-mesh-device"))491        assertEquals(0, pending.devices.size)492        master.fill(0)493    }494495    @Test fun `a self asserted manifest issuer cannot override the authenticated peer`() = runBlocking {496        val repo = VaultRepository(FakeStorage())497        repo.createVault("correct horse")498        val master = repo.syncMasterKey()!!499        val claimedSigning = deriveSyncSigningKeypair(master, "claimed-issuer")500        val paired = FakeDeviceRegistry(501            listOf(502                PairedDevice(503                    deviceId = "claimed-issuer",504                    deviceName = "Claimed Browser",505                    deviceType = "extension",506                    longTermPublicKeyHex = "11".repeat(32),507                    signingPublicKeyHex = pubkeyToHex(claimedSigning.publicKeyRaw),508                    trustSource = "direct",509                    pairedAt = 1L,510                ),511                PairedDevice(512                    deviceId = "authenticated-peer",513                    deviceName = "Authenticated Phone",514                    deviceType = "android",515                    longTermPublicKeyHex = "22".repeat(32),516                    signingPublicKeyHex = "33".repeat(32),517                    trustSource = "direct",518                    pairedAt = 2L,519                ),520            ),521        )522        val pending = FakePendingRegistry()523        val adapter = VaultSyncAdapter(524            repo,525            ourDeviceId = "android-self",526            pairedDevices = paired,527            pendingVouched = pending,528            localCredentials = localCredentials("android-self"),529        )530        val manifest = buildSignedDeviceManifest(531            issuerDeviceId = "claimed-issuer",532            devices = listOf(533                ManifestDeviceEntry(534                    deviceId = "smuggled-device",535                    deviceName = "Smuggled",536                    deviceType = "extension",537                    longTermPublicKeyHex = "aa".repeat(32),538                    signingPublicKeyHex = "bb".repeat(32),539                    relayUrl = meshRelay,540                    pairedAt = 44L,541                ),542            ),543            signingPrivateKey = claimedSigning.privateKey,544        )545546        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x22 })547548        assertEquals(0, pending.devices.size)549550        // Control: the same manifest over the claimed issuer's OWN session does551        // admit the device. Without it the assertion above could pass for any552        // reason — a malformed body, a bad signature, a dead code path.553        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })554        assertEquals("vouched", paired.get("smuggled-device")?.trustSource)555        assertTrue(pending.devices.isEmpty())556        master.fill(0)557    }558559    @Test fun `a revoked long term key cannot walk back in under a fresh deviceId`() = runBlocking {560        val repo = VaultRepository(FakeStorage())561        repo.createVault("correct horse")562        val master = repo.syncMasterKey()!!563        val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")564        val stolenKey = "dd".repeat(32)565        val paired = FakeDeviceRegistry(566            listOf(567                PairedDevice(568                    deviceId = "phone-issuer",569                    deviceName = "Pixel",570                    deviceType = "android",571                    longTermPublicKeyHex = "11".repeat(32),572                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),573                    trustSource = "direct",574                    pairedAt = 1L,575                ),576                PairedDevice(577                    deviceId = "stolen-laptop",578                    deviceName = "Old Laptop",579                    deviceType = "extension",580                    longTermPublicKeyHex = stolenKey,581                    signingPublicKeyHex = "ee".repeat(32),582                    trustSource = "direct",583                    pairedAt = 2L,584                ),585            ),586        )587        val pending = FakePendingRegistry()588        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending, localCredentials = localCredentials("android-self"))589590        adapter.applyIncomingDeviceManifest(591            buildSignedDeviceManifest(592                issuerDeviceId = "phone-issuer",593                devices = emptyList(),594                signingPrivateKey = issuerSigning.privateKey,595                revoked = listOf(596                    DeviceRevocation(597                        deviceId = "stolen-laptop",598                        longTermPublicKeyHex = stolenKey,599                        revokedAt = System.currentTimeMillis(),600                        revokedByDeviceId = "phone-issuer",601                    ),602                ),603            ),604            ByteArray(32) { 0x11 },605        )606        assertNull(paired.get("stolen-laptop"))607608        // deviceId is the issuer's to choose, so re-vouching the same key under609        // a new one is free. Terminality has to follow the key, not the label.610        adapter.applyIncomingDeviceManifest(611            buildSignedDeviceManifest(612                issuerDeviceId = "phone-issuer",613                devices = listOf(614                    ManifestDeviceEntry(615                        deviceId = "stolen-laptop-mk2",616                        deviceName = "New Laptop",617                        deviceType = "extension",618                        longTermPublicKeyHex = stolenKey,619                        signingPublicKeyHex = "ee".repeat(32),620                        relayUrl = meshRelay,621                        pairedAt = System.currentTimeMillis(),622                    ),623                ),624                signingPrivateKey = issuerSigning.privateKey,625            ),626            ByteArray(32) { 0x11 },627        )628        assertEquals(0, pending.devices.size)629        assertNull(paired.get("stolen-laptop-mk2"))630631        // Control: the same fresh id carrying a key that was never revoked632        // queues normally, so the rejection above is the key check, not the id.633        adapter.applyIncomingDeviceManifest(634            buildSignedDeviceManifest(635                issuerDeviceId = "phone-issuer",636                devices = listOf(637                    ManifestDeviceEntry(638                        deviceId = "stolen-laptop-mk2",639                        deviceName = "New Laptop",640                        deviceType = "extension",641                        longTermPublicKeyHex = "99".repeat(32),642                        signingPublicKeyHex = "ee".repeat(32),643                        relayUrl = meshRelay,644                        pairedAt = System.currentTimeMillis(),645                    ),646                ),647                signingPrivateKey = issuerSigning.privateKey,648            ),649            ByteArray(32) { 0x11 },650        )651        assertEquals("vouched", paired.get("stolen-laptop-mk2")?.trustSource)652        assertTrue(pending.devices.isEmpty())653        master.fill(0)654    }655656    @Test fun `direct and vouched peers reject long term and signing key rotation`() = runBlocking {657        val repo = VaultRepository(FakeStorage())658        repo.createVault("correct horse")659        val master = repo.syncMasterKey()!!660        val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")661        val revokedKey = "dd".repeat(32)662        val paired = FakeDeviceRegistry(663            listOf(664                PairedDevice(665                    deviceId = "phone-issuer",666                    deviceName = "Pixel",667                    deviceType = "android",668                    longTermPublicKeyHex = "11".repeat(32),669                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),670                    trustSource = "direct",671                    pairedAt = 1L,672                ),673                PairedDevice(674                    deviceId = "trusted-tablet",675                    deviceName = "Tablet",676                    deviceType = "android",677                    longTermPublicKeyHex = "77".repeat(32),678                    signingPublicKeyHex = "ee".repeat(32),679                    trustSource = "direct",680                    pairedAt = 2L,681                ),682                PairedDevice(683                    deviceId = "vouched-laptop",684                    deviceName = "Laptop",685                    deviceType = "extension",686                    longTermPublicKeyHex = "66".repeat(32),687                    signingPublicKeyHex = "aa".repeat(32),688                    trustSource = "vouched",689                    pairedAt = 3L,690                ),691            ),692        )693        val adapter = VaultSyncAdapter(694            repo,695            ourDeviceId = "android-self",696            pairedDevices = paired,697            pendingVouched = FakePendingRegistry(),698            localCredentials = localCredentials("android-self"),699        )700701        adapter.applyIncomingDeviceManifest(702            buildSignedDeviceManifest(703                issuerDeviceId = "phone-issuer",704                devices = emptyList(),705                signingPrivateKey = issuerSigning.privateKey,706                revoked = listOf(707                    DeviceRevocation(708                        deviceId = "stolen-laptop",709                        longTermPublicKeyHex = revokedKey,710                        revokedAt = System.currentTimeMillis(),711                        revokedByDeviceId = "phone-issuer",712                    ),713                ),714            ),715            ByteArray(32) { 0x11 },716        )717718        // Re-admission dressed as a key rotation on a device we already trust.719        adapter.applyIncomingDeviceManifest(720            buildSignedDeviceManifest(721                issuerDeviceId = "phone-issuer",722                devices = listOf(723                    ManifestDeviceEntry(724                        deviceId = "trusted-tablet",725                        deviceName = "Tablet",726                        deviceType = "android",727                        longTermPublicKeyHex = revokedKey,728                        signingPublicKeyHex = "ff".repeat(32),729                        pairedAt = 2L,730                    ),731                    ManifestDeviceEntry(732                        deviceId = "vouched-laptop",733                        deviceName = "Laptop",734                        deviceType = "extension",735                        longTermPublicKeyHex = "55".repeat(32),736                        signingPublicKeyHex = "bb".repeat(32),737                        relayUrl = "wss://relay.peachpasswords.com",738                        pairedAt = 3L,739                    ),740                ),741                signingPrivateKey = issuerSigning.privateKey,742            ),743            ByteArray(32) { 0x11 },744        )745        assertEquals("77".repeat(32), paired.get("trusted-tablet")?.longTermPublicKeyHex)746        assertEquals("ee".repeat(32), paired.get("trusted-tablet")?.signingPublicKeyHex)747        assertEquals("66".repeat(32), paired.get("vouched-laptop")?.longTermPublicKeyHex)748        assertEquals("aa".repeat(32), paired.get("vouched-laptop")?.signingPublicKeyHex)749        master.fill(0)750    }751752    /** Pins the helper the manifest loop and SyncViewModel.confirmVouchedDevice share. */753    @Test fun `revocation matches on id or key but never on the empty-key sentinel`() {754        val registry = FakeDeviceRegistry()755        registry.mergeRevocations(756            listOf(757                DeviceRevocation("gone", "dd".repeat(32), 1L, "issuer"),758                DeviceRevocation("keyless", NO_KEY_SENTINEL, 2L, "issuer"),759            ),760        )761        assertTrue(registry.isRevoked("gone"))762        assertTrue(registry.isRevoked("fresh-id", "DD".repeat(32)))763        assertTrue(registry.isRevoked("keyless"))764        assertFalse(registry.isRevoked("fresh-id", "99".repeat(32)))765        // A device that genuinely never stored a key must not inherit the766        // sentinel's revocation.767        assertFalse(registry.isRevoked("innocent", NO_KEY_SENTINEL))768    }769770    @Test fun `public key decoder requires exactly 32 bytes`() {771        assertEquals(32, hexToPubkey("ab".repeat(32)).size)772        assertTrue(runCatching { hexToPubkey("ab".repeat(31)) }.isFailure)773        assertTrue(runCatching { hexToPubkey("ab".repeat(33)) }.isFailure)774        assertEquals(64, hexToBytes("ab".repeat(64)).size)775    }776}