VaultSyncAdapterTest.kt

32.6 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() { slotA = null; slotB = null; ver = 0; settings = VaultSettings() }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 = revocations.toMutableList()52            val known = nextRevocations.map { it.deviceId }.toSet()53            nextRevocations += batch.incomingRevocations.filterNot { known.contains(it.deviceId) }54            val nextDevices = byId.toMutableMap()55            batch.removals.forEach(nextDevices::remove)56            batch.upserts.forEach { nextDevices[it.deviceId] = it }57            revocations.clear()58            revocations += nextRevocations59            byId.clear()60            byId.putAll(nextDevices)61            return true62        }63        override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> {64            val known = revocations.map { it.deviceId }.toSet()65            val fresh = incoming.filterNot { known.contains(it.deviceId) }66            revocations += fresh67            return fresh.map { it.deviceId }68        }69        override fun clearRevocation(deviceId: String) {70            revocations.removeAll { it.deviceId == deviceId }71        }72    }7374    private class FakePendingRegistry : PendingVouchedRegistry {75        val devices = mutableListOf<PendingVouchedDevice>()76        override fun getAll(): List<PendingVouchedDevice> = devices.toList()77        override fun upsert(device: PendingVouchedDevice) {78            devices.removeAll { it.deviceId == device.deviceId }79            devices += device80        }81        override fun remove(id: String) { devices.removeAll { it.deviceId == id } }82    }8384    private fun newAdapter(): Pair<VaultRepository, VaultSyncAdapter> {85        val repo = VaultRepository(FakeStorage())86        runBlocking { repo.createVault("correct horse") }87        return repo to VaultSyncAdapter(repo, ourDeviceId = "android-test")88    }8990    @Test fun manifest_and_vault_id_reflect_the_vault() = runBlocking {91        val (repo, adapter) = newAdapter()92        val e = repo.addEntry(VaultEntry(id = "", title = "GitHub", createdAt = 0, modifiedAt = 0, password = "p"))93        val manifest = adapter.buildEntryManifest()94        assertEquals(setOf(e.id), manifest.keys)95        assertTrue(adapter.computeVaultId().length == 64) // blake3-256 hex96    }9798    @Test fun envelope_round_trips_within_the_vault() = runBlocking {99        val (repo, adapter) = newAdapter()100        val e = repo.addEntry(VaultEntry(id = "", title = "Mail", createdAt = 0, modifiedAt = 0, password = "secret"))101        val envelopes = adapter.getEncryptedEntries(listOf(e.id))102        assertTrue(envelopes.containsKey(e.id))103        // Re-applying the same envelope is a safe no-op (same mtime, no overwrite).104        adapter.applyEntries(envelopes)105        assertEquals(1, repo.getEntries().size)106    }107108    @Test fun swapped_envelope_is_rejected_by_id_binding() = runBlocking {109        val (repo, adapter) = newAdapter()110        val e = repo.addEntry(VaultEntry(id = "", title = "Bank", createdAt = 0, modifiedAt = 0, password = "x"))111        val blob = adapter.getEncryptedEntries(listOf(e.id)).getValue(e.id)112        // Present the ciphertext under a different id — the decrypted entry.id113        // won't match, so it must be dropped, not poison a new slot.114        adapter.applyEntries(mapOf("attacker-chosen-id" to blob))115        assertNull(repo.getEntry("attacker-chosen-id"))116        assertEquals(1, repo.getEntries().size)117    }118119    // HKDF separator (coordinated with peach-browser): an envelope minted under120    // the OLD, unseparated info (`entryId + suffix`) must no longer decrypt. This121    // locks in the conscious wire break and asserts byte-for-byte parity with the122    // browser's matching `legacy_info_envelope_fails_closed` test.123    @Test fun legacy_info_envelope_fails_closed() = runBlocking {124        val (repo, adapter) = newAdapter()125        val master = repo.syncMasterKey()!!126        val entryId = "11111111-2222-4333-8444-555555555555"127        val entry = VaultEntry(id = entryId, title = "LegacyOnly", createdAt = 0, modifiedAt = 0, password = "pw")128129        // Legacy info: no U+0000 separator.130        val legacyInfo = (entryId + "sync-entry-v1").toByteArray(Charsets.UTF_8)131        val gen = org.bouncycastle.crypto.generators.HKDFBytesGenerator(org.bouncycastle.crypto.digests.SHA256Digest())132        gen.init(org.bouncycastle.crypto.params.HKDFParameters(master, ByteArray(0), legacyInfo))133        val key = ByteArray(32).also { gen.generateBytes(it, 0, it.size) }134135        val json = kotlinx.serialization.json.Json { encodeDefaults = false; ignoreUnknownKeys = true }136        val plaintext = json.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)137        val nonce = ByteArray(12).also { java.security.SecureRandom().nextBytes(it) }138        val cipher = javax.crypto.Cipher.getInstance("AES/GCM/NoPadding")139        cipher.init(140            javax.crypto.Cipher.ENCRYPT_MODE,141            javax.crypto.spec.SecretKeySpec(key, "AES"),142            javax.crypto.spec.GCMParameterSpec(128, nonce),143        )144        cipher.updateAAD("peach-sync-entry-v1".toByteArray(Charsets.UTF_8))145        val ct = cipher.doFinal(plaintext)146        val legacyEnvelope = java.util.Base64.getEncoder().encodeToString(nonce + ct)147148        adapter.applyEntries(mapOf(entryId to legacyEnvelope))149        // Patched adapter derives with the separated info → decrypt fails → dropped.150        assertNull(repo.getEntry(entryId))151    }152153    @Test fun signed_device_manifest_round_trips_through_canonical_signature() = runBlocking {154        val master = ByteArray(32) { it.toByte() }155        val signing = deriveSyncSigningKeypair(master, "extension-issuer")156        val manifest = buildSignedDeviceManifest(157            issuerDeviceId = "extension-issuer",158            devices = listOf(159                ManifestDeviceEntry(160                    deviceId = "android-peer",161                    deviceName = "Phone",162                    deviceType = "android",163                    longTermPublicKeyHex = "aa".repeat(32),164                    signingPublicKeyHex = "bb".repeat(32),165                    pairedAt = 123L,166                ),167            ),168            signingPrivateKey = signing.privateKey,169            nowMs = 1_000L,170        )171172        val verified = verifyDeviceManifest(173            manifest,174            signing.publicKeyRaw,175            nowMs = 1_000L,176        )177178        assertEquals("extension-issuer", verified?.issuerDeviceId)179        assertEquals("android-peer", verified?.devices?.single()?.deviceId)180        assertTrue(verifyDeviceManifest(manifest.copy(signatureHex = "00".repeat(64)), signing.publicKeyRaw, nowMs = 1_000L) == null)181    }182183    @Test fun `vouched peer revocation evicts the device and permanently blocks re-vouching`() = runBlocking {184        val repo = VaultRepository(FakeStorage())185        repo.createVault("correct horse")186        val master = repo.syncMasterKey()!!187        val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")188        val paired = FakeDeviceRegistry(189            listOf(190                PairedDevice(191                    deviceId = "extension-issuer",192                    deviceName = "Peach Browser",193                    deviceType = "extension",194                    longTermPublicKeyHex = "11".repeat(32),195                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),196                    trustSource = "vouched",197                    pairedAt = 1L,198                ),199                PairedDevice(200                    deviceId = "stolen-laptop",201                    deviceName = "Old Laptop",202                    deviceType = "extension",203                    longTermPublicKeyHex = "dd".repeat(32),204                    signingPublicKeyHex = "ee".repeat(32),205                    trustSource = "direct",206                    pairedAt = 2L,207                ),208            ),209        )210        val pending = FakePendingRegistry()211        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)212213        val revocation = buildSignedDeviceManifest(214            issuerDeviceId = "extension-issuer",215            devices = emptyList(),216            signingPrivateKey = issuerSigning.privateKey,217            revoked = listOf(218                DeviceRevocation(219                    deviceId = "stolen-laptop",220                    longTermPublicKeyHex = "dd".repeat(32),221                    revokedAt = System.currentTimeMillis(),222                    revokedByDeviceId = "extension-issuer",223                ),224            ),225        )226        adapter.applyIncomingDeviceManifest(revocation, ByteArray(32) { 0x11 })227228        assertNull(paired.get("stolen-laptop"))229230        // A later manifest vouches it back with a fresh pairedAt — exactly the231        // resurrection a timestamp-ordered design would allow.232        val revouch = buildSignedDeviceManifest(233            issuerDeviceId = "extension-issuer",234            devices = listOf(235                ManifestDeviceEntry(236                    deviceId = "stolen-laptop",237                    deviceName = "Old Laptop",238                    deviceType = "extension",239                    longTermPublicKeyHex = "dd".repeat(32),240                    signingPublicKeyHex = "ee".repeat(32),241                    pairedAt = System.currentTimeMillis() + 60_000L,242                ),243            ),244            signingPrivateKey = issuerSigning.privateKey,245        )246        adapter.applyIncomingDeviceManifest(revouch, ByteArray(32) { 0x11 })247248        assertNull(paired.get("stolen-laptop"))249        assertEquals(0, pending.devices.size)250        master.fill(0)251    }252253    @Test fun `a peer cannot revoke this device`() = runBlocking {254        val repo = VaultRepository(FakeStorage())255        repo.createVault("correct horse")256        val master = repo.syncMasterKey()!!257        val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")258        val paired = FakeDeviceRegistry(259            listOf(260                PairedDevice(261                    deviceId = "extension-issuer",262                    deviceName = "Peach Browser",263                    deviceType = "extension",264                    longTermPublicKeyHex = "11".repeat(32),265                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),266                    trustSource = "direct",267                    pairedAt = 1L,268                ),269            ),270        )271        val pending = FakePendingRegistry()272        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)273274        val selfRevoke = buildSignedDeviceManifest(275            issuerDeviceId = "extension-issuer",276            devices = emptyList(),277            signingPrivateKey = issuerSigning.privateKey,278            revoked = listOf(279                DeviceRevocation(280                    deviceId = "android-self",281                    longTermPublicKeyHex = "ff".repeat(32),282                    revokedAt = System.currentTimeMillis(),283                    revokedByDeviceId = "extension-issuer",284                ),285            ),286        )287        adapter.applyIncomingDeviceManifest(selfRevoke, ByteArray(32) { 0x11 })288289        assertTrue(paired.listRevocations().none { it.deviceId == "android-self" })290        master.fill(0)291    }292293    @Test fun incoming_extension_manifest_auto_admits_routable_same_vault_peer() = runBlocking {294        val repo = VaultRepository(FakeStorage())295        repo.createVault("correct horse")296        val master = repo.syncMasterKey()!!297        val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")298        val paired = FakeDeviceRegistry(299            listOf(300                PairedDevice(301                    deviceId = "extension-issuer",302                    deviceName = "Peach Browser",303                    deviceType = "extension",304                    longTermPublicKeyHex = "11".repeat(32),305                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),306                    trustSource = "direct",307                    pairedAt = 1L,308                ),309            ),310        )311        val pending = FakePendingRegistry()312        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)313        val manifest = buildSignedDeviceManifest(314            issuerDeviceId = "extension-issuer",315            devices = listOf(316                ManifestDeviceEntry(317                    deviceId = "new-phone",318                    deviceName = "June's Phone",319                    deviceType = "android",320                    longTermPublicKeyHex = "aa".repeat(32),321                    signingPublicKeyHex = "bb".repeat(32),322                    relayUrl = meshRelay,323                    pairedAt = 44L,324                ),325            ),326            signingPrivateKey = issuerSigning.privateKey,327        )328329        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })330331        val admitted = paired.get("new-phone")332        assertEquals("vouched", admitted?.trustSource)333        assertEquals(meshRelay, admitted?.relayUrl)334        assertTrue(admitted?.rendezvousSecretHex?.matches(Regex("[0-9a-f]{64}")) == true)335        assertTrue(pending.devices.isEmpty())336        master.fill(0)337    }338339    @Test fun failed_manifest_commit_restores_pending_before_trust_can_land() = runBlocking {340        val repo = VaultRepository(FakeStorage())341        repo.createVault("correct horse")342        val master = repo.syncMasterKey()!!343        val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")344        val paired = FakeDeviceRegistry(345            listOf(346                PairedDevice(347                    deviceId = "extension-issuer",348                    deviceName = "Peach Browser",349                    deviceType = "extension",350                    longTermPublicKeyHex = "11".repeat(32),351                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),352                    trustSource = "direct",353                    pairedAt = 1L,354                ),355            ),356        ).also { it.failManifestCommit = true }357        val pending = FakePendingRegistry().also {358            it.upsert(359                PendingVouchedDevice(360                    deviceId = "new-phone",361                    deviceName = "June's Phone",362                    deviceType = "android",363                    longTermPublicKeyHex = "aa".repeat(32),364                    signingPublicKeyHex = "bb".repeat(32),365                    pairedAt = 44L,366                    receivedAt = 45L,367                    vouchedByDeviceId = "extension-issuer",368                ),369            )370        }371        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)372        val manifest = buildSignedDeviceManifest(373            issuerDeviceId = "extension-issuer",374            devices = listOf(375                ManifestDeviceEntry(376                    deviceId = "new-phone",377                    deviceName = "June's Phone",378                    deviceType = "android",379                    longTermPublicKeyHex = "aa".repeat(32),380                    signingPublicKeyHex = "bb".repeat(32),381                    relayUrl = meshRelay,382                    pairedAt = 44L,383                ),384            ),385            signingPrivateKey = issuerSigning.privateKey,386        )387388        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })389390        assertNull("failed registry commit must not install trust", paired.get("new-phone"))391        assertEquals("pending introduction is restored for retry", 1, pending.devices.size)392        master.fill(0)393    }394    /**395     * Browser parity (`applyVerifiedPeerManifest`): trust is keyed on the issuer396     * being DIRECTLY paired, not on it being a browser. Android used to require397     * deviceType == "extension", which silently killed mesh propagation between398     * native devices — the case the mesh exists for now that phones are the399     * listening side.400     */401    @Test fun `a manifest from a directly paired phone is accepted`() = runBlocking {402        val repo = VaultRepository(FakeStorage())403        repo.createVault("correct horse")404        val master = repo.syncMasterKey()!!405        val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")406        val paired = FakeDeviceRegistry(407            listOf(408                PairedDevice(409                    deviceId = "phone-issuer",410                    deviceName = "Other Phone",411                    deviceType = "android",412                    longTermPublicKeyHex = "11".repeat(32),413                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),414                    trustSource = "direct",415                    pairedAt = 1L,416                ),417            ),418        )419        val pending = FakePendingRegistry()420        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)421        val manifest = buildSignedDeviceManifest(422            issuerDeviceId = "phone-issuer",423            devices = listOf(424                ManifestDeviceEntry(425                    deviceId = "laptop-b",426                    deviceName = "Laptop B",427                    deviceType = "extension",428                    longTermPublicKeyHex = "aa".repeat(32),429                    signingPublicKeyHex = "bb".repeat(32),430                    relayUrl = meshRelay,431                    pairedAt = 44L,432                ),433            ),434            signingPrivateKey = issuerSigning.privateKey,435        )436437        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })438439        assertEquals("vouched", paired.get("laptop-b")?.trustSource)440        assertEquals(meshRelay, paired.get("laptop-b")?.relayUrl)441        assertTrue(pending.devices.isEmpty())442        master.fill(0)443    }444445    /** An admitted peer can propagate the pool without the original hub online. */446    @Test fun `a vouched issuer introduces further mesh devices`() = runBlocking {447        val repo = VaultRepository(FakeStorage())448        repo.createVault("correct horse")449        val master = repo.syncMasterKey()!!450        val issuerSigning = deriveSyncSigningKeypair(master, "vouched-issuer")451        val paired = FakeDeviceRegistry(452            listOf(453                PairedDevice(454                    deviceId = "vouched-issuer",455                    deviceName = "Vouched Device",456                    deviceType = "extension",457                    longTermPublicKeyHex = "11".repeat(32),458                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),459                    trustSource = "vouched",460                    pairedAt = 1L,461                ),462            ),463        )464        val pending = FakePendingRegistry()465        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)466        val manifest = buildSignedDeviceManifest(467            issuerDeviceId = "vouched-issuer",468            devices = listOf(469                ManifestDeviceEntry(470                    deviceId = "onward-mesh-device",471                    deviceName = "Onward mesh device",472                    deviceType = "extension",473                    longTermPublicKeyHex = "aa".repeat(32),474                    signingPublicKeyHex = "bb".repeat(32),475                    relayUrl = meshRelay,476                    pairedAt = 44L,477                ),478            ),479            signingPrivateKey = issuerSigning.privateKey,480        )481482        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })483484        assertEquals("vouched", paired.get("onward-mesh-device")?.trustSource)485        assertEquals(meshRelay, paired.get("onward-mesh-device")?.relayUrl)486        assertTrue(paired.get("onward-mesh-device")?.rendezvousSecretHex != null)487        assertEquals(0, pending.devices.size)488        master.fill(0)489    }490491    @Test fun `a self asserted manifest issuer cannot override the authenticated peer`() = runBlocking {492        val repo = VaultRepository(FakeStorage())493        repo.createVault("correct horse")494        val master = repo.syncMasterKey()!!495        val claimedSigning = deriveSyncSigningKeypair(master, "claimed-issuer")496        val paired = FakeDeviceRegistry(497            listOf(498                PairedDevice(499                    deviceId = "claimed-issuer",500                    deviceName = "Claimed Browser",501                    deviceType = "extension",502                    longTermPublicKeyHex = "11".repeat(32),503                    signingPublicKeyHex = pubkeyToHex(claimedSigning.publicKeyRaw),504                    trustSource = "direct",505                    pairedAt = 1L,506                ),507                PairedDevice(508                    deviceId = "authenticated-peer",509                    deviceName = "Authenticated Phone",510                    deviceType = "android",511                    longTermPublicKeyHex = "22".repeat(32),512                    signingPublicKeyHex = "33".repeat(32),513                    trustSource = "direct",514                    pairedAt = 2L,515                ),516            ),517        )518        val pending = FakePendingRegistry()519        val adapter = VaultSyncAdapter(520            repo,521            ourDeviceId = "android-self",522            pairedDevices = paired,523            pendingVouched = pending,524        )525        val manifest = buildSignedDeviceManifest(526            issuerDeviceId = "claimed-issuer",527            devices = listOf(528                ManifestDeviceEntry(529                    deviceId = "smuggled-device",530                    deviceName = "Smuggled",531                    deviceType = "extension",532                    longTermPublicKeyHex = "aa".repeat(32),533                    signingPublicKeyHex = "bb".repeat(32),534                    relayUrl = meshRelay,535                    pairedAt = 44L,536                ),537            ),538            signingPrivateKey = claimedSigning.privateKey,539        )540541        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x22 })542543        assertEquals(0, pending.devices.size)544545        // Control: the same manifest over the claimed issuer's OWN session does546        // admit the device. Without it the assertion above could pass for any547        // reason — a malformed body, a bad signature, a dead code path.548        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })549        assertEquals("vouched", paired.get("smuggled-device")?.trustSource)550        assertTrue(pending.devices.isEmpty())551        master.fill(0)552    }553554    @Test fun `a revoked long term key cannot walk back in under a fresh deviceId`() = runBlocking {555        val repo = VaultRepository(FakeStorage())556        repo.createVault("correct horse")557        val master = repo.syncMasterKey()!!558        val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")559        val stolenKey = "dd".repeat(32)560        val paired = FakeDeviceRegistry(561            listOf(562                PairedDevice(563                    deviceId = "phone-issuer",564                    deviceName = "Pixel",565                    deviceType = "android",566                    longTermPublicKeyHex = "11".repeat(32),567                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),568                    trustSource = "direct",569                    pairedAt = 1L,570                ),571                PairedDevice(572                    deviceId = "stolen-laptop",573                    deviceName = "Old Laptop",574                    deviceType = "extension",575                    longTermPublicKeyHex = stolenKey,576                    signingPublicKeyHex = "ee".repeat(32),577                    trustSource = "direct",578                    pairedAt = 2L,579                ),580            ),581        )582        val pending = FakePendingRegistry()583        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)584585        adapter.applyIncomingDeviceManifest(586            buildSignedDeviceManifest(587                issuerDeviceId = "phone-issuer",588                devices = emptyList(),589                signingPrivateKey = issuerSigning.privateKey,590                revoked = listOf(591                    DeviceRevocation(592                        deviceId = "stolen-laptop",593                        longTermPublicKeyHex = stolenKey,594                        revokedAt = System.currentTimeMillis(),595                        revokedByDeviceId = "phone-issuer",596                    ),597                ),598            ),599            ByteArray(32) { 0x11 },600        )601        assertNull(paired.get("stolen-laptop"))602603        // deviceId is the issuer's to choose, so re-vouching the same key under604        // a new one is free. Terminality has to follow the key, not the label.605        adapter.applyIncomingDeviceManifest(606            buildSignedDeviceManifest(607                issuerDeviceId = "phone-issuer",608                devices = listOf(609                    ManifestDeviceEntry(610                        deviceId = "stolen-laptop-mk2",611                        deviceName = "New Laptop",612                        deviceType = "extension",613                        longTermPublicKeyHex = stolenKey,614                        signingPublicKeyHex = "ee".repeat(32),615                        relayUrl = meshRelay,616                        pairedAt = System.currentTimeMillis(),617                    ),618                ),619                signingPrivateKey = issuerSigning.privateKey,620            ),621            ByteArray(32) { 0x11 },622        )623        assertEquals(0, pending.devices.size)624        assertNull(paired.get("stolen-laptop-mk2"))625626        // Control: the same fresh id carrying a key that was never revoked627        // queues normally, so the rejection above is the key check, not the id.628        adapter.applyIncomingDeviceManifest(629            buildSignedDeviceManifest(630                issuerDeviceId = "phone-issuer",631                devices = listOf(632                    ManifestDeviceEntry(633                        deviceId = "stolen-laptop-mk2",634                        deviceName = "New Laptop",635                        deviceType = "extension",636                        longTermPublicKeyHex = "99".repeat(32),637                        signingPublicKeyHex = "ee".repeat(32),638                        relayUrl = meshRelay,639                        pairedAt = System.currentTimeMillis(),640                    ),641                ),642                signingPrivateKey = issuerSigning.privateKey,643            ),644            ByteArray(32) { 0x11 },645        )646        assertEquals("vouched", paired.get("stolen-laptop-mk2")?.trustSource)647        assertTrue(pending.devices.isEmpty())648        master.fill(0)649    }650651    @Test fun `a revoked key cannot be written onto a device we still trust`() = runBlocking {652        val repo = VaultRepository(FakeStorage())653        repo.createVault("correct horse")654        val master = repo.syncMasterKey()!!655        val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")656        val revokedKey = "dd".repeat(32)657        val paired = FakeDeviceRegistry(658            listOf(659                PairedDevice(660                    deviceId = "phone-issuer",661                    deviceName = "Pixel",662                    deviceType = "android",663                    longTermPublicKeyHex = "11".repeat(32),664                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),665                    trustSource = "direct",666                    pairedAt = 1L,667                ),668                PairedDevice(669                    deviceId = "trusted-tablet",670                    deviceName = "Tablet",671                    deviceType = "android",672                    longTermPublicKeyHex = "77".repeat(32),673                    signingPublicKeyHex = "ee".repeat(32),674                    trustSource = "direct",675                    pairedAt = 2L,676                ),677            ),678        )679        val adapter = VaultSyncAdapter(680            repo,681            ourDeviceId = "android-self",682            pairedDevices = paired,683            pendingVouched = FakePendingRegistry(),684        )685686        adapter.applyIncomingDeviceManifest(687            buildSignedDeviceManifest(688                issuerDeviceId = "phone-issuer",689                devices = emptyList(),690                signingPrivateKey = issuerSigning.privateKey,691                revoked = listOf(692                    DeviceRevocation(693                        deviceId = "stolen-laptop",694                        longTermPublicKeyHex = revokedKey,695                        revokedAt = System.currentTimeMillis(),696                        revokedByDeviceId = "phone-issuer",697                    ),698                ),699            ),700            ByteArray(32) { 0x11 },701        )702703        // Re-admission dressed as a key rotation on a device we already trust.704        adapter.applyIncomingDeviceManifest(705            buildSignedDeviceManifest(706                issuerDeviceId = "phone-issuer",707                devices = listOf(708                    ManifestDeviceEntry(709                        deviceId = "trusted-tablet",710                        deviceName = "Tablet",711                        deviceType = "android",712                        longTermPublicKeyHex = revokedKey,713                        signingPublicKeyHex = "ee".repeat(32),714                        pairedAt = 2L,715                    ),716                ),717                signingPrivateKey = issuerSigning.privateKey,718            ),719            ByteArray(32) { 0x11 },720        )721        assertEquals("77".repeat(32), paired.get("trusted-tablet")?.longTermPublicKeyHex)722        master.fill(0)723    }724725    /** Pins the helper the manifest loop and SyncViewModel.confirmVouchedDevice share. */726    @Test fun `revocation matches on id or key but never on the empty-key sentinel`() {727        val registry = FakeDeviceRegistry()728        registry.mergeRevocations(729            listOf(730                DeviceRevocation("gone", "dd".repeat(32), 1L, "issuer"),731                DeviceRevocation("keyless", NO_KEY_SENTINEL, 2L, "issuer"),732            ),733        )734        assertTrue(registry.isRevoked("gone"))735        assertTrue(registry.isRevoked("fresh-id", "DD".repeat(32)))736        assertTrue(registry.isRevoked("keyless"))737        assertFalse(registry.isRevoked("fresh-id", "99".repeat(32)))738        // A device that genuinely never stored a key must not inherit the739        // sentinel's revocation.740        assertFalse(registry.isRevoked("innocent", NO_KEY_SENTINEL))741    }742743    @Test fun `public key decoder requires exactly 32 bytes`() {744        assertEquals(32, hexToPubkey("ab".repeat(32)).size)745        assertTrue(runCatching { hexToPubkey("ab".repeat(31)) }.isFailure)746        assertTrue(runCatching { hexToPubkey("ab".repeat(33)) }.isFailure)747        assertEquals(64, hexToBytes("ab".repeat(64)).size)748    }749}