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
8 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 −0
VERSION+1 −1
@@ -1 +1 @@1-2.5.41+2.5.5

view file

app/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 {

view file

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) {}

view file

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,

view file

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,

view file

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(

view file

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,

view file

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`() {

view file