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
↓ peach-android-f4ade7ac.tar.gzatlas at this save
files changed13 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 −0app/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 {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) {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) ?: entryapp/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 }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 }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 @Serializableapp/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 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(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 }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@launchapp/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() {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 {