VaultSyncAdapterTest.kt

29.1 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 class FakeDeviceRegistry(38        devices: List<PairedDevice> = emptyList(),39    ) : PairedDeviceRegistry {40        private val byId = devices.associateBy { it.deviceId }.toMutableMap()41        private val revocations = mutableListOf<DeviceRevocation>()42        override fun getAll(): List<PairedDevice> = byId.values.toList()43        override fun get(id: String): PairedDevice? = byId[id]44        override fun upsert(device: PairedDevice) { byId[device.deviceId] = device }45        override fun remove(deviceId: String) { byId.remove(deviceId) }46        override fun listRevocations(): List<DeviceRevocation> = revocations.toList()47        override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> {48            val known = revocations.map { it.deviceId }.toSet()49            val fresh = incoming.filterNot { known.contains(it.deviceId) }50            revocations += fresh51            return fresh.map { it.deviceId }52        }53        override fun clearRevocation(deviceId: String) {54            revocations.removeAll { it.deviceId == deviceId }55        }56    }5758    private class FakePendingRegistry : PendingVouchedRegistry {59        val devices = mutableListOf<PendingVouchedDevice>()60        override fun getAll(): List<PendingVouchedDevice> = devices.toList()61        override fun upsert(device: PendingVouchedDevice) {62            devices.removeAll { it.deviceId == device.deviceId }63            devices += device64        }65        override fun remove(id: String) { devices.removeAll { it.deviceId == id } }66    }6768    private fun newAdapter(): Pair<VaultRepository, VaultSyncAdapter> {69        val repo = VaultRepository(FakeStorage())70        runBlocking { repo.createVault("correct horse") }71        return repo to VaultSyncAdapter(repo, ourDeviceId = "android-test")72    }7374    @Test fun manifest_and_vault_id_reflect_the_vault() = runBlocking {75        val (repo, adapter) = newAdapter()76        val e = repo.addEntry(VaultEntry(id = "", title = "GitHub", createdAt = 0, modifiedAt = 0, password = "p"))77        val manifest = adapter.buildEntryManifest()78        assertEquals(setOf(e.id), manifest.keys)79        assertTrue(adapter.computeVaultId().length == 64) // blake3-256 hex80    }8182    @Test fun envelope_round_trips_within_the_vault() = runBlocking {83        val (repo, adapter) = newAdapter()84        val e = repo.addEntry(VaultEntry(id = "", title = "Mail", createdAt = 0, modifiedAt = 0, password = "secret"))85        val envelopes = adapter.getEncryptedEntries(listOf(e.id))86        assertTrue(envelopes.containsKey(e.id))87        // Re-applying the same envelope is a safe no-op (same mtime, no overwrite).88        adapter.applyEntries(envelopes)89        assertEquals(1, repo.getEntries().size)90    }9192    @Test fun swapped_envelope_is_rejected_by_id_binding() = runBlocking {93        val (repo, adapter) = newAdapter()94        val e = repo.addEntry(VaultEntry(id = "", title = "Bank", createdAt = 0, modifiedAt = 0, password = "x"))95        val blob = adapter.getEncryptedEntries(listOf(e.id)).getValue(e.id)96        // Present the ciphertext under a different id — the decrypted entry.id97        // won't match, so it must be dropped, not poison a new slot.98        adapter.applyEntries(mapOf("attacker-chosen-id" to blob))99        assertNull(repo.getEntry("attacker-chosen-id"))100        assertEquals(1, repo.getEntries().size)101    }102103    // HKDF separator (coordinated with peach-browser): an envelope minted under104    // the OLD, unseparated info (`entryId + suffix`) must no longer decrypt. This105    // locks in the conscious wire break and asserts byte-for-byte parity with the106    // browser's matching `legacy_info_envelope_fails_closed` test.107    @Test fun legacy_info_envelope_fails_closed() = runBlocking {108        val (repo, adapter) = newAdapter()109        val master = repo.syncMasterKey()!!110        val entryId = "11111111-2222-4333-8444-555555555555"111        val entry = VaultEntry(id = entryId, title = "LegacyOnly", createdAt = 0, modifiedAt = 0, password = "pw")112113        // Legacy info: no U+0000 separator.114        val legacyInfo = (entryId + "sync-entry-v1").toByteArray(Charsets.UTF_8)115        val gen = org.bouncycastle.crypto.generators.HKDFBytesGenerator(org.bouncycastle.crypto.digests.SHA256Digest())116        gen.init(org.bouncycastle.crypto.params.HKDFParameters(master, ByteArray(0), legacyInfo))117        val key = ByteArray(32).also { gen.generateBytes(it, 0, it.size) }118119        val json = kotlinx.serialization.json.Json { encodeDefaults = false; ignoreUnknownKeys = true }120        val plaintext = json.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)121        val nonce = ByteArray(12).also { java.security.SecureRandom().nextBytes(it) }122        val cipher = javax.crypto.Cipher.getInstance("AES/GCM/NoPadding")123        cipher.init(124            javax.crypto.Cipher.ENCRYPT_MODE,125            javax.crypto.spec.SecretKeySpec(key, "AES"),126            javax.crypto.spec.GCMParameterSpec(128, nonce),127        )128        cipher.updateAAD("peach-sync-entry-v1".toByteArray(Charsets.UTF_8))129        val ct = cipher.doFinal(plaintext)130        val legacyEnvelope = java.util.Base64.getEncoder().encodeToString(nonce + ct)131132        adapter.applyEntries(mapOf(entryId to legacyEnvelope))133        // Patched adapter derives with the separated info → decrypt fails → dropped.134        assertNull(repo.getEntry(entryId))135    }136137    @Test fun signed_device_manifest_round_trips_through_canonical_signature() = runBlocking {138        val master = ByteArray(32) { it.toByte() }139        val signing = deriveSyncSigningKeypair(master, "extension-issuer")140        val manifest = buildSignedDeviceManifest(141            issuerDeviceId = "extension-issuer",142            devices = listOf(143                ManifestDeviceEntry(144                    deviceId = "android-peer",145                    deviceName = "Phone",146                    deviceType = "android",147                    longTermPublicKeyHex = "aa".repeat(32),148                    signingPublicKeyHex = "bb".repeat(32),149                    pairedAt = 123L,150                ),151            ),152            signingPrivateKey = signing.privateKey,153            nowMs = 1_000L,154        )155156        val verified = verifyDeviceManifest(157            manifest,158            signing.publicKeyRaw,159            nowMs = 1_000L,160        )161162        assertEquals("extension-issuer", verified?.issuerDeviceId)163        assertEquals("android-peer", verified?.devices?.single()?.deviceId)164        assertTrue(verifyDeviceManifest(manifest.copy(signatureHex = "00".repeat(64)), signing.publicKeyRaw, nowMs = 1_000L) == null)165    }166167    @Test fun `revocation evicts the device and permanently blocks re-vouching`() = runBlocking {168        val repo = VaultRepository(FakeStorage())169        repo.createVault("correct horse")170        val master = repo.syncMasterKey()!!171        val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")172        val paired = FakeDeviceRegistry(173            listOf(174                PairedDevice(175                    deviceId = "extension-issuer",176                    deviceName = "Peach Browser",177                    deviceType = "extension",178                    longTermPublicKeyHex = "11".repeat(32),179                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),180                    trustSource = "direct",181                    pairedAt = 1L,182                ),183                PairedDevice(184                    deviceId = "stolen-laptop",185                    deviceName = "Old Laptop",186                    deviceType = "extension",187                    longTermPublicKeyHex = "dd".repeat(32),188                    signingPublicKeyHex = "ee".repeat(32),189                    trustSource = "direct",190                    pairedAt = 2L,191                ),192            ),193        )194        val pending = FakePendingRegistry()195        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)196197        val revocation = buildSignedDeviceManifest(198            issuerDeviceId = "extension-issuer",199            devices = emptyList(),200            signingPrivateKey = issuerSigning.privateKey,201            revoked = listOf(202                DeviceRevocation(203                    deviceId = "stolen-laptop",204                    longTermPublicKeyHex = "dd".repeat(32),205                    revokedAt = System.currentTimeMillis(),206                    revokedByDeviceId = "extension-issuer",207                ),208            ),209        )210        adapter.applyIncomingDeviceManifest(revocation, ByteArray(32) { 0x11 })211212        assertNull(paired.get("stolen-laptop"))213214        // A later manifest vouches it back with a fresh pairedAt — exactly the215        // resurrection a timestamp-ordered design would allow.216        val revouch = buildSignedDeviceManifest(217            issuerDeviceId = "extension-issuer",218            devices = listOf(219                ManifestDeviceEntry(220                    deviceId = "stolen-laptop",221                    deviceName = "Old Laptop",222                    deviceType = "extension",223                    longTermPublicKeyHex = "dd".repeat(32),224                    signingPublicKeyHex = "ee".repeat(32),225                    pairedAt = System.currentTimeMillis() + 60_000L,226                ),227            ),228            signingPrivateKey = issuerSigning.privateKey,229        )230        adapter.applyIncomingDeviceManifest(revouch, ByteArray(32) { 0x11 })231232        assertNull(paired.get("stolen-laptop"))233        assertEquals(0, pending.devices.size)234        master.fill(0)235    }236237    @Test fun `a peer cannot revoke this device`() = runBlocking {238        val repo = VaultRepository(FakeStorage())239        repo.createVault("correct horse")240        val master = repo.syncMasterKey()!!241        val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")242        val paired = FakeDeviceRegistry(243            listOf(244                PairedDevice(245                    deviceId = "extension-issuer",246                    deviceName = "Peach Browser",247                    deviceType = "extension",248                    longTermPublicKeyHex = "11".repeat(32),249                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),250                    trustSource = "direct",251                    pairedAt = 1L,252                ),253            ),254        )255        val pending = FakePendingRegistry()256        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)257258        val selfRevoke = buildSignedDeviceManifest(259            issuerDeviceId = "extension-issuer",260            devices = emptyList(),261            signingPrivateKey = issuerSigning.privateKey,262            revoked = listOf(263                DeviceRevocation(264                    deviceId = "android-self",265                    longTermPublicKeyHex = "ff".repeat(32),266                    revokedAt = System.currentTimeMillis(),267                    revokedByDeviceId = "extension-issuer",268                ),269            ),270        )271        adapter.applyIncomingDeviceManifest(selfRevoke, ByteArray(32) { 0x11 })272273        assertTrue(paired.listRevocations().none { it.deviceId == "android-self" })274        master.fill(0)275    }276277    @Test fun incoming_extension_manifest_queues_unknown_vouched_devices_until_confirmed() = runBlocking {278        val repo = VaultRepository(FakeStorage())279        repo.createVault("correct horse")280        val master = repo.syncMasterKey()!!281        val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")282        val paired = FakeDeviceRegistry(283            listOf(284                PairedDevice(285                    deviceId = "extension-issuer",286                    deviceName = "Peach Browser",287                    deviceType = "extension",288                    longTermPublicKeyHex = "11".repeat(32),289                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),290                    trustSource = "direct",291                    pairedAt = 1L,292                ),293            ),294        )295        val pending = FakePendingRegistry()296        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)297        val manifest = buildSignedDeviceManifest(298            issuerDeviceId = "extension-issuer",299            devices = listOf(300                ManifestDeviceEntry(301                    deviceId = "new-phone",302                    deviceName = "June's Phone",303                    deviceType = "android",304                    longTermPublicKeyHex = "aa".repeat(32),305                    signingPublicKeyHex = "bb".repeat(32),306                    pairedAt = 44L,307                ),308            ),309            signingPrivateKey = issuerSigning.privateKey,310        )311312        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })313314        assertNull(paired.get("new-phone"))315        assertEquals(1, pending.devices.size)316        assertEquals("new-phone", pending.devices.single().deviceId)317        assertEquals("vouched target stays sync-inert until UI confirm", emptyList<PairedDevice>(), paired.getAll().filter { it.deviceId == "new-phone" })318        master.fill(0)319    }320    /**321     * Browser parity (`applyVerifiedPeerManifest`): trust is keyed on the issuer322     * being DIRECTLY paired, not on it being a browser. Android used to require323     * deviceType == "extension", which silently killed mesh propagation between324     * native devices — the case the mesh exists for now that phones are the325     * listening side.326     */327    @Test fun `a manifest from a directly paired phone is accepted`() = runBlocking {328        val repo = VaultRepository(FakeStorage())329        repo.createVault("correct horse")330        val master = repo.syncMasterKey()!!331        val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")332        val paired = FakeDeviceRegistry(333            listOf(334                PairedDevice(335                    deviceId = "phone-issuer",336                    deviceName = "Other Phone",337                    deviceType = "android",338                    longTermPublicKeyHex = "11".repeat(32),339                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),340                    trustSource = "direct",341                    pairedAt = 1L,342                ),343            ),344        )345        val pending = FakePendingRegistry()346        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)347        val manifest = buildSignedDeviceManifest(348            issuerDeviceId = "phone-issuer",349            devices = listOf(350                ManifestDeviceEntry(351                    deviceId = "laptop-b",352                    deviceName = "Laptop B",353                    deviceType = "extension",354                    longTermPublicKeyHex = "aa".repeat(32),355                    signingPublicKeyHex = "bb".repeat(32),356                    pairedAt = 44L,357                ),358            ),359            signingPrivateKey = issuerSigning.privateKey,360        )361362        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })363364        assertEquals(1, pending.devices.size)365        assertEquals("laptop-b", pending.devices.single().deviceId)366        master.fill(0)367    }368369    /**370     * The other half: a device that was itself only vouched for must not be able371     * to vouch for anyone else. Without this, trust chains transitively from a372     * single direct pairing — which the browser refuses outright.373     */374    @Test fun `a vouched issuer cannot introduce further devices`() = runBlocking {375        val repo = VaultRepository(FakeStorage())376        repo.createVault("correct horse")377        val master = repo.syncMasterKey()!!378        val issuerSigning = deriveSyncSigningKeypair(master, "vouched-issuer")379        val paired = FakeDeviceRegistry(380            listOf(381                PairedDevice(382                    deviceId = "vouched-issuer",383                    deviceName = "Vouched Device",384                    deviceType = "extension",385                    longTermPublicKeyHex = "11".repeat(32),386                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),387                    trustSource = "vouched",388                    pairedAt = 1L,389                ),390            ),391        )392        val pending = FakePendingRegistry()393        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)394        val manifest = buildSignedDeviceManifest(395            issuerDeviceId = "vouched-issuer",396            devices = listOf(397                ManifestDeviceEntry(398                    deviceId = "smuggled-device",399                    deviceName = "Smuggled",400                    deviceType = "extension",401                    longTermPublicKeyHex = "aa".repeat(32),402                    signingPublicKeyHex = "bb".repeat(32),403                    pairedAt = 44L,404                ),405            ),406            signingPrivateKey = issuerSigning.privateKey,407        )408409        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })410411        assertEquals("a vouched device must not chain trust onward", 0, pending.devices.size)412        master.fill(0)413    }414415    @Test fun `a self asserted manifest issuer cannot override the authenticated peer`() = runBlocking {416        val repo = VaultRepository(FakeStorage())417        repo.createVault("correct horse")418        val master = repo.syncMasterKey()!!419        val claimedSigning = deriveSyncSigningKeypair(master, "claimed-issuer")420        val paired = FakeDeviceRegistry(421            listOf(422                PairedDevice(423                    deviceId = "claimed-issuer",424                    deviceName = "Claimed Browser",425                    deviceType = "extension",426                    longTermPublicKeyHex = "11".repeat(32),427                    signingPublicKeyHex = pubkeyToHex(claimedSigning.publicKeyRaw),428                    trustSource = "direct",429                    pairedAt = 1L,430                ),431                PairedDevice(432                    deviceId = "authenticated-peer",433                    deviceName = "Authenticated Phone",434                    deviceType = "android",435                    longTermPublicKeyHex = "22".repeat(32),436                    signingPublicKeyHex = "33".repeat(32),437                    trustSource = "direct",438                    pairedAt = 2L,439                ),440            ),441        )442        val pending = FakePendingRegistry()443        val adapter = VaultSyncAdapter(444            repo,445            ourDeviceId = "android-self",446            pairedDevices = paired,447            pendingVouched = pending,448        )449        val manifest = buildSignedDeviceManifest(450            issuerDeviceId = "claimed-issuer",451            devices = listOf(452                ManifestDeviceEntry(453                    deviceId = "smuggled-device",454                    deviceName = "Smuggled",455                    deviceType = "extension",456                    longTermPublicKeyHex = "aa".repeat(32),457                    signingPublicKeyHex = "bb".repeat(32),458                    pairedAt = 44L,459                ),460            ),461            signingPrivateKey = claimedSigning.privateKey,462        )463464        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x22 })465466        assertEquals(0, pending.devices.size)467468        // Control: the same manifest over the claimed issuer's OWN session does469        // queue the device. Without it the assertion above could pass for any470        // reason — a malformed body, a bad signature, a dead code path.471        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })472        assertEquals(listOf("smuggled-device"), pending.devices.map { it.deviceId })473        master.fill(0)474    }475476    @Test fun `a revoked long term key cannot walk back in under a fresh deviceId`() = runBlocking {477        val repo = VaultRepository(FakeStorage())478        repo.createVault("correct horse")479        val master = repo.syncMasterKey()!!480        val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")481        val stolenKey = "dd".repeat(32)482        val paired = FakeDeviceRegistry(483            listOf(484                PairedDevice(485                    deviceId = "phone-issuer",486                    deviceName = "Pixel",487                    deviceType = "android",488                    longTermPublicKeyHex = "11".repeat(32),489                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),490                    trustSource = "direct",491                    pairedAt = 1L,492                ),493                PairedDevice(494                    deviceId = "stolen-laptop",495                    deviceName = "Old Laptop",496                    deviceType = "extension",497                    longTermPublicKeyHex = stolenKey,498                    signingPublicKeyHex = "ee".repeat(32),499                    trustSource = "direct",500                    pairedAt = 2L,501                ),502            ),503        )504        val pending = FakePendingRegistry()505        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)506507        adapter.applyIncomingDeviceManifest(508            buildSignedDeviceManifest(509                issuerDeviceId = "phone-issuer",510                devices = emptyList(),511                signingPrivateKey = issuerSigning.privateKey,512                revoked = listOf(513                    DeviceRevocation(514                        deviceId = "stolen-laptop",515                        longTermPublicKeyHex = stolenKey,516                        revokedAt = System.currentTimeMillis(),517                        revokedByDeviceId = "phone-issuer",518                    ),519                ),520            ),521            ByteArray(32) { 0x11 },522        )523        assertNull(paired.get("stolen-laptop"))524525        // deviceId is the issuer's to choose, so re-vouching the same key under526        // a new one is free. Terminality has to follow the key, not the label.527        adapter.applyIncomingDeviceManifest(528            buildSignedDeviceManifest(529                issuerDeviceId = "phone-issuer",530                devices = listOf(531                    ManifestDeviceEntry(532                        deviceId = "stolen-laptop-mk2",533                        deviceName = "New Laptop",534                        deviceType = "extension",535                        longTermPublicKeyHex = stolenKey,536                        signingPublicKeyHex = "ee".repeat(32),537                        pairedAt = System.currentTimeMillis(),538                    ),539                ),540                signingPrivateKey = issuerSigning.privateKey,541            ),542            ByteArray(32) { 0x11 },543        )544        assertEquals(0, pending.devices.size)545        assertNull(paired.get("stolen-laptop-mk2"))546547        // Control: the same fresh id carrying a key that was never revoked548        // queues normally, so the rejection above is the key check, not the id.549        adapter.applyIncomingDeviceManifest(550            buildSignedDeviceManifest(551                issuerDeviceId = "phone-issuer",552                devices = listOf(553                    ManifestDeviceEntry(554                        deviceId = "stolen-laptop-mk2",555                        deviceName = "New Laptop",556                        deviceType = "extension",557                        longTermPublicKeyHex = "99".repeat(32),558                        signingPublicKeyHex = "ee".repeat(32),559                        pairedAt = System.currentTimeMillis(),560                    ),561                ),562                signingPrivateKey = issuerSigning.privateKey,563            ),564            ByteArray(32) { 0x11 },565        )566        assertEquals(listOf("stolen-laptop-mk2"), pending.devices.map { it.deviceId })567        master.fill(0)568    }569570    @Test fun `a revoked key cannot be written onto a device we still trust`() = runBlocking {571        val repo = VaultRepository(FakeStorage())572        repo.createVault("correct horse")573        val master = repo.syncMasterKey()!!574        val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")575        val revokedKey = "dd".repeat(32)576        val paired = FakeDeviceRegistry(577            listOf(578                PairedDevice(579                    deviceId = "phone-issuer",580                    deviceName = "Pixel",581                    deviceType = "android",582                    longTermPublicKeyHex = "11".repeat(32),583                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),584                    trustSource = "direct",585                    pairedAt = 1L,586                ),587                PairedDevice(588                    deviceId = "trusted-tablet",589                    deviceName = "Tablet",590                    deviceType = "android",591                    longTermPublicKeyHex = "77".repeat(32),592                    signingPublicKeyHex = "ee".repeat(32),593                    trustSource = "direct",594                    pairedAt = 2L,595                ),596            ),597        )598        val adapter = VaultSyncAdapter(599            repo,600            ourDeviceId = "android-self",601            pairedDevices = paired,602            pendingVouched = FakePendingRegistry(),603        )604605        adapter.applyIncomingDeviceManifest(606            buildSignedDeviceManifest(607                issuerDeviceId = "phone-issuer",608                devices = emptyList(),609                signingPrivateKey = issuerSigning.privateKey,610                revoked = listOf(611                    DeviceRevocation(612                        deviceId = "stolen-laptop",613                        longTermPublicKeyHex = revokedKey,614                        revokedAt = System.currentTimeMillis(),615                        revokedByDeviceId = "phone-issuer",616                    ),617                ),618            ),619            ByteArray(32) { 0x11 },620        )621622        // Re-admission dressed as a key rotation on a device we already trust.623        adapter.applyIncomingDeviceManifest(624            buildSignedDeviceManifest(625                issuerDeviceId = "phone-issuer",626                devices = listOf(627                    ManifestDeviceEntry(628                        deviceId = "trusted-tablet",629                        deviceName = "Tablet",630                        deviceType = "android",631                        longTermPublicKeyHex = revokedKey,632                        signingPublicKeyHex = "ee".repeat(32),633                        pairedAt = 2L,634                    ),635                ),636                signingPrivateKey = issuerSigning.privateKey,637            ),638            ByteArray(32) { 0x11 },639        )640        assertEquals("77".repeat(32), paired.get("trusted-tablet")?.longTermPublicKeyHex)641        master.fill(0)642    }643644    /** Pins the helper the manifest loop and SyncViewModel.confirmVouchedDevice share. */645    @Test fun `revocation matches on id or key but never on the empty-key sentinel`() {646        val registry = FakeDeviceRegistry()647        registry.mergeRevocations(648            listOf(649                DeviceRevocation("gone", "dd".repeat(32), 1L, "issuer"),650                DeviceRevocation("keyless", NO_KEY_SENTINEL, 2L, "issuer"),651            ),652        )653        assertTrue(registry.isRevoked("gone"))654        assertTrue(registry.isRevoked("fresh-id", "DD".repeat(32)))655        assertTrue(registry.isRevoked("keyless"))656        assertFalse(registry.isRevoked("fresh-id", "99".repeat(32)))657        // A device that genuinely never stored a key must not inherit the658        // sentinel's revocation.659        assertFalse(registry.isRevoked("innocent", NO_KEY_SENTINEL))660    }661662    @Test fun `public key decoder requires exactly 32 bytes`() {663        assertEquals(32, hexToPubkey("ab".repeat(32)).size)664        assertTrue(runCatching { hexToPubkey("ab".repeat(31)) }.isFailure)665        assertTrue(runCatching { hexToPubkey("ab".repeat(33)) }.isFailure)666        assertEquals(64, hexToBytes("ab".repeat(64)).size)667    }668}