2c0edcf5291a

name a rotating device from local state instead of the vouching peer's manifest claim, and stop a third-party issuer renaming a paired record (v2.4.3)

time
2026-07-26 08:04 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
sync campaign remaining items
parent
536cb610f6f6
5 files VERSION+1 −1app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/SyncAlert.kt+6 −1app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+5 −1app/src/test/java/com/peachpasswords/sync/SyncAlertTest.kt+25 −0
VERSION+1 −1
@@ -1 +1 @@1-2.4.21+2.4.3

view file

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

view file

app/src/main/java/com/peachpasswords/sync/SyncAlert.kt+6 −1
@@ -84,9 +84,14 @@8484         !CryptoCompare.hex(it, nextSigning)8585     } == true8686     if (!longTermChanged && !signingChanged) return null87+    // S5: name the device from LOCAL state. `incoming` is a third-party88+    // issuer's unverified claim about someone else, so preferring it would let89+    // an attacker make its own key rotation read as a rotation on a device the90+    // user trusts more. Fall back to the manifest only when nothing is stored.8791     return SyncAlert(8892         type = SyncAlertType.DEVICE_KEY_ROTATED,89-        deviceName = incoming.deviceName.ifBlank { existing.deviceName ?: "paired device" },93+        deviceName = existing.deviceName?.takeIf { it.isNotBlank() }94+            ?: incoming.deviceName.ifBlank { "paired device" },9095         timestamp = nowMs,9196     )9297 }

view file

app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+5 −1
@@ -174,9 +174,13 @@174174                 deviceKeyRotationAlert(existing, entry, System.currentTimeMillis())?.let { alert ->175175                     syncAlerts?.record(listOf(alert))176176                 }177+                // S5: keys and ports propagate through the mesh, the name does178+                // not. A device names itself at pairing; letting a third-party179+                // issuer rewrite that is the rename-then-impersonate vector.177180                 registry.upsert(178181                     existing.copy(179-                        deviceName = entry.deviceName,182+                        deviceName = existing.deviceName?.takeIf { it.isNotBlank() }183+                            ?: entry.deviceName,180184                         deviceType = entry.deviceType,181185                         longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),182186                         signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),

view file

app/src/test/java/com/peachpasswords/sync/SyncAlertTest.kt+25 −0
@@ -98,6 +98,31 @@9898         assertNull(deviceKeyRotationAlert(existing, incoming.copy(signingPublicKeyHex = "bb".repeat(32)), nowMs = 99L))9999     }100100 101+    @Test fun `S5 rotation alert names the device from local state not the manifest`() {102+        // A hostile issuer relabels the rotating device, hoping the alert reads103+        // as a rotation on something the user trusts more.104+        val existing = PairedDevice(105+            deviceId = "browser",106+            deviceName = "Spare Tablet",107+            deviceType = "extension",108+            longTermPublicKeyHex = "aa".repeat(32),109+            signingPublicKeyHex = "bb".repeat(32),110+            pairedAt = 1L,111+        )112+        val incoming = ManifestDeviceEntry(113+            deviceId = "browser",114+            deviceName = "June's Primary Phone",115+            deviceType = "extension",116+            longTermPublicKeyHex = "aa".repeat(32),117+            signingPublicKeyHex = "cc".repeat(32),118+            pairedAt = 1L,119+        )120+121+        val alert = deviceKeyRotationAlert(existing, incoming, nowMs = 99L)122+123+        assertEquals("Spare Tablet", alert?.deviceName)124+    }125+101126     @Test fun sync_alert_queue_is_bounded_to_latest_two_hundred() {102127         val existing = (0 until 199).map { SyncAlert(SyncAlertType.REMOTE_DELETE, timestamp = it.toLong()) }103128         val incoming = listOf(

view file