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 `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 = "direct",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    /**375     * The other half: a device that was itself only vouched for must not be able376     * to vouch for anyone else. Without this, trust chains transitively from a377     * single direct pairing — which the browser refuses outright.378     */379    @Test fun `a vouched issuer cannot introduce further devices`() = runBlocking {380        val repo = VaultRepository(FakeStorage())381        repo.createVault("correct horse")382        val master = repo.syncMasterKey()!!383        val issuerSigning = deriveSyncSigningKeypair(master, "vouched-issuer")384        val paired = FakeDeviceRegistry(385            listOf(386                PairedDevice(387                    deviceId = "vouched-issuer",388                    deviceName = "Vouched Device",389                    deviceType = "extension",390                    longTermPublicKeyHex = "11".repeat(32),391                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),392                    trustSource = "vouched",393                    pairedAt = 1L,394                ),395            ),396        )397        val pending = FakePendingRegistry()398        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)399        val manifest = buildSignedDeviceManifest(400            issuerDeviceId = "vouched-issuer",401            devices = listOf(402                ManifestDeviceEntry(403                    deviceId = "smuggled-device",404                    deviceName = "Smuggled",405                    deviceType = "extension",406                    longTermPublicKeyHex = "aa".repeat(32),407                    signingPublicKeyHex = "bb".repeat(32),408                    relayUrl = meshRelay,409                    pairedAt = 44L,410                ),411            ),412            signingPrivateKey = issuerSigning.privateKey,413        )414415        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })416417        assertEquals("a vouched device must not chain trust onward", 0, pending.devices.size)418        master.fill(0)419    }420421    @Test fun `a self asserted manifest issuer cannot override the authenticated peer`() = runBlocking {422        val repo = VaultRepository(FakeStorage())423        repo.createVault("correct horse")424        val master = repo.syncMasterKey()!!425        val claimedSigning = deriveSyncSigningKeypair(master, "claimed-issuer")426        val paired = FakeDeviceRegistry(427            listOf(428                PairedDevice(429                    deviceId = "claimed-issuer",430                    deviceName = "Claimed Browser",431                    deviceType = "extension",432                    longTermPublicKeyHex = "11".repeat(32),433                    signingPublicKeyHex = pubkeyToHex(claimedSigning.publicKeyRaw),434                    trustSource = "direct",435                    pairedAt = 1L,436                ),437                PairedDevice(438                    deviceId = "authenticated-peer",439                    deviceName = "Authenticated Phone",440                    deviceType = "android",441                    longTermPublicKeyHex = "22".repeat(32),442                    signingPublicKeyHex = "33".repeat(32),443                    trustSource = "direct",444                    pairedAt = 2L,445                ),446            ),447        )448        val pending = FakePendingRegistry()449        val adapter = VaultSyncAdapter(450            repo,451            ourDeviceId = "android-self",452            pairedDevices = paired,453            pendingVouched = pending,454        )455        val manifest = buildSignedDeviceManifest(456            issuerDeviceId = "claimed-issuer",457            devices = listOf(458                ManifestDeviceEntry(459                    deviceId = "smuggled-device",460                    deviceName = "Smuggled",461                    deviceType = "extension",462                    longTermPublicKeyHex = "aa".repeat(32),463                    signingPublicKeyHex = "bb".repeat(32),464                    relayUrl = meshRelay,465                    pairedAt = 44L,466                ),467            ),468            signingPrivateKey = claimedSigning.privateKey,469        )470471        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x22 })472473        assertEquals(0, pending.devices.size)474475        // Control: the same manifest over the claimed issuer's OWN session does476        // admit the device. Without it the assertion above could pass for any477        // reason — a malformed body, a bad signature, a dead code path.478        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })479        assertEquals("vouched", paired.get("smuggled-device")?.trustSource)480        assertTrue(pending.devices.isEmpty())481        master.fill(0)482    }483484    @Test fun `a revoked long term key cannot walk back in under a fresh deviceId`() = runBlocking {485        val repo = VaultRepository(FakeStorage())486        repo.createVault("correct horse")487        val master = repo.syncMasterKey()!!488        val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")489        val stolenKey = "dd".repeat(32)490        val paired = FakeDeviceRegistry(491            listOf(492                PairedDevice(493                    deviceId = "phone-issuer",494                    deviceName = "Pixel",495                    deviceType = "android",496                    longTermPublicKeyHex = "11".repeat(32),497                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),498                    trustSource = "direct",499                    pairedAt = 1L,500                ),501                PairedDevice(502                    deviceId = "stolen-laptop",503                    deviceName = "Old Laptop",504                    deviceType = "extension",505                    longTermPublicKeyHex = stolenKey,506                    signingPublicKeyHex = "ee".repeat(32),507                    trustSource = "direct",508                    pairedAt = 2L,509                ),510            ),511        )512        val pending = FakePendingRegistry()513        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)514515        adapter.applyIncomingDeviceManifest(516            buildSignedDeviceManifest(517                issuerDeviceId = "phone-issuer",518                devices = emptyList(),519                signingPrivateKey = issuerSigning.privateKey,520                revoked = listOf(521                    DeviceRevocation(522                        deviceId = "stolen-laptop",523                        longTermPublicKeyHex = stolenKey,524                        revokedAt = System.currentTimeMillis(),525                        revokedByDeviceId = "phone-issuer",526                    ),527                ),528            ),529            ByteArray(32) { 0x11 },530        )531        assertNull(paired.get("stolen-laptop"))532533        // deviceId is the issuer's to choose, so re-vouching the same key under534        // a new one is free. Terminality has to follow the key, not the label.535        adapter.applyIncomingDeviceManifest(536            buildSignedDeviceManifest(537                issuerDeviceId = "phone-issuer",538                devices = listOf(539                    ManifestDeviceEntry(540                        deviceId = "stolen-laptop-mk2",541                        deviceName = "New Laptop",542                        deviceType = "extension",543                        longTermPublicKeyHex = stolenKey,544                        signingPublicKeyHex = "ee".repeat(32),545                        relayUrl = meshRelay,546                        pairedAt = System.currentTimeMillis(),547                    ),548                ),549                signingPrivateKey = issuerSigning.privateKey,550            ),551            ByteArray(32) { 0x11 },552        )553        assertEquals(0, pending.devices.size)554        assertNull(paired.get("stolen-laptop-mk2"))555556        // Control: the same fresh id carrying a key that was never revoked557        // queues normally, so the rejection above is the key check, not the id.558        adapter.applyIncomingDeviceManifest(559            buildSignedDeviceManifest(560                issuerDeviceId = "phone-issuer",561                devices = listOf(562                    ManifestDeviceEntry(563                        deviceId = "stolen-laptop-mk2",564                        deviceName = "New Laptop",565                        deviceType = "extension",566                        longTermPublicKeyHex = "99".repeat(32),567                        signingPublicKeyHex = "ee".repeat(32),568                        relayUrl = meshRelay,569                        pairedAt = System.currentTimeMillis(),570                    ),571                ),572                signingPrivateKey = issuerSigning.privateKey,573            ),574            ByteArray(32) { 0x11 },575        )576        assertEquals("vouched", paired.get("stolen-laptop-mk2")?.trustSource)577        assertTrue(pending.devices.isEmpty())578        master.fill(0)579    }580581    @Test fun `a revoked key cannot be written onto a device we still trust`() = runBlocking {582        val repo = VaultRepository(FakeStorage())583        repo.createVault("correct horse")584        val master = repo.syncMasterKey()!!585        val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")586        val revokedKey = "dd".repeat(32)587        val paired = FakeDeviceRegistry(588            listOf(589                PairedDevice(590                    deviceId = "phone-issuer",591                    deviceName = "Pixel",592                    deviceType = "android",593                    longTermPublicKeyHex = "11".repeat(32),594                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),595                    trustSource = "direct",596                    pairedAt = 1L,597                ),598                PairedDevice(599                    deviceId = "trusted-tablet",600                    deviceName = "Tablet",601                    deviceType = "android",602                    longTermPublicKeyHex = "77".repeat(32),603                    signingPublicKeyHex = "ee".repeat(32),604                    trustSource = "direct",605                    pairedAt = 2L,606                ),607            ),608        )609        val adapter = VaultSyncAdapter(610            repo,611            ourDeviceId = "android-self",612            pairedDevices = paired,613            pendingVouched = FakePendingRegistry(),614        )615616        adapter.applyIncomingDeviceManifest(617            buildSignedDeviceManifest(618                issuerDeviceId = "phone-issuer",619                devices = emptyList(),620                signingPrivateKey = issuerSigning.privateKey,621                revoked = listOf(622                    DeviceRevocation(623                        deviceId = "stolen-laptop",624                        longTermPublicKeyHex = revokedKey,625                        revokedAt = System.currentTimeMillis(),626                        revokedByDeviceId = "phone-issuer",627                    ),628                ),629            ),630            ByteArray(32) { 0x11 },631        )632633        // Re-admission dressed as a key rotation on a device we already trust.634        adapter.applyIncomingDeviceManifest(635            buildSignedDeviceManifest(636                issuerDeviceId = "phone-issuer",637                devices = listOf(638                    ManifestDeviceEntry(639                        deviceId = "trusted-tablet",640                        deviceName = "Tablet",641                        deviceType = "android",642                        longTermPublicKeyHex = revokedKey,643                        signingPublicKeyHex = "ee".repeat(32),644                        pairedAt = 2L,645                    ),646                ),647                signingPrivateKey = issuerSigning.privateKey,648            ),649            ByteArray(32) { 0x11 },650        )651        assertEquals("77".repeat(32), paired.get("trusted-tablet")?.longTermPublicKeyHex)652        master.fill(0)653    }654655    /** Pins the helper the manifest loop and SyncViewModel.confirmVouchedDevice share. */656    @Test fun `revocation matches on id or key but never on the empty-key sentinel`() {657        val registry = FakeDeviceRegistry()658        registry.mergeRevocations(659            listOf(660                DeviceRevocation("gone", "dd".repeat(32), 1L, "issuer"),661                DeviceRevocation("keyless", NO_KEY_SENTINEL, 2L, "issuer"),662            ),663        )664        assertTrue(registry.isRevoked("gone"))665        assertTrue(registry.isRevoked("fresh-id", "DD".repeat(32)))666        assertTrue(registry.isRevoked("keyless"))667        assertFalse(registry.isRevoked("fresh-id", "99".repeat(32)))668        // A device that genuinely never stored a key must not inherit the669        // sentinel's revocation.670        assertFalse(registry.isRevoked("innocent", NO_KEY_SENTINEL))671    }672673    @Test fun `public key decoder requires exactly 32 bytes`() {674        assertEquals(32, hexToPubkey("ab".repeat(32)).size)675        assertTrue(runCatching { hexToPubkey("ab".repeat(31)) }.isFailure)676        assertTrue(runCatching { hexToPubkey("ab".repeat(33)) }.isFailure)677        assertEquals(64, hexToBytes("ab".repeat(64)).size)678    }679}