f4ade7ac52cc

propagate device revocation as terminal signed records in manifest v0x02, and refuse a revoked peer at the reconnect listener (v2.4.4)

time
2026-07-26 08:21 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
sync campaign remaining items
parent
2c0edcf5291a
13 files VERSION+1 −1app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/CodePairingServer.kt+4 −0app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt+31 −1app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt+69 −2app/src/main/java/com/peachpasswords/sync/SyncAlert.kt+3 −0app/src/main/java/com/peachpasswords/sync/SyncMessage.kt+24 −0app/src/main/java/com/peachpasswords/sync/SyncReconnectListener.kt+8 −0app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+36 −2app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+3 −0app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+8 −2app/src/test/java/com/peachpasswords/CrossPlatformKATTest.kt+43 −0app/src/test/java/com/peachpasswords/sync/VaultSyncAdapterTest.kt+122 −0
VERSION+1 −1
@@ -1 +1 @@1-2.4.31+2.4.4

view file

app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 24000320-        versionName "2.4.3"19+        versionCode 24000420+        versionName "2.4.4"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/sync/CodePairingServer.kt+4 −0
@@ -280,6 +280,10 @@280280                 return@withContext PairingEvent.Rejected("sas_mismatch")281281             }282282 283+            // A direct code pairing means a physically present user confirmed284+            // the SAS on both screens — the only thing allowed to lift a285+            // terminal revocation (see PairedDeviceStore.revoke).286+            deviceStore.clearRevocation(device.deviceId)283287             deviceStore.upsert(device)284288             PairingEvent.Paired(name)285289         } catch (e: Exception) {

view file

app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt+31 −1
@@ -4,7 +4,10 @@44 import kotlinx.serialization.json.Json55 import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters66 7-const val DEVICE_MANIFEST_VERSION = 0x017+/** 0x02 added the `revoked` array to the signed body. Coordinated wire change8+ *  with peach-browser — the canonical signing input differs, so a 0x01 peer and9+ *  a 0x02 peer cannot verify each other's manifests. */10+const val DEVICE_MANIFEST_VERSION = 0x02811 private const val DEVICE_MANIFEST_FRESHNESS_MS = 5 * 60_000L912 1013 interface PairedDeviceRegistry {@@ -15,6 +18,14 @@1518             it.longTermPublicKeyHex?.let { key -> CryptoCompare.hex(key, pubkeyHex) } == true1619         }1720     fun upsert(device: PairedDevice)21+    fun remove(deviceId: String) {}22+    fun listRevocations(): List<DeviceRevocation> = emptyList()23+24+    /** Returns the ids that were not already revoked. */25+    fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> = emptyList()26+27+    /** Only a direct, SAS-confirmed re-pair may lift a terminal revocation. */28+    fun clearRevocation(deviceId: String) {}1829 }1930 2031 fun buildSignedDeviceManifest(@@ -22,6 +33,7 @@2233     devices: List<ManifestDeviceEntry>,2334     signingPrivateKey: Ed25519PrivateKeyParameters,2435     nowMs: Long = System.currentTimeMillis(),36+    revoked: List<DeviceRevocation> = emptyList(),2537 ): SignedDeviceManifest {2638     require(issuerDeviceId.isNotBlank()) { "issuerDeviceId is required" }2739     val body = DeviceManifestBody(@@ -29,6 +41,7 @@2941         issuerDeviceId = issuerDeviceId,3042         issuedAt = nowMs,3143         devices = devices.mapNotNull(::normaliseManifestEntry),44+        revoked = revoked.map(::normaliseRevocation),3245     )3346     val signature = signEd25519(signingPrivateKey, canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8))3447     return SignedDeviceManifest(body, pubkeyToHex(signature))@@ -45,6 +58,7 @@4558     if (kotlin.math.abs(nowMs - body.issuedAt) > freshnessMs) return null4659     if (!isHex(manifest.signatureHex, 128)) return null4760     if (!body.devices.all { normaliseManifestEntry(it) != null }) return null61+    if (!body.revoked.all { isValidRevocation(it) }) return null4862     val signature = runCatching { hexToBytes(manifest.signatureHex) }.getOrNull() ?: return null4963     val canonical = canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8)5064     if (!verifyEd25519(issuerSigningPublicKey, canonical, signature)) return null@@ -58,6 +72,7 @@5872             "issuedAt" to body.issuedAt,5973             "issuerDeviceId" to body.issuerDeviceId,6074             "manifestVersion" to body.manifestVersion,75+            "revoked" to body.revoked.map { revocationMap(it) },6176         ),6277     )6378 @@ -88,6 +103,21 @@88103         signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),89104     )90105 }106+107+private fun normaliseRevocation(r: DeviceRevocation): DeviceRevocation =108+    r.copy(longTermPublicKeyHex = r.longTermPublicKeyHex.lowercase())109+110+private fun isValidRevocation(r: DeviceRevocation): Boolean =111+    r.deviceId.isNotBlank() &&112+        isHex(r.longTermPublicKeyHex.lowercase(), 64) &&113+        r.revokedByDeviceId.isNotBlank()114+115+private fun revocationMap(r: DeviceRevocation): Map<String, Any> = linkedMapOf(116+    "deviceId" to r.deviceId,117+    "longTermPublicKeyHex" to r.longTermPublicKeyHex.lowercase(),118+    "revokedAt" to r.revokedAt,119+    "revokedByDeviceId" to r.revokedByDeviceId,120+)91121 92122 private fun manifestEntryMap(entry: ManifestDeviceEntry): Map<String, Any> {93123     val normalised = normaliseManifestEntry(entry) ?: entry

view file

app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt+69 −2
@@ -51,6 +51,7 @@5151     )5252 5353     private val listSerializer = ListSerializer(PairedDevice.serializer())54+    private val revocationSerializer = ListSerializer(DeviceRevocation.serializer())5455 5556     override fun getAll(): List<PairedDevice> {5657         val raw = prefs.getString(KEY_DEVICES, null) ?: return emptyList()@@ -70,10 +71,67 @@7071         persist(next)7172     }7273 73-    fun remove(id: String) {74-        persist(getAll().filterNot { it.deviceId == id })74+    override fun remove(deviceId: String) {75+        persist(getAll().filterNot { it.deviceId == deviceId })7576     }7677 78+    // ── Device revocation (Part 5 §C) ────────────────────────────────────────79+80+    override fun listRevocations(): List<DeviceRevocation> {81+        val raw = prefs.getString(KEY_REVOCATIONS, null) ?: return emptyList()82+        return runCatching { json.decodeFromString(revocationSerializer, raw) }83+            .getOrDefault(emptyList())84+    }85+86+    fun isRevoked(deviceId: String): Boolean =87+        listRevocations().any { it.deviceId == deviceId }88+89+    /**90+     * Revoke a device: record it, then drop it from the registry. The record91+     * rides out in every manifest this device signs from now on.92+     */93+    fun revoke(94+        deviceId: String,95+        revokedByDeviceId: String,96+        nowMs: Long = System.currentTimeMillis(),97+    ): DeviceRevocation? {98+        val existing = listRevocations()99+        if (existing.any { it.deviceId == deviceId }) return null100+        val record = DeviceRevocation(101+            deviceId = deviceId,102+            longTermPublicKeyHex = get(deviceId)?.longTermPublicKeyHex ?: "0".repeat(64),103+            revokedAt = nowMs,104+            revokedByDeviceId = revokedByDeviceId,105+        )106+        persistRevocations(existing + record)107+        remove(deviceId)108+        return record109+    }110+111+    /**112+     * `revokedAt` is peer-supplied and is NOT used for ordering — revocation is113+     * terminal, so an incoming record can never displace one already held.114+     */115+    override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> {116+        val existing = listRevocations()117+        val known = existing.map { it.deviceId }.toSet()118+        val fresh = incoming.filterNot { known.contains(it.deviceId) }119+        if (fresh.isEmpty()) return emptyList()120+        persistRevocations((existing + fresh).takeLast(MAX_REVOCATIONS))121+        return fresh.map { it.deviceId }122+    }123+124+    /**125+     * Clear a revocation. The ONLY legitimate caller is a direct, SAS-confirmed126+     * re-pair — a physically present user on both devices. Never call this from127+     * a path a remote peer can reach.128+     */129+    override fun clearRevocation(deviceId: String) {130+        val existing = listRevocations()131+        val filtered = existing.filterNot { it.deviceId == deviceId }132+        if (filtered.size != existing.size) persistRevocations(filtered)133+    }134+77135     fun clear() {78136         prefs.edit().remove(KEY_DEVICES).apply()79137     }@@ -115,6 +173,12 @@115173         awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }116174     }117175 176+    private fun persistRevocations(records: List<DeviceRevocation>) {177+        prefs.edit()178+            .putString(KEY_REVOCATIONS, json.encodeToString(revocationSerializer, records))179+            .apply()180+    }181+118182     private fun persist(devices: List<PairedDevice>) {119183         prefs.edit()120184             .putString(KEY_DEVICES, json.encodeToString(listSerializer, devices))@@ -124,6 +188,9 @@124188     private companion object {125189         const val PREFS_FILE = "peach_paired_devices"126190         const val KEY_DEVICES = "paired_devices"191+        const val KEY_REVOCATIONS = "device_revocations"192+        /** Bound the list so a hostile peer cannot grow storage without limit. */193+        const val MAX_REVOCATIONS = 500127194         val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }128195     }129196 }

view file

app/src/main/java/com/peachpasswords/sync/SyncAlert.kt+3 −0
@@ -11,6 +11,7 @@1111     REMOTE_PASSWORD_CHANGE,1212     REMOTE_DELETE,1313     DEVICE_KEY_ROTATED,14+    DEVICE_REVOKED,1415 }1516 1617 @Serializable@@ -102,6 +103,8 @@102103     return when (alert.type) {103104         SyncAlertType.REMOTE_PASSWORD_CHANGE -> "$device changed the password for $title"104105         SyncAlertType.REMOTE_DELETE -> "$device deleted $title"106+        SyncAlertType.DEVICE_REVOKED ->107+            "$device revoked a paired device"105108         SyncAlertType.DEVICE_KEY_ROTATED ->106109             "$device's sync key changed - if you didn't reinstall Peach there, rotate your sync identity"107110     }

view file

app/src/main/java/com/peachpasswords/sync/SyncMessage.kt+24 −0
@@ -73,6 +73,30 @@7373     val issuerDeviceId: String,7474     val issuedAt: Long,7575     val devices: List<ManifestDeviceEntry>,76+    val revoked: List<DeviceRevocation> = emptyList(),77+)78+79+/**80+ * A device that must no longer be synced with. Revocation is TERMINAL: once a81+ * deviceId appears here no manifest may re-vouch it. Tombstone-style82+ * newest-timestamp-wins ordering is deliberately NOT used — every timestamp in83+ * a manifest is peer-supplied, so a peer could resurrect a revoked device just84+ * by issuing a fresh `pairedAt`. Re-admitting requires a direct, SAS-confirmed85+ * re-pair with a physically present user.86+ *87+ * HONEST LIMIT: signing keys derive from the shared vault root key, so these88+ * signatures prove vault membership, not device identity. A revoked device89+ * still holds the root key and, running modified software, can ignore its own90+ * revocation. This stops honest peers talking to it; it is not a cryptographic91+ * ejection. A genuinely compromised device needs the master password and92+ * recovery words rotated.93+ */94+@Serializable95+data class DeviceRevocation(96+    val deviceId: String,97+    val longTermPublicKeyHex: String,98+    val revokedAt: Long,99+    val revokedByDeviceId: String,76100 )77101 78102 @Serializable

view file

app/src/main/java/com/peachpasswords/sync/SyncReconnectListener.kt+8 −0
@@ -106,6 +106,14 @@106106             val rendezvousId = selector.optString("rendezvous_id")107107             val known = Rendezvous.resolvePeer(deviceStore.getAll(), rendezvousId)108108                 ?: return@withContext109+            // Part 5 §C: revocation drops the registry row, so resolvePeer110+            // already fails for a revoked device. This second check is the one111+            // that actually has to hold — the phone is the listening side, so112+            // it is what a stolen laptop dials from anywhere over the relay.113+            // Never let a row that somehow survived re-open that door.114+            if (deviceStore.listRevocations().any { it.deviceId == known.deviceId }) {115+                return@withContext116+            }109117             val initiatorKeyHex = known.longTermPublicKeyHex ?: return@withContext110118             val initiatorKey = hexToPubkey(initiatorKeyHex)111119 

view file

app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+36 −2
@@ -124,8 +124,17 @@124124         val master = requireMasterKey()125125         return try {126126             val signing = deriveSyncSigningKeypair(master, ourDeviceId)127-            val devices = registry.getAll().mapNotNull { it.toManifestEntry() }128-            buildSignedDeviceManifest(ourDeviceId, devices, signing.privateKey)127+            val revoked = registry.listRevocations()128+            val revokedIds = revoked.map { it.deviceId }.toSet()129+            val devices = registry.getAll()130+                .filterNot { revokedIds.contains(it.deviceId) }131+                .mapNotNull { it.toManifestEntry() }132+            buildSignedDeviceManifest(133+                ourDeviceId,134+                devices,135+                signing.privateKey,136+                revoked = revoked,137+            )129138         } finally {130139             master.fill(0)131140         }@@ -153,8 +162,33 @@153162         val body = verifyDeviceManifest(manifest, hexToPubkey(signingKey)) ?: return154163         if (body.issuerDeviceId != issuer.deviceId) return155164 165+        // Revocations are absorbed BEFORE any vouching, so a manifest that both166+        // revokes and re-vouches the same device cannot race itself. A peer may167+        // not revoke US — that would let one compromised device sever the whole168+        // mesh — so our own id is filtered out first.169+        val newlyRevoked = registry.mergeRevocations(170+            body.revoked.filterNot { it.deviceId == ourDeviceId },171+        )172+        for (deviceId in newlyRevoked) {173+            registry.remove(deviceId)174+            pending.remove(deviceId)175+            syncAlerts?.record(176+                listOf(177+                    SyncAlert(178+                        type = SyncAlertType.DEVICE_REVOKED,179+                        deviceName = issuer.deviceName ?: "paired device",180+                        entryTitle = deviceId,181+                        timestamp = System.currentTimeMillis(),182+                    ),183+                ),184+            )185+        }186+        val revokedIds = registry.listRevocations().map { it.deviceId }.toSet()187+156188         for (entry in body.devices) {157189             if (entry.deviceId == ourDeviceId || entry.deviceId == issuer.deviceId) continue190+            // Terminal: no manifest re-admits a revoked device.191+            if (revokedIds.contains(entry.deviceId)) continue158192             val existing = registry.get(entry.deviceId)159193             if (existing == null) {160194                 pending.upsert(

view file

app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+3 −0
@@ -195,6 +195,9 @@195195             override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true196196 197197             override suspend fun upsertPairedDevice(device: PairedDevice) {198+                // SAS-confirmed direct adoption — the one consent signal strong199+                // enough to lift a terminal revocation.200+                deviceStore.clearRevocation(device.deviceId)198201                 deviceStore.upsert(device)199202             }200203         }

view file

app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+8 −2
@@ -90,8 +90,14 @@9090         pendingVouchedStore.observeAll()9191             .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())9292 93-    /** Forget a paired device — it can no longer be dialled for sync. */94-    fun unpairDevice(id: String) = viewModelScope.launch { deviceStore.remove(id) }93+    /**94+     * Revoke a paired device. Removing it REVOKES rather than just dropping the95+     * local row: a plain removal is undone the moment any other peer vouches it96+     * back through a manifest, which is exactly what the relay makes easy.97+     */98+    fun unpairDevice(id: String) = viewModelScope.launch {99+        deviceStore.revoke(id, getOrCreateDeviceId(context))100+    }95101 96102     fun confirmVouchedDevice(id: String) = viewModelScope.launch {97103         val pending = pendingVouchedStore.get(id) ?: return@launch

view file

app/src/test/java/com/peachpasswords/CrossPlatformKATTest.kt+43 −0
@@ -1,6 +1,10 @@11 package com.peachpasswords22 3+import com.peachpasswords.sync.DeviceManifestBody4+import com.peachpasswords.sync.DeviceRevocation5+import com.peachpasswords.sync.ManifestDeviceEntry36 import com.peachpasswords.sync.SASDerivation7+import com.peachpasswords.sync.canonicalDeviceManifestBody48 import com.peachpasswords.sync.computeVaultId59 import com.peachpasswords.sync.noiseNKHkdf2ForTest610 import com.peachpasswords.sync.noiseNKInitHashForTest@@ -56,6 +60,45 @@5660         // Blake3-256( masterKey(0x00..0x1f) || "peach-vault-id" ), hex.5761         val masterKey = ByteArray(32) { it.toByte() }5862         assertEquals("ae0ec54041c9ba3e20c0c2d33bdeb2552f3b9a3fb4be3286ec0ef0a61be2fc9c", computeVaultId(masterKey))63+    }64+65+    @Test fun device_manifest_v2_canonical_form_vector() {66+        // Pinned byte-for-byte against peach-browser's canonicalise() in67+        // device-manifest.test.ts. If either side reorders a key, renames a68+        // field, or changes number formatting, this string moves and the two69+        // stop verifying each other's signatures.70+        val body = DeviceManifestBody(71+            manifestVersion = 2,72+            issuerDeviceId = "issuer-1",73+            issuedAt = 1_700_000_000_000L,74+            devices = listOf(75+                ManifestDeviceEntry(76+                    deviceId = "peer-1",77+                    deviceName = "Peer One",78+                    deviceType = "android",79+                    longTermPublicKeyHex = "aa".repeat(32),80+                    signingPublicKeyHex = "bb".repeat(32),81+                    wsPort = 49142,82+                    pairedAt = 1_700_000_000_001L,83+                ),84+            ),85+            revoked = listOf(86+                DeviceRevocation(87+                    deviceId = "gone-1",88+                    longTermPublicKeyHex = "cc".repeat(32),89+                    revokedAt = 1_700_000_000_002L,90+                    revokedByDeviceId = "issuer-1",91+                ),92+            ),93+        )94+        val expected = "{\"devices\":[{\"deviceId\":\"peer-1\",\"deviceName\":\"Peer One\"," +95+            "\"deviceType\":\"android\",\"longTermPublicKeyHex\":\"" + "aa".repeat(32) + "\"," +96+            "\"pairedAt\":1700000000001,\"signingPublicKeyHex\":\"" + "bb".repeat(32) + "\"," +97+            "\"wsPort\":49142}],\"issuedAt\":1700000000000,\"issuerDeviceId\":\"issuer-1\"," +98+            "\"manifestVersion\":2,\"revoked\":[{\"deviceId\":\"gone-1\"," +99+            "\"longTermPublicKeyHex\":\"" + "cc".repeat(32) + "\",\"revokedAt\":1700000000002," +100+            "\"revokedByDeviceId\":\"issuer-1\"}]}"101+        assertEquals(expected, canonicalDeviceManifestBody(body))59102     }60103 61104     @Test fun sas_derivation_vector() {

view file

app/src/test/java/com/peachpasswords/sync/VaultSyncAdapterTest.kt+122 −0
@@ -37,9 +37,21 @@3737         devices: List<PairedDevice> = emptyList(),3838     ) : PairedDeviceRegistry {3939         private val byId = devices.associateBy { it.deviceId }.toMutableMap()40+        private val revocations = mutableListOf<DeviceRevocation>()4041         override fun getAll(): List<PairedDevice> = byId.values.toList()4142         override fun get(id: String): PairedDevice? = byId[id]4243         override fun upsert(device: PairedDevice) { byId[device.deviceId] = device }44+        override fun remove(deviceId: String) { byId.remove(deviceId) }45+        override fun listRevocations(): List<DeviceRevocation> = revocations.toList()46+        override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> {47+            val known = revocations.map { it.deviceId }.toSet()48+            val fresh = incoming.filterNot { known.contains(it.deviceId) }49+            revocations += fresh50+            return fresh.map { it.deviceId }51+        }52+        override fun clearRevocation(deviceId: String) {53+            revocations.removeAll { it.deviceId == deviceId }54+        }4355     }4456 4557     private class FakePendingRegistry : PendingVouchedRegistry {@@ -149,6 +161,116 @@149161         assertEquals("extension-issuer", verified?.issuerDeviceId)150162         assertEquals("android-peer", verified?.devices?.single()?.deviceId)151163         assertTrue(verifyDeviceManifest(manifest.copy(signatureHex = "00".repeat(64)), signing.publicKeyRaw, nowMs = 1_000L) == null)164+    }165+166+    @Test fun `revocation evicts the device and permanently blocks re-vouching`() = runBlocking {167+        val repo = VaultRepository(FakeStorage())168+        repo.createVault("correct horse")169+        val master = repo.syncMasterKey()!!170+        val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")171+        val paired = FakeDeviceRegistry(172+            listOf(173+                PairedDevice(174+                    deviceId = "extension-issuer",175+                    deviceName = "Peach Browser",176+                    deviceType = "extension",177+                    longTermPublicKeyHex = "11".repeat(32),178+                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),179+                    trustSource = "direct",180+                    pairedAt = 1L,181+                ),182+                PairedDevice(183+                    deviceId = "stolen-laptop",184+                    deviceName = "Old Laptop",185+                    deviceType = "extension",186+                    longTermPublicKeyHex = "dd".repeat(32),187+                    signingPublicKeyHex = "ee".repeat(32),188+                    trustSource = "direct",189+                    pairedAt = 2L,190+                ),191+            ),192+        )193+        val pending = FakePendingRegistry()194+        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)195+196+        val revocation = buildSignedDeviceManifest(197+            issuerDeviceId = "extension-issuer",198+            devices = emptyList(),199+            signingPrivateKey = issuerSigning.privateKey,200+            revoked = listOf(201+                DeviceRevocation(202+                    deviceId = "stolen-laptop",203+                    longTermPublicKeyHex = "dd".repeat(32),204+                    revokedAt = System.currentTimeMillis(),205+                    revokedByDeviceId = "extension-issuer",206+                ),207+            ),208+        )209+        adapter.applyIncomingDeviceManifest(revocation, ByteArray(32) { 0x11 })210+211+        assertNull(paired.get("stolen-laptop"))212+213+        // A later manifest vouches it back with a fresh pairedAt — exactly the214+        // resurrection a timestamp-ordered design would allow.215+        val revouch = buildSignedDeviceManifest(216+            issuerDeviceId = "extension-issuer",217+            devices = listOf(218+                ManifestDeviceEntry(219+                    deviceId = "stolen-laptop",220+                    deviceName = "Old Laptop",221+                    deviceType = "extension",222+                    longTermPublicKeyHex = "dd".repeat(32),223+                    signingPublicKeyHex = "ee".repeat(32),224+                    pairedAt = System.currentTimeMillis() + 60_000L,225+                ),226+            ),227+            signingPrivateKey = issuerSigning.privateKey,228+        )229+        adapter.applyIncomingDeviceManifest(revouch, ByteArray(32) { 0x11 })230+231+        assertNull(paired.get("stolen-laptop"))232+        assertEquals(0, pending.devices.size)233+        master.fill(0)234+    }235+236+    @Test fun `a peer cannot revoke this device`() = runBlocking {237+        val repo = VaultRepository(FakeStorage())238+        repo.createVault("correct horse")239+        val master = repo.syncMasterKey()!!240+        val issuerSigning = deriveSyncSigningKeypair(master, "extension-issuer")241+        val paired = FakeDeviceRegistry(242+            listOf(243+                PairedDevice(244+                    deviceId = "extension-issuer",245+                    deviceName = "Peach Browser",246+                    deviceType = "extension",247+                    longTermPublicKeyHex = "11".repeat(32),248+                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),249+                    trustSource = "direct",250+                    pairedAt = 1L,251+                ),252+            ),253+        )254+        val pending = FakePendingRegistry()255+        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)256+257+        val selfRevoke = buildSignedDeviceManifest(258+            issuerDeviceId = "extension-issuer",259+            devices = emptyList(),260+            signingPrivateKey = issuerSigning.privateKey,261+            revoked = listOf(262+                DeviceRevocation(263+                    deviceId = "android-self",264+                    longTermPublicKeyHex = "ff".repeat(32),265+                    revokedAt = System.currentTimeMillis(),266+                    revokedByDeviceId = "extension-issuer",267+                ),268+            ),269+        )270+        adapter.applyIncomingDeviceManifest(selfRevoke, ByteArray(32) { 0x11 })271+272+        assertTrue(paired.listRevocations().none { it.deviceId == "android-self" })273+        master.fill(0)152274     }153275 154276     @Test fun incoming_extension_manifest_queues_unknown_vouched_devices_until_confirmed() = runBlocking {

view file