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
↓ peach-android-28ed7c0d.tar.gzatlas at this save
files changed3 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 −0app/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,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 }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