2dfe4d9c3398

fix: Remove stale revoked device rows (v2.5.16)

time
2026-07-27 17:22 UTC · 2 months ago
machine
koh2
agent
codex-gpt-5
session
peach-sync-e2e
parent
f90df691e6d2
4 files VERSION+1 −1app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt+47 −8app/src/test/java/com/peachpasswords/sync/PairedDeviceRevocationTest.kt+53 −0
VERSION+1 −1
@@ -1 +1 @@1-2.5.15\ No newline at end of file1+2.5.16

view file

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

view file

app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt+47 −8
@@ -94,17 +94,22 @@9494         revokedByDeviceId: String,9595         nowMs: Long = System.currentTimeMillis(),9696     ): DeviceRevocation? {97-        val existing = listRevocations()98-        if (existing.any { it.deviceId == deviceId }) return null99-        val record = DeviceRevocation(97+        val currentDevices = getAll()98+        val currentRevocations = listRevocations()99+        val mutation = planDeviceRevocation(100+            devices = currentDevices,101+            revocations = currentRevocations,100102             deviceId = deviceId,101-            longTermPublicKeyHex = get(deviceId)?.longTermPublicKeyHex ?: "0".repeat(64),102-            revokedAt = nowMs,103103             revokedByDeviceId = revokedByDeviceId,104+            nowMs = nowMs,104105         )105-        persistRevocations(existing + record)106-        remove(deviceId)107-        return record106+        if (mutation.revocations !== currentRevocations) {107+            persistRevocations(mutation.revocations)108+        }109+        if (mutation.devices != currentDevices) {110+            persist(mutation.devices)111+        }112+        return mutation.added108113     }109114 110115     /**@@ -196,6 +201,40 @@196201         const val MAX_REVOCATIONS = 500197202         val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }198203     }204+}205+206+internal data class DeviceRevocationMutation(207+    val devices: List<PairedDevice>,208+    val revocations: List<DeviceRevocation>,209+    val added: DeviceRevocation?,210+)211+212+/**213+ * Compute one revoke transaction without Android storage so the impossible214+ * "paired and already revoked" state can be regression-tested directly.215+ *216+ * Older builds could leave that overlap behind. A repeated Remove must still217+ * drop the live row even though the terminal record itself is idempotent.218+ */219+internal fun planDeviceRevocation(220+    devices: List<PairedDevice>,221+    revocations: List<DeviceRevocation>,222+    deviceId: String,223+    revokedByDeviceId: String,224+    nowMs: Long,225+): DeviceRevocationMutation {226+    val remaining = devices.filterNot { it.deviceId == deviceId }227+    if (revocations.any { it.deviceId == deviceId }) {228+        return DeviceRevocationMutation(remaining, revocations, null)229+    }230+    val device = devices.firstOrNull { it.deviceId == deviceId }231+    val record = DeviceRevocation(232+        deviceId = deviceId,233+        longTermPublicKeyHex = device?.longTermPublicKeyHex ?: "0".repeat(64),234+        revokedAt = nowMs,235+        revokedByDeviceId = revokedByDeviceId,236+    )237+    return DeviceRevocationMutation(remaining, revocations + record, record)199238 }200239 201240 /**

view file

app/src/test/java/com/peachpasswords/sync/PairedDeviceRevocationTest.kt+53 −0
@@ -0,0 +1,53 @@1+package com.peachpasswords.sync2+3+import org.junit.Assert.assertEquals4+import org.junit.Assert.assertNull5+import org.junit.Test6+7+class PairedDeviceRevocationTest {8+    private val browser = PairedDevice(9+        deviceId = "browser-1",10+        deviceName = "Chrome",11+        deviceType = "extension",12+        longTermPublicKeyHex = "ab".repeat(32),13+        pairedAt = 1L,14+    )15+16+    @Test17+    fun `new revocation records the key and removes the paired row`() {18+        val mutation = planDeviceRevocation(19+            devices = listOf(browser),20+            revocations = emptyList(),21+            deviceId = browser.deviceId,22+            revokedByDeviceId = "phone-1",23+            nowMs = 42L,24+        )25+26+        assertEquals(emptyList<PairedDevice>(), mutation.devices)27+        assertEquals(1, mutation.revocations.size)28+        assertEquals(browser.longTermPublicKeyHex, mutation.added?.longTermPublicKeyHex)29+        assertEquals(42L, mutation.added?.revokedAt)30+    }31+32+    @Test33+    fun `existing revocation still removes a stale overlapping paired row`() {34+        val existing = DeviceRevocation(35+            deviceId = browser.deviceId,36+            longTermPublicKeyHex = browser.longTermPublicKeyHex!!,37+            revokedAt = 1L,38+            revokedByDeviceId = "phone-1",39+        )40+41+        val mutation = planDeviceRevocation(42+            devices = listOf(browser),43+            revocations = listOf(existing),44+            deviceId = browser.deviceId,45+            revokedByDeviceId = "phone-1",46+            nowMs = 99L,47+        )48+49+        assertEquals(emptyList<PairedDevice>(), mutation.devices)50+        assertEquals(listOf(existing), mutation.revocations)51+        assertNull(mutation.added)52+    }53+}

view file