VaultSyncAdapterTest.kt

29.5 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        private val byId = devices.associateBy { it.deviceId }.toMutableMap()42        private val revocations = mutableListOf<DeviceRevocation>()43        override fun getAll(): List<PairedDevice> = byId.values.toList()44        override fun get(id: String): PairedDevice? = byId[id]45        override fun upsert(device: PairedDevice) { byId[device.deviceId] = device }46        override fun remove(deviceId: String) { byId.remove(deviceId) }47        override fun listRevocations(): List<DeviceRevocation> = revocations.toList()48        override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> {49            val known = revocations.map { it.deviceId }.toSet()50            val fresh = incoming.filterNot { known.contains(it.deviceId) }51            revocations += fresh52            return fresh.map { it.deviceId }53        }54        override fun clearRevocation(deviceId: String) {55            revocations.removeAll { it.deviceId == deviceId }56        }57    }5859    private class FakePendingRegistry : PendingVouchedRegistry {60        val devices = mutableListOf<PendingVouchedDevice>()61        override fun getAll(): List<PendingVouchedDevice> = devices.toList()62        override fun upsert(device: PendingVouchedDevice) {63            devices.removeAll { it.deviceId == device.deviceId }64            devices += device65        }66        override fun remove(id: String) { devices.removeAll { it.deviceId == id } }67    }6869    private fun newAdapter(): Pair<VaultRepository, VaultSyncAdapter> {70        val repo = VaultRepository(FakeStorage())71        runBlocking { repo.createVault("correct horse") }72        return repo to VaultSyncAdapter(repo, ourDeviceId = "android-test")73    }7475    @Test fun manifest_and_vault_id_reflect_the_vault() = runBlocking {76        val (repo, adapter) = newAdapter()77        val e = repo.addEntry(VaultEntry(id = "", title = "GitHub", createdAt = 0, modifiedAt = 0, password = "p"))78        val manifest = adapter.buildEntryManifest()79        assertEquals(setOf(e.id), manifest.keys)80        assertTrue(adapter.computeVaultId().length == 64) // blake3-256 hex81    }8283    @Test fun envelope_round_trips_within_the_vault() = runBlocking {84        val (repo, adapter) = newAdapter()85        val e = repo.addEntry(VaultEntry(id = "", title = "Mail", createdAt = 0, modifiedAt = 0, password = "secret"))86        val envelopes = adapter.getEncryptedEntries(listOf(e.id))87        assertTrue(envelopes.containsKey(e.id))88        // Re-applying the same envelope is a safe no-op (same mtime, no overwrite).89        adapter.applyEntries(envelopes)90        assertEquals(1, repo.getEntries().size)91    }9293    @Test fun swapped_envelope_is_rejected_by_id_binding() = runBlocking {94        val (repo, adapter) = newAdapter()95        val e = repo.addEntry(VaultEntry(id = "", title = "Bank", createdAt = 0, modifiedAt = 0, password = "x"))96        val blob = adapter.getEncryptedEntries(listOf(e.id)).getValue(e.id)97        // Present the ciphertext under a different id — the decrypted entry.id98        // won't match, so it must be dropped, not poison a new slot.99        adapter.applyEntries(mapOf("attacker-chosen-id" to blob))100        assertNull(repo.getEntry("attacker-chosen-id"))101        assertEquals(1, repo.getEntries().size)102    }103104    // HKDF separator (coordinated with peach-browser): an envelope minted under105    // the OLD, unseparated info (`entryId + suffix`) must no longer decrypt. This106    // locks in the conscious wire break and asserts byte-for-byte parity with the107    // browser's matching `legacy_info_envelope_fails_closed` test.108    @Test fun legacy_info_envelope_fails_closed() = runBlocking {109        val (repo, adapter) = newAdapter()110        val master = repo.syncMasterKey()!!111        val entryId = "11111111-2222-4333-8444-555555555555"112        val entry = VaultEntry(id = entryId, title = "LegacyOnly", createdAt = 0, modifiedAt = 0, password = "pw")113114        // Legacy info: no U+0000 separator.115        val legacyInfo = (entryId + "sync-entry-v1").toByteArray(Charsets.UTF_8)116        val gen = org.bouncycastle.crypto.generators.HKDFBytesGenerator(org.bouncycastle.crypto.digests.SHA256Digest())117        gen.init(org.bouncycastle.crypto.params.HKDFParameters(master, ByteArray(0), legacyInfo))118        val key = ByteArray(32).also { gen.generateBytes(it, 0, it.size) }119120        val json = kotlinx.serialization.json.Json { encodeDefaults = false; ignoreUnknownKeys = true }121        val plaintext = json.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)122        val nonce = ByteArray(12).also { java.security.SecureRandom().nextBytes(it) }123        val cipher = javax.crypto.Cipher.getInstance("AES/GCM/NoPadding")124        cipher.init(125            javax.crypto.Cipher.ENCRYPT_MODE,126            javax.crypto.spec.SecretKeySpec(key, "AES"),127            javax.crypto.spec.GCMParameterSpec(128, nonce),128        )129        cipher.updateAAD("peach-sync-entry-v1".toByteArray(Charsets.UTF_8))130        val ct = cipher.doFinal(plaintext)131        val legacyEnvelope = java.util.Base64.getEncoder().encodeToString(nonce + ct)132133        adapter.applyEntries(mapOf(entryId to legacyEnvelope))134        // Patched adapter derives with the separated info → decrypt fails → dropped.135        assertNull(repo.getEntry(entryId))136    }137138    @Test fun signed_device_manifest_round_trips_through_canonical_signature() = runBlocking {139        val master = ByteArray(32) { it.toByte() }140        val signing = deriveSyncSigningKeypair(master, "extension-issuer")141        val manifest = buildSignedDeviceManifest(142            issuerDeviceId = "extension-issuer",143            devices = listOf(144                ManifestDeviceEntry(145                    deviceId = "android-peer",146                    deviceName = "Phone",147                    deviceType = "android",148                    longTermPublicKeyHex = "aa".repeat(32),149                    signingPublicKeyHex = "bb".repeat(32),150                    pairedAt = 123L,151                ),152            ),153            signingPrivateKey = signing.privateKey,154            nowMs = 1_000L,155        )156157        val verified = verifyDeviceManifest(158            manifest,159            signing.publicKeyRaw,160            nowMs = 1_000L,161        )162163        assertEquals("extension-issuer", verified?.issuerDeviceId)164        assertEquals("android-peer", verified?.devices?.single()?.deviceId)165        assertTrue(verifyDeviceManifest(manifest.copy(signatureHex = "00".repeat(64)), signing.publicKeyRaw, nowMs = 1_000L) == null)166    }167168    @Test fun `vouched peer revocation evicts the device and permanently blocks re-vouching`() = runBlocking {169        val repo = VaultRepository(FakeStorage())170        repo.createVault("correct horse")171        val master = repo.syncMasterKey()!!172        val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")173        val paired = FakeDeviceRegistry(174            listOf(175                PairedDevice(176                    deviceId = "extension-issuer",177                    deviceName = "Peach Browser",178                    deviceType = "extension",179                    longTermPublicKeyHex = "11".repeat(32),180                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),181                    trustSource = "vouched",182                    pairedAt = 1L,183                ),184                PairedDevice(185                    deviceId = "stolen-laptop",186                    deviceName = "Old Laptop",187                    deviceType = "extension",188                    longTermPublicKeyHex = "dd".repeat(32),189                    signingPublicKeyHex = "ee".repeat(32),190                    trustSource = "direct",191                    pairedAt = 2L,192                ),193            ),194        )195        val pending = FakePendingRegistry()196        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)197198        val revocation = buildSignedDeviceManifest(199            issuerDeviceId = "extension-issuer",200            devices = emptyList(),201            signingPrivateKey = issuerSigning.privateKey,202            revoked = listOf(203                DeviceRevocation(204                    deviceId = "stolen-laptop",205                    longTermPublicKeyHex = "dd".repeat(32),206                    revokedAt = System.currentTimeMillis(),207                    revokedByDeviceId = "extension-issuer",208                ),209            ),210        )211        adapter.applyIncomingDeviceManifest(revocation, ByteArray(32) { 0x11 })212213        assertNull(paired.get("stolen-laptop"))214215        // A later manifest vouches it back with a fresh pairedAt — exactly the216        // resurrection a timestamp-ordered design would allow.217        val revouch = buildSignedDeviceManifest(218            issuerDeviceId = "extension-issuer",219            devices = listOf(220                ManifestDeviceEntry(221                    deviceId = "stolen-laptop",222                    deviceName = "Old Laptop",223                    deviceType = "extension",224                    longTermPublicKeyHex = "dd".repeat(32),225                    signingPublicKeyHex = "ee".repeat(32),226                    pairedAt = System.currentTimeMillis() + 60_000L,227                ),228            ),229            signingPrivateKey = issuerSigning.privateKey,230        )231        adapter.applyIncomingDeviceManifest(revouch, ByteArray(32) { 0x11 })232233        assertNull(paired.get("stolen-laptop"))234        assertEquals(0, pending.devices.size)235        master.fill(0)236    }237238    @Test fun `a peer cannot revoke this device`() = runBlocking {239        val repo = VaultRepository(FakeStorage())240        repo.createVault("correct horse")241        val master = repo.syncMasterKey()!!242        val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")243        val paired = FakeDeviceRegistry(244            listOf(245                PairedDevice(246                    deviceId = "extension-issuer",247                    deviceName = "Peach Browser",248                    deviceType = "extension",249                    longTermPublicKeyHex = "11".repeat(32),250                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),251                    trustSource = "direct",252                    pairedAt = 1L,253                ),254            ),255        )256        val pending = FakePendingRegistry()257        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)258259        val selfRevoke = buildSignedDeviceManifest(260            issuerDeviceId = "extension-issuer",261            devices = emptyList(),262            signingPrivateKey = issuerSigning.privateKey,263            revoked = listOf(264                DeviceRevocation(265                    deviceId = "android-self",266                    longTermPublicKeyHex = "ff".repeat(32),267                    revokedAt = System.currentTimeMillis(),268                    revokedByDeviceId = "extension-issuer",269                ),270            ),271        )272        adapter.applyIncomingDeviceManifest(selfRevoke, ByteArray(32) { 0x11 })273274        assertTrue(paired.listRevocations().none { it.deviceId == "android-self" })275        master.fill(0)276    }277278    @Test fun incoming_extension_manifest_auto_admits_routable_same_vault_peer() = runBlocking {279        val repo = VaultRepository(FakeStorage())280        repo.createVault("correct horse")281        val master = repo.syncMasterKey()!!282        val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")283        val paired = FakeDeviceRegistry(284            listOf(285                PairedDevice(286                    deviceId = "extension-issuer",287                    deviceName = "Peach Browser",288                    deviceType = "extension",289                    longTermPublicKeyHex = "11".repeat(32),290                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),291                    trustSource = "direct",292                    pairedAt = 1L,293                ),294            ),295        )296        val pending = FakePendingRegistry()297        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)298        val manifest = buildSignedDeviceManifest(299            issuerDeviceId = "extension-issuer",300            devices = listOf(301                ManifestDeviceEntry(302                    deviceId = "new-phone",303                    deviceName = "June's Phone",304                    deviceType = "android",305                    longTermPublicKeyHex = "aa".repeat(32),306                    signingPublicKeyHex = "bb".repeat(32),307                    relayUrl = meshRelay,308                    pairedAt = 44L,309                ),310            ),311            signingPrivateKey = issuerSigning.privateKey,312        )313314        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })315316        val admitted = paired.get("new-phone")317        assertEquals("vouched", admitted?.trustSource)318        assertEquals(meshRelay, admitted?.relayUrl)319        assertTrue(admitted?.rendezvousSecretHex?.matches(Regex("[0-9a-f]{64}")) == true)320        assertTrue(pending.devices.isEmpty())321        master.fill(0)322    }323    /**324     * Browser parity (`applyVerifiedPeerManifest`): trust is keyed on the issuer325     * being DIRECTLY paired, not on it being a browser. Android used to require326     * deviceType == "extension", which silently killed mesh propagation between327     * native devices — the case the mesh exists for now that phones are the328     * listening side.329     */330    @Test fun `a manifest from a directly paired phone is accepted`() = runBlocking {331        val repo = VaultRepository(FakeStorage())332        repo.createVault("correct horse")333        val master = repo.syncMasterKey()!!334        val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")335        val paired = FakeDeviceRegistry(336            listOf(337                PairedDevice(338                    deviceId = "phone-issuer",339                    deviceName = "Other Phone",340                    deviceType = "android",341                    longTermPublicKeyHex = "11".repeat(32),342                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),343                    trustSource = "direct",344                    pairedAt = 1L,345                ),346            ),347        )348        val pending = FakePendingRegistry()349        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)350        val manifest = buildSignedDeviceManifest(351            issuerDeviceId = "phone-issuer",352            devices = listOf(353                ManifestDeviceEntry(354                    deviceId = "laptop-b",355                    deviceName = "Laptop B",356                    deviceType = "extension",357                    longTermPublicKeyHex = "aa".repeat(32),358                    signingPublicKeyHex = "bb".repeat(32),359                    relayUrl = meshRelay,360                    pairedAt = 44L,361                ),362            ),363            signingPrivateKey = issuerSigning.privateKey,364        )365366        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })367368        assertEquals("vouched", paired.get("laptop-b")?.trustSource)369        assertEquals(meshRelay, paired.get("laptop-b")?.relayUrl)370        assertTrue(pending.devices.isEmpty())371        master.fill(0)372    }373374    /** An admitted peer can propagate the pool without the original hub online. */375    @Test fun `a vouched issuer introduces further mesh devices`() = runBlocking {376        val repo = VaultRepository(FakeStorage())377        repo.createVault("correct horse")378        val master = repo.syncMasterKey()!!379        val issuerSigning = deriveSyncSigningKeypair(master, "vouched-issuer")380        val paired = FakeDeviceRegistry(381            listOf(382                PairedDevice(383                    deviceId = "vouched-issuer",384                    deviceName = "Vouched Device",385                    deviceType = "extension",386                    longTermPublicKeyHex = "11".repeat(32),387                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),388                    trustSource = "vouched",389                    pairedAt = 1L,390                ),391            ),392        )393        val pending = FakePendingRegistry()394        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)395        val manifest = buildSignedDeviceManifest(396            issuerDeviceId = "vouched-issuer",397            devices = listOf(398                ManifestDeviceEntry(399                    deviceId = "onward-mesh-device",400                    deviceName = "Onward mesh device",401                    deviceType = "extension",402                    longTermPublicKeyHex = "aa".repeat(32),403                    signingPublicKeyHex = "bb".repeat(32),404                    relayUrl = meshRelay,405                    pairedAt = 44L,406                ),407            ),408            signingPrivateKey = issuerSigning.privateKey,409        )410411        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })412413        assertEquals("vouched", paired.get("onward-mesh-device")?.trustSource)414        assertEquals(meshRelay, paired.get("onward-mesh-device")?.relayUrl)415        assertTrue(paired.get("onward-mesh-device")?.rendezvousSecretHex != null)416        assertEquals(0, pending.devices.size)417        master.fill(0)418    }419420    @Test fun `a self asserted manifest issuer cannot override the authenticated peer`() = runBlocking {421        val repo = VaultRepository(FakeStorage())422        repo.createVault("correct horse")423        val master = repo.syncMasterKey()!!424        val claimedSigning = deriveSyncSigningKeypair(master, "claimed-issuer")425        val paired = FakeDeviceRegistry(426            listOf(427                PairedDevice(428                    deviceId = "claimed-issuer",429                    deviceName = "Claimed Browser",430                    deviceType = "extension",431                    longTermPublicKeyHex = "11".repeat(32),432                    signingPublicKeyHex = pubkeyToHex(claimedSigning.publicKeyRaw),433                    trustSource = "direct",434                    pairedAt = 1L,435                ),436                PairedDevice(437                    deviceId = "authenticated-peer",438                    deviceName = "Authenticated Phone",439                    deviceType = "android",440                    longTermPublicKeyHex = "22".repeat(32),441                    signingPublicKeyHex = "33".repeat(32),442                    trustSource = "direct",443                    pairedAt = 2L,444                ),445            ),446        )447        val pending = FakePendingRegistry()448        val adapter = VaultSyncAdapter(449            repo,450            ourDeviceId = "android-self",451            pairedDevices = paired,452            pendingVouched = pending,453        )454        val manifest = buildSignedDeviceManifest(455            issuerDeviceId = "claimed-issuer",456            devices = listOf(457                ManifestDeviceEntry(458                    deviceId = "smuggled-device",459                    deviceName = "Smuggled",460                    deviceType = "extension",461                    longTermPublicKeyHex = "aa".repeat(32),462                    signingPublicKeyHex = "bb".repeat(32),463                    relayUrl = meshRelay,464                    pairedAt = 44L,465                ),466            ),467            signingPrivateKey = claimedSigning.privateKey,468        )469470        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x22 })471472        assertEquals(0, pending.devices.size)473474        // Control: the same manifest over the claimed issuer's OWN session does475        // admit the device. Without it the assertion above could pass for any476        // reason — a malformed body, a bad signature, a dead code path.477        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })478        assertEquals("vouched", paired.get("smuggled-device")?.trustSource)479        assertTrue(pending.devices.isEmpty())480        master.fill(0)481    }482483    @Test fun `a revoked long term key cannot walk back in under a fresh deviceId`() = runBlocking {484        val repo = VaultRepository(FakeStorage())485        repo.createVault("correct horse")486        val master = repo.syncMasterKey()!!487        val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")488        val stolenKey = "dd".repeat(32)489        val paired = FakeDeviceRegistry(490            listOf(491                PairedDevice(492                    deviceId = "phone-issuer",493                    deviceName = "Pixel",494                    deviceType = "android",495                    longTermPublicKeyHex = "11".repeat(32),496                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),497                    trustSource = "direct",498                    pairedAt = 1L,499                ),500                PairedDevice(501                    deviceId = "stolen-laptop",502                    deviceName = "Old Laptop",503                    deviceType = "extension",504                    longTermPublicKeyHex = stolenKey,505                    signingPublicKeyHex = "ee".repeat(32),506                    trustSource = "direct",507                    pairedAt = 2L,508                ),509            ),510        )511        val pending = FakePendingRegistry()512        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)513514        adapter.applyIncomingDeviceManifest(515            buildSignedDeviceManifest(516                issuerDeviceId = "phone-issuer",517                devices = emptyList(),518                signingPrivateKey = issuerSigning.privateKey,519                revoked = listOf(520                    DeviceRevocation(521                        deviceId = "stolen-laptop",522                        longTermPublicKeyHex = stolenKey,523                        revokedAt = System.currentTimeMillis(),524                        revokedByDeviceId = "phone-issuer",525                    ),526                ),527            ),528            ByteArray(32) { 0x11 },529        )530        assertNull(paired.get("stolen-laptop"))531532        // deviceId is the issuer's to choose, so re-vouching the same key under533        // a new one is free. Terminality has to follow the key, not the label.534        adapter.applyIncomingDeviceManifest(535            buildSignedDeviceManifest(536                issuerDeviceId = "phone-issuer",537                devices = listOf(538                    ManifestDeviceEntry(539                        deviceId = "stolen-laptop-mk2",540                        deviceName = "New Laptop",541                        deviceType = "extension",542                        longTermPublicKeyHex = stolenKey,543                        signingPublicKeyHex = "ee".repeat(32),544                        relayUrl = meshRelay,545                        pairedAt = System.currentTimeMillis(),546                    ),547                ),548                signingPrivateKey = issuerSigning.privateKey,549            ),550            ByteArray(32) { 0x11 },551        )552        assertEquals(0, pending.devices.size)553        assertNull(paired.get("stolen-laptop-mk2"))554555        // Control: the same fresh id carrying a key that was never revoked556        // queues normally, so the rejection above is the key check, not the id.557        adapter.applyIncomingDeviceManifest(558            buildSignedDeviceManifest(559                issuerDeviceId = "phone-issuer",560                devices = listOf(561                    ManifestDeviceEntry(562                        deviceId = "stolen-laptop-mk2",563                        deviceName = "New Laptop",564                        deviceType = "extension",565                        longTermPublicKeyHex = "99".repeat(32),566                        signingPublicKeyHex = "ee".repeat(32),567                        relayUrl = meshRelay,568                        pairedAt = System.currentTimeMillis(),569                    ),570                ),571                signingPrivateKey = issuerSigning.privateKey,572            ),573            ByteArray(32) { 0x11 },574        )575        assertEquals("vouched", paired.get("stolen-laptop-mk2")?.trustSource)576        assertTrue(pending.devices.isEmpty())577        master.fill(0)578    }579580    @Test fun `a revoked key cannot be written onto a device we still trust`() = runBlocking {581        val repo = VaultRepository(FakeStorage())582        repo.createVault("correct horse")583        val master = repo.syncMasterKey()!!584        val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")585        val revokedKey = "dd".repeat(32)586        val paired = FakeDeviceRegistry(587            listOf(588                PairedDevice(589                    deviceId = "phone-issuer",590                    deviceName = "Pixel",591                    deviceType = "android",592                    longTermPublicKeyHex = "11".repeat(32),593                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),594                    trustSource = "direct",595                    pairedAt = 1L,596                ),597                PairedDevice(598                    deviceId = "trusted-tablet",599                    deviceName = "Tablet",600                    deviceType = "android",601                    longTermPublicKeyHex = "77".repeat(32),602                    signingPublicKeyHex = "ee".repeat(32),603                    trustSource = "direct",604                    pairedAt = 2L,605                ),606            ),607        )608        val adapter = VaultSyncAdapter(609            repo,610            ourDeviceId = "android-self",611            pairedDevices = paired,612            pendingVouched = FakePendingRegistry(),613        )614615        adapter.applyIncomingDeviceManifest(616            buildSignedDeviceManifest(617                issuerDeviceId = "phone-issuer",618                devices = emptyList(),619                signingPrivateKey = issuerSigning.privateKey,620                revoked = listOf(621                    DeviceRevocation(622                        deviceId = "stolen-laptop",623                        longTermPublicKeyHex = revokedKey,624                        revokedAt = System.currentTimeMillis(),625                        revokedByDeviceId = "phone-issuer",626                    ),627                ),628            ),629            ByteArray(32) { 0x11 },630        )631632        // Re-admission dressed as a key rotation on a device we already trust.633        adapter.applyIncomingDeviceManifest(634            buildSignedDeviceManifest(635                issuerDeviceId = "phone-issuer",636                devices = listOf(637                    ManifestDeviceEntry(638                        deviceId = "trusted-tablet",639                        deviceName = "Tablet",640                        deviceType = "android",641                        longTermPublicKeyHex = revokedKey,642                        signingPublicKeyHex = "ee".repeat(32),643                        pairedAt = 2L,644                    ),645                ),646                signingPrivateKey = issuerSigning.privateKey,647            ),648            ByteArray(32) { 0x11 },649        )650        assertEquals("77".repeat(32), paired.get("trusted-tablet")?.longTermPublicKeyHex)651        master.fill(0)652    }653654    /** Pins the helper the manifest loop and SyncViewModel.confirmVouchedDevice share. */655    @Test fun `revocation matches on id or key but never on the empty-key sentinel`() {656        val registry = FakeDeviceRegistry()657        registry.mergeRevocations(658            listOf(659                DeviceRevocation("gone", "dd".repeat(32), 1L, "issuer"),660                DeviceRevocation("keyless", NO_KEY_SENTINEL, 2L, "issuer"),661            ),662        )663        assertTrue(registry.isRevoked("gone"))664        assertTrue(registry.isRevoked("fresh-id", "DD".repeat(32)))665        assertTrue(registry.isRevoked("keyless"))666        assertFalse(registry.isRevoked("fresh-id", "99".repeat(32)))667        // A device that genuinely never stored a key must not inherit the668        // sentinel's revocation.669        assertFalse(registry.isRevoked("innocent", NO_KEY_SENTINEL))670    }671672    @Test fun `public key decoder requires exactly 32 bytes`() {673        assertEquals(32, hexToPubkey("ab".repeat(32)).size)674        assertTrue(runCatching { hexToPubkey("ab".repeat(31)) }.isFailure)675        assertTrue(runCatching { hexToPubkey("ab".repeat(33)) }.isFailure)676        assertEquals(64, hexToBytes("ab".repeat(64)).size)677    }678}