1a4685fd63e9
feat: cap peer device names at 64 chars on every inbound seam, matching the browser (v2.14.12)
- time
- 2026-08-07 23:16 UTC · 2 months ago
- machine
- koh2
- parent
- 365bda1d525d
↓ peach-android-1a4685fd.tar.gzatlas at this save
files changed9 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 −0app/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 )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 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 }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 }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 }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 {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+}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.