28ed7c0d30b6

M-09: manifest freshness uses two-sided bounds; per-field caps and validation

time
2026-07-29 15:22 UTC · 2 months ago
machine
koh2
agent
claude-opus-4
session
audit-remediation-2026-07-29
parent
b2caee704b91
3 files app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt+31 −5app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt+5 −1app/src/test/java/com/peachpasswords/sync/DeviceManifestSupportTest.kt+106 −0
app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt+31 −5
@@ -7,6 +7,17 @@77 /** 0x03 adds relay routing to each introduced peer edge. */88 const val DEVICE_MANIFEST_VERSION = 0x0399 private const val DEVICE_MANIFEST_FRESHNESS_MS = 5 * 60_000L10+/**11+ * Upper bounds on what verifyDeviceManifest will accept. A pathological peer12+ * could otherwise craft a manifest that consumes unbounded memory or skips13+ * sanity checks (e.g. `issuedAt = Long.MIN_VALUE` bypasses `abs(...) > window`14+ * because `abs(Long.MIN_VALUE)` is still negative). These caps match the15+ * realistic limits of the pairing protocol: a handful of paired devices, a16+ * bounded revocation list, and string fields long enough for any honest use.17+ */18+private const val MAX_MANIFEST_DEVICES = 25619+private const val MAX_MANIFEST_REVOCATIONS = 50020+private const val MAX_MANIFEST_STRING = 2561021 internal const val NO_KEY_SENTINEL = "0000000000000000000000000000000000000000000000000000000000000000"1122 1223 interface PairedDeviceRegistry {@@ -69,7 +80,15 @@6980 ): DeviceManifestBody? {7081     val body = manifest.body7182     if (body.manifestVersion != DEVICE_MANIFEST_VERSION) return null72-    if (kotlin.math.abs(nowMs - body.issuedAt) > freshnessMs) return null83+    // Two-sided bounds — avoids `abs(...) > window`, which overflows for84+    // `Long.MIN_VALUE` and lets a peer-presented timestamp bypass the85+    // freshness gate. Reject negative `issuedAt` outright so the bounds86+    // arithmetic can't be steered into a wrap-around by the manifest.87+    if (body.issuedAt < 0L) return null88+    if (body.issuedAt < nowMs - freshnessMs || body.issuedAt > nowMs + freshnessMs) return null89+    if (body.issuerDeviceId.isBlank() || body.issuerDeviceId.length > MAX_MANIFEST_STRING) return null90+    if (body.devices.size > MAX_MANIFEST_DEVICES) return null91+    if (body.revoked.size > MAX_MANIFEST_REVOCATIONS) return null7392     if (!isHex(manifest.signatureHex, 128)) return null7493     if (!body.devices.all { normaliseManifestEntry(it) != null }) return null7594     if (!body.revoked.all { isValidRevocation(it) }) return null@@ -110,12 +129,18 @@110129 111130 private fun normaliseManifestEntry(entry: ManifestDeviceEntry): ManifestDeviceEntry? {112131     if (entry.deviceId.isBlank() || entry.deviceName.isBlank()) return null132+    // Bound string fields — a peer could otherwise send multi-megabyte strings133+    // that pass the format checks and bloat the canonical signature input.134+    if (entry.deviceId.length > MAX_MANIFEST_STRING || entry.deviceName.length > MAX_MANIFEST_STRING) return null113135     if (entry.deviceType !in setOf("extension", "android", "ios")) return null114136     if (!isHex(entry.longTermPublicKeyHex, 64) || !isHex(entry.signingPublicKeyHex, 64)) return null115137     if (entry.wsPort != null && entry.wsPort !in 1..65_535) return null138+    if (entry.pairedAt < 0L) return null116139     if (117-        entry.relayUrl != null &&118-        runCatching { Rendezvous.normalizeRelayUrl(entry.relayUrl) }.isFailure140+        entry.relayUrl != null && (141+            entry.relayUrl.length > MAX_MANIFEST_STRING ||142+            runCatching { Rendezvous.normalizeRelayUrl(entry.relayUrl) }.isFailure143+        )119144     ) return null120145     return entry.copy(121146         longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),@@ -127,9 +152,10 @@127152     r.copy(longTermPublicKeyHex = r.longTermPublicKeyHex.lowercase())128153 129154 private fun isValidRevocation(r: DeviceRevocation): Boolean =130-    r.deviceId.isNotBlank() &&155+    r.deviceId.isNotBlank() && r.deviceId.length <= MAX_MANIFEST_STRING &&131156         isHex(r.longTermPublicKeyHex.lowercase(), 64) &&132-        r.revokedByDeviceId.isNotBlank()157+        r.revokedByDeviceId.isNotBlank() && r.revokedByDeviceId.length <= MAX_MANIFEST_STRING &&158+        r.revokedAt >= 0L133159 134160 private fun revocationMap(r: DeviceRevocation): Map<String, Any> = linkedMapOf(135161     "deviceId" to r.deviceId,

view file

app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt+5 −1
@@ -304,7 +304,11 @@304304     ours.filter { (id, ts) -> ts > (peer[id] ?: 0L) }305305 306306 private fun warnOnSkew(localTime: Long, remoteTime: Long) {307-    if (kotlin.math.abs(localTime - remoteTime) > CLOCK_SKEW_WARN_MS) {307+    // Two-sided bounds — `abs(...) > window` wraps at `Long.MIN_VALUE` and308+    // would let a peer-supplied timestamp dodge the skew warn entirely. CLOCK_SKEW_WARN_MS309+    // is small relative to any sane wall-clock value, so no arithmetic on these two310+    // longs can overflow for realistic inputs.311+    if (remoteTime < localTime - CLOCK_SKEW_WARN_MS || remoteTime > localTime + CLOCK_SKEW_WARN_MS) {308312         android.util.Log.w("peach-sync", "clock skew detected: local=$localTime remote=$remoteTime")309313     }310314 }

view file

app/src/test/java/com/peachpasswords/sync/DeviceManifestSupportTest.kt+106 −0
@@ -0,0 +1,106 @@1+package com.peachpasswords.sync2+3+import org.junit.Assert.assertNotNull4+import org.junit.Assert.assertNull5+import org.junit.Test6+7+/**8+ * Bounds tests for [verifyDeviceManifest]. The arithmetic fix lives in the9+ * verify path itself; these tests pin the inputs that used to slip past it.10+ */11+class DeviceManifestSupportTest {12+    private val master = ByteArray(32) { it.toByte() }13+    private val signing = deriveSyncSigningKeypair(master, "issuer")14+15+    private fun validEntry(deviceId: String = "peer", pairedAt: Long = 1L) = ManifestDeviceEntry(16+        deviceId = deviceId,17+        deviceName = "Peer",18+        deviceType = "android",19+        longTermPublicKeyHex = "aa".repeat(32),20+        signingPublicKeyHex = "bb".repeat(32),21+        pairedAt = pairedAt,22+    )23+24+    private fun manifestWith(issuedAt: Long, devices: List<ManifestDeviceEntry> = listOf(validEntry())) =25+        buildSignedDeviceManifest(26+            issuerDeviceId = "issuer",27+            devices = devices,28+            signingPrivateKey = signing.privateKey,29+            nowMs = issuedAt,30+        )31+32+    @Test fun rejects_issued_at_long_min_value() {33+        // abs(Long.MIN_VALUE) is still negative, so the legacy abs()-based gate34+        // accepted this. The new two-sided bound must reject it.35+        val manifest = manifestWith(issuedAt = Long.MIN_VALUE)36+        assertNull(verifyDeviceManifest(manifest, signing.publicKeyRaw, nowMs = 1_700_000_000_000L))37+    }38+39+    @Test fun rejects_issued_at_too_old() {40+        val manifest = manifestWith(issuedAt = 1L)41+        assertNull(verifyDeviceManifest(manifest, signing.publicKeyRaw, nowMs = 1_700_000_000_000L))42+    }43+44+    @Test fun rejects_issued_at_too_far_in_future() {45+        val now = 1_700_000_000_000L46+        // 5 min + 1 ms past the default freshness window.47+        val manifest = manifestWith(issuedAt = now + 5 * 60_000L + 1L)48+        assertNull(verifyDeviceManifest(manifest, signing.publicKeyRaw, nowMs = now))49+    }50+51+    @Test fun accepts_issued_at_within_window() {52+        val now = 1_700_000_000_000L53+        val manifest = manifestWith(issuedAt = now)54+        assertNotNull(verifyDeviceManifest(manifest, signing.publicKeyRaw, nowMs = now))55+    }56+57+    @Test fun rejects_oversized_device_id_string() {58+        val manifest = manifestWith(59+            issuedAt = 1_000L,60+            devices = listOf(validEntry(deviceId = "x".repeat(10_000))),61+        )62+        assertNull(verifyDeviceManifest(manifest, signing.publicKeyRaw, nowMs = 1_000L))63+    }64+65+    @Test fun rejects_negative_paired_at() {66+        val manifest = manifestWith(67+            issuedAt = 1_000L,68+            devices = listOf(validEntry(pairedAt = -1L)),69+        )70+        assertNull(verifyDeviceManifest(manifest, signing.publicKeyRaw, nowMs = 1_000L))71+    }72+73+    @Test fun rejects_too_many_devices() {74+        val manifest = buildSignedDeviceManifest(75+            issuerDeviceId = "issuer",76+            devices = List(300) { validEntry(deviceId = "peer-$it") },77+            signingPrivateKey = signing.privateKey,78+            nowMs = 1_000L,79+        )80+        assertNull(verifyDeviceManifest(manifest, signing.publicKeyRaw, nowMs = 1_000L))81+    }82+83+    @Test fun rejects_oversized_issuer_device_id() {84+        val manifest = manifestWith(issuedAt = 1_000L)85+        val oversized = manifest.copy(body = manifest.body.copy(issuerDeviceId = "y".repeat(2_000)))86+        assertNull(verifyDeviceManifest(oversized, signing.publicKeyRaw, nowMs = 1_000L))87+    }88+89+    @Test fun rejects_negative_revoked_at() {90+        val manifest = buildSignedDeviceManifest(91+            issuerDeviceId = "issuer",92+            devices = emptyList(),93+            signingPrivateKey = signing.privateKey,94+            nowMs = 1_000L,95+            revoked = listOf(96+                DeviceRevocation(97+                    deviceId = "peer",98+                    longTermPublicKeyHex = "aa".repeat(32),99+                    revokedAt = -1L,100+                    revokedByDeviceId = "issuer",101+                ),102+            ),103+        )104+        assertNull(verifyDeviceManifest(manifest, signing.publicKeyRaw, nowMs = 1_000L))105+    }106+}\ No newline at end of file

view file