49be2ef5e42e
match device revocation on the revoked long-term key, not just the self-asserted deviceId, so a revoked device cannot return under a fresh id (v2.5.5)
- time
- 2026-07-26 18:36 UTC · 2 months ago
- machine
- koh2
- agent
- claude-opus-5
- session
- S1 mesh vouching authority trace
- parent
- 3831ea3a2d15
↓ peach-android-49be2ef5.tar.gzatlas at this save
files changed8 files
VERSION+1 −1app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt+16 −0app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt+3 −4app/src/main/java/com/peachpasswords/sync/SyncMessage.kt+5 −1app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+6 −4app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+7 −0app/src/test/java/com/peachpasswords/sync/VaultSyncAdapterTest.kt+193 −0app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616 applicationId "com.peachpasswords"1717 minSdk 261818 targetSdk 3519- versionCode 24010420- versionName "2.5.4"19+ versionCode 24010520+ versionName "2.5.5"2121 2222 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323 vectorDrawables {app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt+16 −0
@@ -9,6 +9,7 @@99 * a 0x02 peer cannot verify each other's manifests. */1010 const val DEVICE_MANIFEST_VERSION = 0x021111 private const val DEVICE_MANIFEST_FRESHNESS_MS = 5 * 60_000L12+internal const val NO_KEY_SENTINEL = "0000000000000000000000000000000000000000000000000000000000000000"1213 1314 interface PairedDeviceRegistry {1415 fun getAll(): List<PairedDevice>@@ -23,6 +24,21 @@2324 2425 /** Returns the ids that were not already revoked. */2526 fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> = emptyList()27+28+ /**29+ * True when either the id or the long-term key is under a terminal30+ * revocation. `deviceId` is a self-asserted string in a manifest, so31+ * keying terminality on it alone is bypassed by re-vouching a revoked32+ * device's key under a fresh id. The key is what a revoked device still33+ * holds, so it is what terminality has to follow. `0`×64 is the "no key was34+ * ever stored" sentinel (see PairedDeviceStore.revoke) and matches nothing.35+ */36+ fun isRevoked(deviceId: String, longTermPublicKeyHex: String? = null): Boolean {37+ val key = longTermPublicKeyHex?.lowercase()?.takeIf { it != NO_KEY_SENTINEL }38+ return listRevocations().any {39+ it.deviceId == deviceId || (key != null && it.longTermPublicKeyHex.lowercase() == key)40+ }41+ }2642 2743 /** Only a direct, SAS-confirmed re-pair may lift a terminal revocation. */2844 fun clearRevocation(deviceId: String) {}app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt+3 −4
@@ -83,12 +83,11 @@8383 .getOrDefault(emptyList())8484 }8585 86- fun isRevoked(deviceId: String): Boolean =87- listRevocations().any { it.deviceId == deviceId }88-8986 /**9087 * Revoke a device: record it, then drop it from the registry. The record91- * rides out in every manifest this device signs from now on.88+ * rides out in every manifest this device signs from now on. A stale entry89+ * may survive in the pending-vouch queue; the confirm path re-checks90+ * revocation before promoting anything out of it.9291 */9392 fun revoke(9493 deviceId: String,app/src/main/java/com/peachpasswords/sync/SyncMessage.kt+5 −1
@@ -78,7 +78,8 @@7878 7979 /**8080 * A device that must no longer be synced with. Revocation is TERMINAL: once a81- * deviceId appears here no manifest may re-vouch it. Tombstone-style81+ * deviceId OR its long-term key appears here, no manifest may re-vouch it and82+ * no pending entry carrying it may be confirmed. Tombstone-style8283 * newest-timestamp-wins ordering is deliberately NOT used — every timestamp in8384 * a manifest is peer-supplied, so a peer could resurrect a revoked device just8485 * by issuing a fresh `pairedAt`. Re-admitting requires a direct, SAS-confirmed@@ -94,6 +95,9 @@9495 @Serializable9596 data class DeviceRevocation(9697 val deviceId: String,98+ /** Load-bearing, not descriptive: `deviceId` is whatever an issuing99+ * manifest says it is, so the key is what re-vouching is matched against.100+ * [NO_KEY_SENTINEL] means no key was ever stored and matches nothing. */97101 val longTermPublicKeyHex: String,98102 val revokedAt: Long,99103 val revokedByDeviceId: String,app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+6 −4
@@ -183,12 +183,14 @@183183 ),184184 )185185 }186- val revokedIds = registry.listRevocations().map { it.deviceId }.toSet()187-188186 for (entry in body.devices) {189187 if (entry.deviceId == ourDeviceId || entry.deviceId == issuer.deviceId) continue190- // Terminal: no manifest re-admits a revoked device.191- if (revokedIds.contains(entry.deviceId)) continue188+ // Terminal: no manifest re-admits a revoked device. Matched on the189+ // key as well as the id, because the id is the issuer's to choose —190+ // checking only the id let a revoked key walk back in under a fresh191+ // one. The check sits ahead of the existing-device lookup so it also192+ // refuses rotating a device we still trust onto a revoked key.193+ if (registry.isRevoked(entry.deviceId, entry.longTermPublicKeyHex)) continue192194 val existing = registry.get(entry.deviceId)193195 if (existing == null) {194196 pending.upsert(app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+7 −0
@@ -95,6 +95,13 @@9595 9696 fun confirmVouchedDevice(id: String) = viewModelScope.launch {9797 val pending = pendingVouchedStore.get(id) ?: return@launch98+ // Last gate before a vouched device becomes a sync peer. A queue entry99+ // can predate the revocation that names it, so revocation is re-checked100+ // here rather than trusted to have swept the queue.101+ if (deviceStore.isRevoked(pending.deviceId, pending.longTermPublicKeyHex)) {102+ pendingVouchedStore.remove(id)103+ return@launch104+ }98105 deviceStore.upsert(99106 PairedDevice(100107 deviceId = pending.deviceId,app/src/test/java/com/peachpasswords/sync/VaultSyncAdapterTest.kt+193 −0
@@ -7,6 +7,7 @@77 import com.peachpasswords.vault.VaultStorage88 import kotlinx.coroutines.runBlocking99 import org.junit.Assert.assertEquals10+import org.junit.Assert.assertFalse1011 import org.junit.Assert.assertNull1112 import org.junit.Assert.assertTrue1213 import org.junit.Test@@ -463,7 +464,199 @@463464 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x22 })464465 465466 assertEquals(0, pending.devices.size)467+468+ // Control: the same manifest over the claimed issuer's OWN session does469+ // queue the device. Without it the assertion above could pass for any470+ // reason — a malformed body, a bad signature, a dead code path.471+ adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })472+ assertEquals(listOf("smuggled-device"), pending.devices.map { it.deviceId })466473 master.fill(0)474+ }475+476+ @Test fun `a revoked long term key cannot walk back in under a fresh deviceId`() = runBlocking {477+ val repo = VaultRepository(FakeStorage())478+ repo.createVault("correct horse")479+ val master = repo.syncMasterKey()!!480+ val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")481+ val stolenKey = "dd".repeat(32)482+ val paired = FakeDeviceRegistry(483+ listOf(484+ PairedDevice(485+ deviceId = "phone-issuer",486+ deviceName = "Pixel",487+ deviceType = "android",488+ longTermPublicKeyHex = "11".repeat(32),489+ signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),490+ trustSource = "direct",491+ pairedAt = 1L,492+ ),493+ PairedDevice(494+ deviceId = "stolen-laptop",495+ deviceName = "Old Laptop",496+ deviceType = "extension",497+ longTermPublicKeyHex = stolenKey,498+ signingPublicKeyHex = "ee".repeat(32),499+ trustSource = "direct",500+ pairedAt = 2L,501+ ),502+ ),503+ )504+ val pending = FakePendingRegistry()505+ val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)506+507+ adapter.applyIncomingDeviceManifest(508+ buildSignedDeviceManifest(509+ issuerDeviceId = "phone-issuer",510+ devices = emptyList(),511+ signingPrivateKey = issuerSigning.privateKey,512+ revoked = listOf(513+ DeviceRevocation(514+ deviceId = "stolen-laptop",515+ longTermPublicKeyHex = stolenKey,516+ revokedAt = System.currentTimeMillis(),517+ revokedByDeviceId = "phone-issuer",518+ ),519+ ),520+ ),521+ ByteArray(32) { 0x11 },522+ )523+ assertNull(paired.get("stolen-laptop"))524+525+ // deviceId is the issuer's to choose, so re-vouching the same key under526+ // a new one is free. Terminality has to follow the key, not the label.527+ adapter.applyIncomingDeviceManifest(528+ buildSignedDeviceManifest(529+ issuerDeviceId = "phone-issuer",530+ devices = listOf(531+ ManifestDeviceEntry(532+ deviceId = "stolen-laptop-mk2",533+ deviceName = "New Laptop",534+ deviceType = "extension",535+ longTermPublicKeyHex = stolenKey,536+ signingPublicKeyHex = "ee".repeat(32),537+ pairedAt = System.currentTimeMillis(),538+ ),539+ ),540+ signingPrivateKey = issuerSigning.privateKey,541+ ),542+ ByteArray(32) { 0x11 },543+ )544+ assertEquals(0, pending.devices.size)545+ assertNull(paired.get("stolen-laptop-mk2"))546+547+ // Control: the same fresh id carrying a key that was never revoked548+ // queues normally, so the rejection above is the key check, not the id.549+ adapter.applyIncomingDeviceManifest(550+ buildSignedDeviceManifest(551+ issuerDeviceId = "phone-issuer",552+ devices = listOf(553+ ManifestDeviceEntry(554+ deviceId = "stolen-laptop-mk2",555+ deviceName = "New Laptop",556+ deviceType = "extension",557+ longTermPublicKeyHex = "99".repeat(32),558+ signingPublicKeyHex = "ee".repeat(32),559+ pairedAt = System.currentTimeMillis(),560+ ),561+ ),562+ signingPrivateKey = issuerSigning.privateKey,563+ ),564+ ByteArray(32) { 0x11 },565+ )566+ assertEquals(listOf("stolen-laptop-mk2"), pending.devices.map { it.deviceId })567+ master.fill(0)568+ }569+570+ @Test fun `a revoked key cannot be written onto a device we still trust`() = runBlocking {571+ val repo = VaultRepository(FakeStorage())572+ repo.createVault("correct horse")573+ val master = repo.syncMasterKey()!!574+ val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")575+ val revokedKey = "dd".repeat(32)576+ val paired = FakeDeviceRegistry(577+ listOf(578+ PairedDevice(579+ deviceId = "phone-issuer",580+ deviceName = "Pixel",581+ deviceType = "android",582+ longTermPublicKeyHex = "11".repeat(32),583+ signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),584+ trustSource = "direct",585+ pairedAt = 1L,586+ ),587+ PairedDevice(588+ deviceId = "trusted-tablet",589+ deviceName = "Tablet",590+ deviceType = "android",591+ longTermPublicKeyHex = "77".repeat(32),592+ signingPublicKeyHex = "ee".repeat(32),593+ trustSource = "direct",594+ pairedAt = 2L,595+ ),596+ ),597+ )598+ val adapter = VaultSyncAdapter(599+ repo,600+ ourDeviceId = "android-self",601+ pairedDevices = paired,602+ pendingVouched = FakePendingRegistry(),603+ )604+605+ adapter.applyIncomingDeviceManifest(606+ buildSignedDeviceManifest(607+ issuerDeviceId = "phone-issuer",608+ devices = emptyList(),609+ signingPrivateKey = issuerSigning.privateKey,610+ revoked = listOf(611+ DeviceRevocation(612+ deviceId = "stolen-laptop",613+ longTermPublicKeyHex = revokedKey,614+ revokedAt = System.currentTimeMillis(),615+ revokedByDeviceId = "phone-issuer",616+ ),617+ ),618+ ),619+ ByteArray(32) { 0x11 },620+ )621+622+ // Re-admission dressed as a key rotation on a device we already trust.623+ adapter.applyIncomingDeviceManifest(624+ buildSignedDeviceManifest(625+ issuerDeviceId = "phone-issuer",626+ devices = listOf(627+ ManifestDeviceEntry(628+ deviceId = "trusted-tablet",629+ deviceName = "Tablet",630+ deviceType = "android",631+ longTermPublicKeyHex = revokedKey,632+ signingPublicKeyHex = "ee".repeat(32),633+ pairedAt = 2L,634+ ),635+ ),636+ signingPrivateKey = issuerSigning.privateKey,637+ ),638+ ByteArray(32) { 0x11 },639+ )640+ assertEquals("77".repeat(32), paired.get("trusted-tablet")?.longTermPublicKeyHex)641+ master.fill(0)642+ }643+644+ /** Pins the helper the manifest loop and SyncViewModel.confirmVouchedDevice share. */645+ @Test fun `revocation matches on id or key but never on the empty-key sentinel`() {646+ val registry = FakeDeviceRegistry()647+ registry.mergeRevocations(648+ listOf(649+ DeviceRevocation("gone", "dd".repeat(32), 1L, "issuer"),650+ DeviceRevocation("keyless", NO_KEY_SENTINEL, 2L, "issuer"),651+ ),652+ )653+ assertTrue(registry.isRevoked("gone"))654+ assertTrue(registry.isRevoked("fresh-id", "DD".repeat(32)))655+ assertTrue(registry.isRevoked("keyless"))656+ assertFalse(registry.isRevoked("fresh-id", "99".repeat(32)))657+ // A device that genuinely never stored a key must not inherit the658+ // sentinel's revocation.659+ assertFalse(registry.isRevoked("innocent", NO_KEY_SENTINEL))467660 }468661 469662 @Test fun `public key decoder requires exactly 32 bytes`() {