VaultSyncAdapterTest.kt

32.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        var failManifestCommit = false42        private val byId = devices.associateBy { it.deviceId }.toMutableMap()43        private val revocations = mutableListOf<DeviceRevocation>()44        override fun getAll(): List<PairedDevice> = byId.values.toList()45        override fun get(id: String): PairedDevice? = byId[id]46        override fun upsert(device: PairedDevice) { byId[device.deviceId] = device }47        override fun remove(deviceId: String) { byId.remove(deviceId) }48        override fun listRevocations(): List<DeviceRevocation> = revocations.toList()49        override fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean {50            if (failManifestCommit) return false51            val nextRevocations = mergeRevocationRecords(revocations, batch.incomingRevocations).records.toMutableList()52            val nextDevices = byId.toMutableMap()53            batch.removals.forEach(nextDevices::remove)54            batch.upserts.forEach { nextDevices[it.deviceId] = it }55            revocations.clear()56            revocations += nextRevocations57            byId.clear()58            byId.putAll(nextDevices)59            return true60        }61        override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> {62            val merged = mergeRevocationRecords(revocations, incoming)63            revocations.clear()64            revocations += merged.records65            return merged.admitted.map { it.deviceId }66        }67        override fun clearRevocation(deviceId: String) {68            revocations.removeAll { it.deviceId == deviceId }69        }70    }7172    private class FakePendingRegistry : PendingVouchedRegistry {73        val devices = mutableListOf<PendingVouchedDevice>()74        override fun getAll(): List<PendingVouchedDevice> = devices.toList()75        override fun upsert(device: PendingVouchedDevice) {76            devices.removeAll { it.deviceId == device.deviceId }77            devices += device78        }79        override fun remove(id: String) { devices.removeAll { it.deviceId == id } }80    }8182    private fun newAdapter(): Pair<VaultRepository, VaultSyncAdapter> {83        val repo = VaultRepository(FakeStorage())84        runBlocking { repo.createVault("correct horse") }85        return repo to VaultSyncAdapter(repo, ourDeviceId = "android-test")86    }8788    @Test fun manifest_and_vault_id_reflect_the_vault() = runBlocking {89        val (repo, adapter) = newAdapter()90        val e = repo.addEntry(VaultEntry(id = "", title = "GitHub", createdAt = 0, modifiedAt = 0, password = "p"))91        val manifest = adapter.buildEntryManifest()92        assertEquals(setOf(e.id), manifest.keys)93        assertTrue(adapter.computeVaultId().length == 64) // blake3-256 hex94    }9596    @Test fun envelope_round_trips_within_the_vault() = runBlocking {97        val (repo, adapter) = newAdapter()98        val e = repo.addEntry(VaultEntry(id = "", title = "Mail", createdAt = 0, modifiedAt = 0, password = "secret"))99        val envelopes = adapter.getEncryptedEntries(listOf(e.id))100        assertTrue(envelopes.containsKey(e.id))101        // Re-applying the same envelope is a safe no-op (same mtime, no overwrite).102        adapter.applyEntries(envelopes)103        assertEquals(1, repo.getEntries().size)104    }105106    @Test fun swapped_envelope_is_rejected_by_id_binding() = runBlocking {107        val (repo, adapter) = newAdapter()108        val e = repo.addEntry(VaultEntry(id = "", title = "Bank", createdAt = 0, modifiedAt = 0, password = "x"))109        val blob = adapter.getEncryptedEntries(listOf(e.id)).getValue(e.id)110        // Present the ciphertext under a different id — the decrypted entry.id111        // won't match, so it must be dropped, not poison a new slot.112        adapter.applyEntries(mapOf("attacker-chosen-id" to blob))113        assertNull(repo.getEntry("attacker-chosen-id"))114        assertEquals(1, repo.getEntries().size)115    }116117    // HKDF separator (coordinated with peach-browser): an envelope minted under118    // the OLD, unseparated info (`entryId + suffix`) must no longer decrypt. This119    // locks in the conscious wire break and asserts byte-for-byte parity with the120    // browser's matching `legacy_info_envelope_fails_closed` test.121    @Test fun legacy_info_envelope_fails_closed() = runBlocking {122        val (repo, adapter) = newAdapter()123        val master = repo.syncMasterKey()!!124        val entryId = "11111111-2222-4333-8444-555555555555"125        val entry = VaultEntry(id = entryId, title = "LegacyOnly", createdAt = 0, modifiedAt = 0, password = "pw")126127        // Legacy info: no U+0000 separator.128        val legacyInfo = (entryId + "sync-entry-v1").toByteArray(Charsets.UTF_8)129        val gen = org.bouncycastle.crypto.generators.HKDFBytesGenerator(org.bouncycastle.crypto.digests.SHA256Digest())130        gen.init(org.bouncycastle.crypto.params.HKDFParameters(master, ByteArray(0), legacyInfo))131        val key = ByteArray(32).also { gen.generateBytes(it, 0, it.size) }132133        val json = kotlinx.serialization.json.Json { encodeDefaults = false; ignoreUnknownKeys = true }134        val plaintext = json.encodeToString(VaultEntry.serializer(), entry).toByteArray(Charsets.UTF_8)135        val nonce = ByteArray(12).also { java.security.SecureRandom().nextBytes(it) }136        val cipher = javax.crypto.Cipher.getInstance("AES/GCM/NoPadding")137        cipher.init(138            javax.crypto.Cipher.ENCRYPT_MODE,139            javax.crypto.spec.SecretKeySpec(key, "AES"),140            javax.crypto.spec.GCMParameterSpec(128, nonce),141        )142        cipher.updateAAD("peach-sync-entry-v1".toByteArray(Charsets.UTF_8))143        val ct = cipher.doFinal(plaintext)144        val legacyEnvelope = java.util.Base64.getEncoder().encodeToString(nonce + ct)145146        adapter.applyEntries(mapOf(entryId to legacyEnvelope))147        // Patched adapter derives with the separated info → decrypt fails → dropped.148        assertNull(repo.getEntry(entryId))149    }150151    @Test fun signed_device_manifest_round_trips_through_canonical_signature() = runBlocking {152        val master = ByteArray(32) { it.toByte() }153        val signing = deriveSyncSigningKeypair(master, "extension-issuer")154        val manifest = buildSignedDeviceManifest(155            issuerDeviceId = "extension-issuer",156            devices = listOf(157                ManifestDeviceEntry(158                    deviceId = "android-peer",159                    deviceName = "Phone",160                    deviceType = "android",161                    longTermPublicKeyHex = "aa".repeat(32),162                    signingPublicKeyHex = "bb".repeat(32),163                    pairedAt = 123L,164                ),165            ),166            signingPrivateKey = signing.privateKey,167            nowMs = 1_000L,168        )169170        val verified = verifyDeviceManifest(171            manifest,172            signing.publicKeyRaw,173            nowMs = 1_000L,174        )175176        assertEquals("extension-issuer", verified?.issuerDeviceId)177        assertEquals("android-peer", verified?.devices?.single()?.deviceId)178        assertTrue(verifyDeviceManifest(manifest.copy(signatureHex = "00".repeat(64)), signing.publicKeyRaw, nowMs = 1_000L) == null)179    }180181    @Test fun `vouched peer revocation evicts the device and permanently blocks re-vouching`() = runBlocking {182        val repo = VaultRepository(FakeStorage())183        repo.createVault("correct horse")184        val master = repo.syncMasterKey()!!185        val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")186        val paired = FakeDeviceRegistry(187            listOf(188                PairedDevice(189                    deviceId = "extension-issuer",190                    deviceName = "Peach Browser",191                    deviceType = "extension",192                    longTermPublicKeyHex = "11".repeat(32),193                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),194                    trustSource = "vouched",195                    pairedAt = 1L,196                ),197                PairedDevice(198                    deviceId = "stolen-laptop",199                    deviceName = "Old Laptop",200                    deviceType = "extension",201                    longTermPublicKeyHex = "dd".repeat(32),202                    signingPublicKeyHex = "ee".repeat(32),203                    trustSource = "direct",204                    pairedAt = 2L,205                ),206            ),207        )208        val pending = FakePendingRegistry()209        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)210211        val revocation = buildSignedDeviceManifest(212            issuerDeviceId = "extension-issuer",213            devices = emptyList(),214            signingPrivateKey = issuerSigning.privateKey,215            revoked = listOf(216                DeviceRevocation(217                    deviceId = "stolen-laptop",218                    longTermPublicKeyHex = "dd".repeat(32),219                    revokedAt = System.currentTimeMillis(),220                    revokedByDeviceId = "extension-issuer",221                ),222            ),223        )224        adapter.applyIncomingDeviceManifest(revocation, ByteArray(32) { 0x11 })225226        assertNull(paired.get("stolen-laptop"))227228        // A later manifest vouches it back with a fresh pairedAt — exactly the229        // resurrection a timestamp-ordered design would allow.230        val revouch = buildSignedDeviceManifest(231            issuerDeviceId = "extension-issuer",232            devices = listOf(233                ManifestDeviceEntry(234                    deviceId = "stolen-laptop",235                    deviceName = "Old Laptop",236                    deviceType = "extension",237                    longTermPublicKeyHex = "dd".repeat(32),238                    signingPublicKeyHex = "ee".repeat(32),239                    pairedAt = System.currentTimeMillis() + 60_000L,240                ),241            ),242            signingPrivateKey = issuerSigning.privateKey,243        )244        adapter.applyIncomingDeviceManifest(revouch, ByteArray(32) { 0x11 })245246        assertNull(paired.get("stolen-laptop"))247        assertEquals(0, pending.devices.size)248        master.fill(0)249    }250251    @Test fun `a peer cannot revoke this device`() = runBlocking {252        val repo = VaultRepository(FakeStorage())253        repo.createVault("correct horse")254        val master = repo.syncMasterKey()!!255        val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")256        val paired = FakeDeviceRegistry(257            listOf(258                PairedDevice(259                    deviceId = "extension-issuer",260                    deviceName = "Peach Browser",261                    deviceType = "extension",262                    longTermPublicKeyHex = "11".repeat(32),263                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),264                    trustSource = "direct",265                    pairedAt = 1L,266                ),267            ),268        )269        val pending = FakePendingRegistry()270        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)271272        val selfRevoke = buildSignedDeviceManifest(273            issuerDeviceId = "extension-issuer",274            devices = emptyList(),275            signingPrivateKey = issuerSigning.privateKey,276            revoked = listOf(277                DeviceRevocation(278                    deviceId = "android-self",279                    longTermPublicKeyHex = "ff".repeat(32),280                    revokedAt = System.currentTimeMillis(),281                    revokedByDeviceId = "extension-issuer",282                ),283            ),284        )285        adapter.applyIncomingDeviceManifest(selfRevoke, ByteArray(32) { 0x11 })286287        assertTrue(paired.listRevocations().none { it.deviceId == "android-self" })288        master.fill(0)289    }290291    @Test fun incoming_extension_manifest_auto_admits_routable_same_vault_peer() = runBlocking {292        val repo = VaultRepository(FakeStorage())293        repo.createVault("correct horse")294        val master = repo.syncMasterKey()!!295        val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")296        val paired = FakeDeviceRegistry(297            listOf(298                PairedDevice(299                    deviceId = "extension-issuer",300                    deviceName = "Peach Browser",301                    deviceType = "extension",302                    longTermPublicKeyHex = "11".repeat(32),303                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),304                    trustSource = "direct",305                    pairedAt = 1L,306                ),307            ),308        )309        val pending = FakePendingRegistry()310        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)311        val manifest = buildSignedDeviceManifest(312            issuerDeviceId = "extension-issuer",313            devices = listOf(314                ManifestDeviceEntry(315                    deviceId = "new-phone",316                    deviceName = "June's Phone",317                    deviceType = "android",318                    longTermPublicKeyHex = "aa".repeat(32),319                    signingPublicKeyHex = "bb".repeat(32),320                    relayUrl = meshRelay,321                    pairedAt = 44L,322                ),323            ),324            signingPrivateKey = issuerSigning.privateKey,325        )326327        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })328329        val admitted = paired.get("new-phone")330        assertEquals("vouched", admitted?.trustSource)331        assertEquals(meshRelay, admitted?.relayUrl)332        assertTrue(admitted?.rendezvousSecretHex?.matches(Regex("[0-9a-f]{64}")) == true)333        assertTrue(pending.devices.isEmpty())334        master.fill(0)335    }336337    @Test fun failed_manifest_commit_restores_pending_before_trust_can_land() = runBlocking {338        val repo = VaultRepository(FakeStorage())339        repo.createVault("correct horse")340        val master = repo.syncMasterKey()!!341        val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")342        val paired = FakeDeviceRegistry(343            listOf(344                PairedDevice(345                    deviceId = "extension-issuer",346                    deviceName = "Peach Browser",347                    deviceType = "extension",348                    longTermPublicKeyHex = "11".repeat(32),349                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),350                    trustSource = "direct",351                    pairedAt = 1L,352                ),353            ),354        ).also { it.failManifestCommit = true }355        val pending = FakePendingRegistry().also {356            it.upsert(357                PendingVouchedDevice(358                    deviceId = "new-phone",359                    deviceName = "June's Phone",360                    deviceType = "android",361                    longTermPublicKeyHex = "aa".repeat(32),362                    signingPublicKeyHex = "bb".repeat(32),363                    pairedAt = 44L,364                    receivedAt = 45L,365                    vouchedByDeviceId = "extension-issuer",366                ),367            )368        }369        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)370        val manifest = buildSignedDeviceManifest(371            issuerDeviceId = "extension-issuer",372            devices = listOf(373                ManifestDeviceEntry(374                    deviceId = "new-phone",375                    deviceName = "June's Phone",376                    deviceType = "android",377                    longTermPublicKeyHex = "aa".repeat(32),378                    signingPublicKeyHex = "bb".repeat(32),379                    relayUrl = meshRelay,380                    pairedAt = 44L,381                ),382            ),383            signingPrivateKey = issuerSigning.privateKey,384        )385386        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })387388        assertNull("failed registry commit must not install trust", paired.get("new-phone"))389        assertEquals("pending introduction is restored for retry", 1, pending.devices.size)390        master.fill(0)391    }392    /**393     * Browser parity (`applyVerifiedPeerManifest`): trust is keyed on the issuer394     * being DIRECTLY paired, not on it being a browser. Android used to require395     * deviceType == "extension", which silently killed mesh propagation between396     * native devices — the case the mesh exists for now that phones are the397     * listening side.398     */399    @Test fun `a manifest from a directly paired phone is accepted`() = runBlocking {400        val repo = VaultRepository(FakeStorage())401        repo.createVault("correct horse")402        val master = repo.syncMasterKey()!!403        val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")404        val paired = FakeDeviceRegistry(405            listOf(406                PairedDevice(407                    deviceId = "phone-issuer",408                    deviceName = "Other Phone",409                    deviceType = "android",410                    longTermPublicKeyHex = "11".repeat(32),411                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),412                    trustSource = "direct",413                    pairedAt = 1L,414                ),415            ),416        )417        val pending = FakePendingRegistry()418        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)419        val manifest = buildSignedDeviceManifest(420            issuerDeviceId = "phone-issuer",421            devices = listOf(422                ManifestDeviceEntry(423                    deviceId = "laptop-b",424                    deviceName = "Laptop B",425                    deviceType = "extension",426                    longTermPublicKeyHex = "aa".repeat(32),427                    signingPublicKeyHex = "bb".repeat(32),428                    relayUrl = meshRelay,429                    pairedAt = 44L,430                ),431            ),432            signingPrivateKey = issuerSigning.privateKey,433        )434435        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })436437        assertEquals("vouched", paired.get("laptop-b")?.trustSource)438        assertEquals(meshRelay, paired.get("laptop-b")?.relayUrl)439        assertTrue(pending.devices.isEmpty())440        master.fill(0)441    }442443    /** An admitted peer can propagate the pool without the original hub online. */444    @Test fun `a vouched issuer introduces further mesh devices`() = runBlocking {445        val repo = VaultRepository(FakeStorage())446        repo.createVault("correct horse")447        val master = repo.syncMasterKey()!!448        val issuerSigning = deriveSyncSigningKeypair(master, "vouched-issuer")449        val paired = FakeDeviceRegistry(450            listOf(451                PairedDevice(452                    deviceId = "vouched-issuer",453                    deviceName = "Vouched Device",454                    deviceType = "extension",455                    longTermPublicKeyHex = "11".repeat(32),456                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),457                    trustSource = "vouched",458                    pairedAt = 1L,459                ),460            ),461        )462        val pending = FakePendingRegistry()463        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)464        val manifest = buildSignedDeviceManifest(465            issuerDeviceId = "vouched-issuer",466            devices = listOf(467                ManifestDeviceEntry(468                    deviceId = "onward-mesh-device",469                    deviceName = "Onward mesh device",470                    deviceType = "extension",471                    longTermPublicKeyHex = "aa".repeat(32),472                    signingPublicKeyHex = "bb".repeat(32),473                    relayUrl = meshRelay,474                    pairedAt = 44L,475                ),476            ),477            signingPrivateKey = issuerSigning.privateKey,478        )479480        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })481482        assertEquals("vouched", paired.get("onward-mesh-device")?.trustSource)483        assertEquals(meshRelay, paired.get("onward-mesh-device")?.relayUrl)484        assertTrue(paired.get("onward-mesh-device")?.rendezvousSecretHex != null)485        assertEquals(0, pending.devices.size)486        master.fill(0)487    }488489    @Test fun `a self asserted manifest issuer cannot override the authenticated peer`() = runBlocking {490        val repo = VaultRepository(FakeStorage())491        repo.createVault("correct horse")492        val master = repo.syncMasterKey()!!493        val claimedSigning = deriveSyncSigningKeypair(master, "claimed-issuer")494        val paired = FakeDeviceRegistry(495            listOf(496                PairedDevice(497                    deviceId = "claimed-issuer",498                    deviceName = "Claimed Browser",499                    deviceType = "extension",500                    longTermPublicKeyHex = "11".repeat(32),501                    signingPublicKeyHex = pubkeyToHex(claimedSigning.publicKeyRaw),502                    trustSource = "direct",503                    pairedAt = 1L,504                ),505                PairedDevice(506                    deviceId = "authenticated-peer",507                    deviceName = "Authenticated Phone",508                    deviceType = "android",509                    longTermPublicKeyHex = "22".repeat(32),510                    signingPublicKeyHex = "33".repeat(32),511                    trustSource = "direct",512                    pairedAt = 2L,513                ),514            ),515        )516        val pending = FakePendingRegistry()517        val adapter = VaultSyncAdapter(518            repo,519            ourDeviceId = "android-self",520            pairedDevices = paired,521            pendingVouched = pending,522        )523        val manifest = buildSignedDeviceManifest(524            issuerDeviceId = "claimed-issuer",525            devices = listOf(526                ManifestDeviceEntry(527                    deviceId = "smuggled-device",528                    deviceName = "Smuggled",529                    deviceType = "extension",530                    longTermPublicKeyHex = "aa".repeat(32),531                    signingPublicKeyHex = "bb".repeat(32),532                    relayUrl = meshRelay,533                    pairedAt = 44L,534                ),535            ),536            signingPrivateKey = claimedSigning.privateKey,537        )538539        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x22 })540541        assertEquals(0, pending.devices.size)542543        // Control: the same manifest over the claimed issuer's OWN session does544        // admit the device. Without it the assertion above could pass for any545        // reason — a malformed body, a bad signature, a dead code path.546        adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })547        assertEquals("vouched", paired.get("smuggled-device")?.trustSource)548        assertTrue(pending.devices.isEmpty())549        master.fill(0)550    }551552    @Test fun `a revoked long term key cannot walk back in under a fresh deviceId`() = runBlocking {553        val repo = VaultRepository(FakeStorage())554        repo.createVault("correct horse")555        val master = repo.syncMasterKey()!!556        val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")557        val stolenKey = "dd".repeat(32)558        val paired = FakeDeviceRegistry(559            listOf(560                PairedDevice(561                    deviceId = "phone-issuer",562                    deviceName = "Pixel",563                    deviceType = "android",564                    longTermPublicKeyHex = "11".repeat(32),565                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),566                    trustSource = "direct",567                    pairedAt = 1L,568                ),569                PairedDevice(570                    deviceId = "stolen-laptop",571                    deviceName = "Old Laptop",572                    deviceType = "extension",573                    longTermPublicKeyHex = stolenKey,574                    signingPublicKeyHex = "ee".repeat(32),575                    trustSource = "direct",576                    pairedAt = 2L,577                ),578            ),579        )580        val pending = FakePendingRegistry()581        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)582583        adapter.applyIncomingDeviceManifest(584            buildSignedDeviceManifest(585                issuerDeviceId = "phone-issuer",586                devices = emptyList(),587                signingPrivateKey = issuerSigning.privateKey,588                revoked = listOf(589                    DeviceRevocation(590                        deviceId = "stolen-laptop",591                        longTermPublicKeyHex = stolenKey,592                        revokedAt = System.currentTimeMillis(),593                        revokedByDeviceId = "phone-issuer",594                    ),595                ),596            ),597            ByteArray(32) { 0x11 },598        )599        assertNull(paired.get("stolen-laptop"))600601        // deviceId is the issuer's to choose, so re-vouching the same key under602        // a new one is free. Terminality has to follow the key, not the label.603        adapter.applyIncomingDeviceManifest(604            buildSignedDeviceManifest(605                issuerDeviceId = "phone-issuer",606                devices = listOf(607                    ManifestDeviceEntry(608                        deviceId = "stolen-laptop-mk2",609                        deviceName = "New Laptop",610                        deviceType = "extension",611                        longTermPublicKeyHex = stolenKey,612                        signingPublicKeyHex = "ee".repeat(32),613                        relayUrl = meshRelay,614                        pairedAt = System.currentTimeMillis(),615                    ),616                ),617                signingPrivateKey = issuerSigning.privateKey,618            ),619            ByteArray(32) { 0x11 },620        )621        assertEquals(0, pending.devices.size)622        assertNull(paired.get("stolen-laptop-mk2"))623624        // Control: the same fresh id carrying a key that was never revoked625        // queues normally, so the rejection above is the key check, not the id.626        adapter.applyIncomingDeviceManifest(627            buildSignedDeviceManifest(628                issuerDeviceId = "phone-issuer",629                devices = listOf(630                    ManifestDeviceEntry(631                        deviceId = "stolen-laptop-mk2",632                        deviceName = "New Laptop",633                        deviceType = "extension",634                        longTermPublicKeyHex = "99".repeat(32),635                        signingPublicKeyHex = "ee".repeat(32),636                        relayUrl = meshRelay,637                        pairedAt = System.currentTimeMillis(),638                    ),639                ),640                signingPrivateKey = issuerSigning.privateKey,641            ),642            ByteArray(32) { 0x11 },643        )644        assertEquals("vouched", paired.get("stolen-laptop-mk2")?.trustSource)645        assertTrue(pending.devices.isEmpty())646        master.fill(0)647    }648649    @Test fun `a revoked key cannot be written onto a device we still trust`() = runBlocking {650        val repo = VaultRepository(FakeStorage())651        repo.createVault("correct horse")652        val master = repo.syncMasterKey()!!653        val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")654        val revokedKey = "dd".repeat(32)655        val paired = FakeDeviceRegistry(656            listOf(657                PairedDevice(658                    deviceId = "phone-issuer",659                    deviceName = "Pixel",660                    deviceType = "android",661                    longTermPublicKeyHex = "11".repeat(32),662                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),663                    trustSource = "direct",664                    pairedAt = 1L,665                ),666                PairedDevice(667                    deviceId = "trusted-tablet",668                    deviceName = "Tablet",669                    deviceType = "android",670                    longTermPublicKeyHex = "77".repeat(32),671                    signingPublicKeyHex = "ee".repeat(32),672                    trustSource = "direct",673                    pairedAt = 2L,674                ),675            ),676        )677        val adapter = VaultSyncAdapter(678            repo,679            ourDeviceId = "android-self",680            pairedDevices = paired,681            pendingVouched = FakePendingRegistry(),682        )683684        adapter.applyIncomingDeviceManifest(685            buildSignedDeviceManifest(686                issuerDeviceId = "phone-issuer",687                devices = emptyList(),688                signingPrivateKey = issuerSigning.privateKey,689                revoked = listOf(690                    DeviceRevocation(691                        deviceId = "stolen-laptop",692                        longTermPublicKeyHex = revokedKey,693                        revokedAt = System.currentTimeMillis(),694                        revokedByDeviceId = "phone-issuer",695                    ),696                ),697            ),698            ByteArray(32) { 0x11 },699        )700701        // Re-admission dressed as a key rotation on a device we already trust.702        adapter.applyIncomingDeviceManifest(703            buildSignedDeviceManifest(704                issuerDeviceId = "phone-issuer",705                devices = listOf(706                    ManifestDeviceEntry(707                        deviceId = "trusted-tablet",708                        deviceName = "Tablet",709                        deviceType = "android",710                        longTermPublicKeyHex = revokedKey,711                        signingPublicKeyHex = "ee".repeat(32),712                        pairedAt = 2L,713                    ),714                ),715                signingPrivateKey = issuerSigning.privateKey,716            ),717            ByteArray(32) { 0x11 },718        )719        assertEquals("77".repeat(32), paired.get("trusted-tablet")?.longTermPublicKeyHex)720        master.fill(0)721    }722723    /** Pins the helper the manifest loop and SyncViewModel.confirmVouchedDevice share. */724    @Test fun `revocation matches on id or key but never on the empty-key sentinel`() {725        val registry = FakeDeviceRegistry()726        registry.mergeRevocations(727            listOf(728                DeviceRevocation("gone", "dd".repeat(32), 1L, "issuer"),729                DeviceRevocation("keyless", NO_KEY_SENTINEL, 2L, "issuer"),730            ),731        )732        assertTrue(registry.isRevoked("gone"))733        assertTrue(registry.isRevoked("fresh-id", "DD".repeat(32)))734        assertTrue(registry.isRevoked("keyless"))735        assertFalse(registry.isRevoked("fresh-id", "99".repeat(32)))736        // A device that genuinely never stored a key must not inherit the737        // sentinel's revocation.738        assertFalse(registry.isRevoked("innocent", NO_KEY_SENTINEL))739    }740741    @Test fun `public key decoder requires exactly 32 bytes`() {742        assertEquals(32, hexToPubkey("ab".repeat(32)).size)743        assertTrue(runCatching { hexToPubkey("ab".repeat(31)) }.isFailure)744        assertTrue(runCatching { hexToPubkey("ab".repeat(33)) }.isFailure)745        assertEquals(64, hexToBytes("ab".repeat(64)).size)746    }747}