1a4685fd63e9

feat: cap peer device names at 64 chars on every inbound seam, matching the browser (v2.14.12)

Android's manifest path accepted names up to 256 and rejected above, while the browser (v4.8.8) caps at 64 — same vault, different names per platform, and admit-semantics divergence on pathological names. Add MAX_DEVICE_NAME_CHARS/capDeviceName and apply at verifyDeviceManifest return, normaliseManifestEntry, PairedDeviceStore upsert + manifest batch, PendingVouchedDeviceStore upsert, SyncAlertStore record, RelayAdoptionHost display strings, and build-side toManifestEntry. Cap-not-reject so old peers with long-but-honest names keep syncing. +2 tests; 989/0/0; lint unchanged.

time
2026-08-07 23:16 UTC · 2 months ago
machine
koh2
parent
365bda1d525d
9 files VERSION+1 −1app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt+21 −3app/src/main/java/com/peachpasswords/sync/PairIdentityFrames.kt+1 −1app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt+4 −0app/src/main/java/com/peachpasswords/sync/PendingVouchedDeviceStore.kt+3 −0app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+2 −2app/src/main/java/com/peachpasswords/sync/SyncAlertStore.kt+3 −1app/src/test/java/com/peachpasswords/sync/DeviceManifestSupportTest.kt+35 −1releases/v2.14.12.md+2 −0
VERSION+1 −1
@@ -1 +1 @@1-2.14.111+2.14.12

view file

app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt+21 −3
@@ -18,6 +18,18 @@1818 private const val MAX_MANIFEST_DEVICES = 2561919 private const val MAX_MANIFEST_REVOCATIONS = 5002020 private const val MAX_MANIFEST_STRING = 25621+/**22+ * Display/storage bound for device names, matching the browser's23+ * MAX_DEVICE_NAME_CHARS (shared/sync-limits.ts) and both platforms' local24+ * setDeviceName bound. Inbound seams cap rather than reject so old peers25+ * with long-but-honest names keep syncing; a device name was never a26+ * 256-char string.27+ */28+internal const val MAX_DEVICE_NAME_CHARS = 6429+30+/** Truncate a peer- or user-supplied device name to the shared bound. */31+internal fun capDeviceName(name: String): String =32+    if (name.length > MAX_DEVICE_NAME_CHARS) name.take(MAX_DEVICE_NAME_CHARS) else name2133 internal const val NO_KEY_SENTINEL = "0000000000000000000000000000000000000000000000000000000000000000"2234 2335 /**@@ -112,7 +124,10 @@112124     val signature = runCatching { hexToBytes(manifest.signatureHex) }.getOrNull() ?: return null113125     val canonical = canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8)114126     if (!verifyEd25519(issuerSigningPublicKey, canonical, signature)) return null115-    return body127+    // Return entries with the display name capped so every consumer (registry128+    // upserts, pending-vouch queue, key-rotation alerts) stores the bounded129+    // name. The signature above was verified against the raw body.130+    return body.copy(devices = body.devices.map { normaliseManifestEntry(it)!! })116131 }117132 118133 fun canonicalDeviceManifestBody(body: DeviceManifestBody): String =@@ -128,7 +143,7 @@128143 129144 fun PairedDevice.toManifestEntry(): ManifestDeviceEntry? {130145     val id = deviceId.takeIf { it.isNotBlank() } ?: return null131-    val name = deviceName?.takeIf { it.isNotBlank() } ?: return null146+    val name = deviceName?.takeIf { it.isNotBlank() }?.let(::capDeviceName) ?: return null132147     val type = deviceType?.takeIf { it in setOf("extension", "android", "ios") } ?: return null133148     val longTerm = longTermPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null134149     val signing = signingPublicKeyHex?.lowercase()?.takeIf { isHex(it, 64) } ?: return null@@ -148,7 +163,9 @@148163     if (entry.deviceId.isBlank() || entry.deviceName.isBlank()) return null149164     // Bound string fields — a peer could otherwise send multi-megabyte strings150165     // that pass the format checks and bloat the canonical signature input.151-    if (entry.deviceId.length > MAX_MANIFEST_STRING || entry.deviceName.length > MAX_MANIFEST_STRING) return null166+    // deviceName is capped (not rejected) at MAX_DEVICE_NAME_CHARS, matching167+    // the browser, so a long-but-honest name still syncs.168+    if (entry.deviceId.length > MAX_MANIFEST_STRING) return null152169     if (entry.deviceType !in setOf("extension", "android", "ios")) return null153170     if (!isHex(entry.longTermPublicKeyHex, 64) || !isHex(entry.signingPublicKeyHex, 64)) return null154171     if (entry.wsPort != null && entry.wsPort !in 1..65_535) return null@@ -160,6 +177,7 @@160177         )161178     ) return null162179     return entry.copy(180+        deviceName = capDeviceName(entry.deviceName),163181         longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),164182         signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),165183     )

view file

app/src/main/java/com/peachpasswords/sync/PairIdentityFrames.kt+1 −1
@@ -51,7 +51,7 @@5151 // mirrors `setDeviceName`, which already enforces 64). Keys are 32-byte hex5252 // (64 chars). A wss URL has no realistic upper bound we want to trust past 2 KiB.5353 private const val MAX_DEVICE_ID = 6454-private const val MAX_DEVICE_NAME = 6454+private const val MAX_DEVICE_NAME = MAX_DEVICE_NAME_CHARS5555 private const val MAX_PUBKEY_HEX = 645656 private const val MAX_RELAY_URL = 20485757 

view file

app/src/main/java/com/peachpasswords/sync/PairedDeviceStore.kt+4 −0
@@ -67,6 +67,9 @@6767 6868     /** Insert or replace by [PairedDevice.deviceId]. */6969     override fun upsert(device: PairedDevice) {70+        // Peer-supplied names arrive capped so a hostile peer cannot grow the71+        // registry without limit (adoption + direct-pair seams land here).72+        val device = device.copy(deviceName = device.deviceName?.let(::capDeviceName))7073         val next = getAll().filterNot { it.deviceId == device.deviceId } + device7174         persist(next)7275     }@@ -94,6 +97,7 @@9497             .filterNot { removed.contains(it.deviceId) }9598             .toMutableList()9699         batch.upserts.forEach { device ->100+            val device = device.copy(deviceName = device.deviceName?.let(::capDeviceName))97101             nextDevices.removeAll { it.deviceId == device.deviceId }98102             nextDevices += device99103         }

view file

app/src/main/java/com/peachpasswords/sync/PendingVouchedDeviceStore.kt+3 −0
@@ -78,6 +78,9 @@7878     fun get(id: String): PendingVouchedDevice? = getAll().firstOrNull { it.deviceId == id }7979 8080     override fun upsert(device: PendingVouchedDevice) {81+        // Vouch requests carry a peer-chosen name; bound it like every other82+        // inbound name so the pending queue cannot grow without limit.83+        val device = device.copy(deviceName = capDeviceName(device.deviceName))8184         val next = (getAll().filterNot { it.deviceId == device.deviceId } + device).takeLast(MAX_PENDING)8285         persist(next)8386     }

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+2 −2
@@ -81,7 +81,7 @@8181                             trySend(8282                                 RelayAdoptionEvent.AwaitingPeerConfirmation(8383                                     sas = sas,84-                                    deviceName = request.deviceName.ifBlank { "New device" },84+                                    deviceName = capDeviceName(request.deviceName.ifBlank { "New device" }),8585                                 ),8686                             )8787                             // Showing a fresh invitation from an unlocked vault@@ -96,7 +96,7 @@9696                 } else {9797                     trySend(9898                         RelayAdoptionEvent.Adopted(99-                            adopted.deviceName?.takeIf(String::isNotBlank) ?: "New device",99+                            capDeviceName(adopted.deviceName?.takeIf(String::isNotBlank) ?: "New device"),100100                         ),101101                     )102102                 }

view file

app/src/main/java/com/peachpasswords/sync/SyncAlertStore.kt+3 −1
@@ -24,7 +24,9 @@2424     }2525 2626     override fun record(alerts: List<SyncAlert>) {27-        val content = syncSecurityNotificationContent(alerts) ?: return27+        // Final display seam: cap whatever name reached the queue.28+        val capped = alerts.map { it.copy(deviceName = it.deviceName?.let(::capDeviceName)) }29+        val content = syncSecurityNotificationContent(capped) ?: return2830         val manager =2931             context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager3032         runCatching {

view file

app/src/test/java/com/peachpasswords/sync/DeviceManifestSupportTest.kt+35 −1
@@ -3,6 +3,7 @@33 import org.junit.Assert.assertNotNull44 import org.junit.Assert.assertNull55 import org.junit.Test6+import org.junit.Assert.assertEquals67 78 /**89  * Bounds tests for [verifyDeviceManifest]. The arithmetic fix lives in the@@ -62,6 +63,39 @@6263         assertNull(verifyDeviceManifest(manifest, signing.publicKeyRaw, nowMs = 1_000L))6364     }6465 66+    @Test fun caps_oversized_device_name_at_verify() {67+        // Sign the long name RAW (build-side normalise would cap it) so this68+        // pins the verify-side policy: admit and truncate at the shared69+        // 64-char bound, matching the browser — not reject.70+        val now = 1_700_000_000_000L71+        val body = DeviceManifestBody(72+            manifestVersion = DEVICE_MANIFEST_VERSION,73+            issuerDeviceId = "issuer",74+            issuedAt = now,75+            devices = listOf(validEntry().copy(deviceName = "N".repeat(200))),76+            revoked = emptyList(),77+        )78+        val signature = signEd25519(79+            signing.privateKey,80+            canonicalDeviceManifestBody(body).toByteArray(Charsets.UTF_8),81+        )82+        val verified = verifyDeviceManifest(83+            SignedDeviceManifest(body, pubkeyToHex(signature)),84+            signing.publicKeyRaw,85+            nowMs = now,86+        )87+        assertNotNull(verified)88+        assertEquals("N".repeat(MAX_DEVICE_NAME_CHARS), verified!!.devices[0].deviceName)89+    }90+91+    @Test fun caps_device_name_at_build() {92+        val manifest = manifestWith(93+            issuedAt = 1_000L,94+            devices = listOf(validEntry().copy(deviceName = "B".repeat(200))),95+        )96+        assertEquals("B".repeat(MAX_DEVICE_NAME_CHARS), manifest.body.devices[0].deviceName)97+    }98+6599     @Test fun rejects_negative_paired_at() {66100         val manifest = manifestWith(67101             issuedAt = 1_000L,@@ -103,4 +137,4 @@103137         )104138         assertNull(verifyDeviceManifest(manifest, signing.publicKeyRaw, nowMs = 1_000L))105139     }106-}\ No newline at end of file140+}

view file

releases/v2.14.12.md+2 −0
@@ -0,0 +1,2 @@1+## Fixes2+- No visible change. Peach Sync now truncates unusually long device names (to 64 characters, same as the browser extension) so an abnormal name from a paired device can never distort your device list or sync security notifications.

view file