9cadaac8c124

M-08: pair identity uses kotlinx.serialization with field length/format validation

time
2026-07-29 15:09 UTC · 2 months ago
machine
koh2
agent
claude-opus-4
session
audit-remediation-2026-07-29
parent
093d52874539
2 files app/src/main/java/com/peachpasswords/sync/PairIdentityFrames.kt+84 −60app/src/test/java/com/peachpasswords/sync/PairIdentityFramesTest.kt+94 −0
app/src/main/java/com/peachpasswords/sync/PairIdentityFrames.kt+84 −60
@@ -1,5 +1,8 @@11 package com.peachpasswords.sync22 3+import kotlinx.serialization.SerialName4+import kotlinx.serialization.Serializable5+import kotlinx.serialization.json.Json36 import java.nio.charset.StandardCharsets47 import java.security.MessageDigest58 @@ -22,82 +25,103 @@2225     val relayUrl: String?,2326 )2427 28+/**29+ * Wire shape — snake_case keys and the literal `type` discriminator must match30+ * the browser's `shared/sync-types.ts` PAIR_IDENTITY frame byte-for-byte. The31+ * hand-rolled encoder/parser this replaced disagreed on escaping: the encoder32+ * escaped `"`, `\`, and control bytes, the parser stopped at the next unescaped33+ * quote and never unescaped, so a device name containing `"` or a newline34+ * truncated the frame and corrupted everything after it. kotlinx.serialization35+ * round-trips every JSON escape correctly, and the field validators below36+ * bound the values at the trust boundary.37+ */38+@Serializable39+private data class PairIdentityFields(40+    val type: String = "PAIR_IDENTITY",41+    @SerialName("device_id") val deviceId: String,42+    @SerialName("device_name") val deviceName: String,43+    @SerialName("device_type") val deviceType: String,44+    @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String,45+    @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null,46+    @SerialName("ws_port") val wsPort: Int? = null,47+    @SerialName("relay_url") val relayUrl: String? = null,48+)49+50+// Field length caps. The browser clamps `device_name` to 64 (DeviceIdentity.ts51+// mirrors `setDeviceName`, which already enforces 64). Keys are 32-byte hex52+// (64 chars). A wss URL has no realistic upper bound we want to trust past 2 KiB.53+private const val MAX_DEVICE_ID = 6454+private const val MAX_DEVICE_NAME = 6455+private const val MAX_PUBKEY_HEX = 6456+private const val MAX_RELAY_URL = 204857+58+private val PairIdentityJson = Json {59+    encodeDefaults = false60+    ignoreUnknownKeys = true61+}62+2563 internal fun encodePairIdentityFrame(id: LocalDeviceIdentity): ByteArray {26-    val sb = StringBuilder()27-    sb.append('{')28-    sb.append("\"type\":\"PAIR_IDENTITY\"")29-    sb.append(",\"device_id\":\"").append(jsonEscape(id.deviceId)).append('"')30-    sb.append(",\"device_name\":\"").append(jsonEscape(id.deviceName)).append('"')31-    sb.append(",\"device_type\":\"").append(id.deviceType.wireValue).append('"')32-    sb.append(",\"long_term_public_key_hex\":\"").append(id.longTermPublicKeyHex).append('"')33-    sb.append(",\"signing_public_key_hex\":\"").append(id.signingPublicKeyHex).append('"')34-    id.wsPort?.let { sb.append(",\"ws_port\":").append(it) }35-    sb.append(",\"relay_url\":\"").append(jsonEscape(id.relayUrl)).append('"')36-    sb.append('}')37-    return sb.toString().toByteArray(StandardCharsets.UTF_8)64+    validateField(id.deviceId, "device_id", MAX_DEVICE_ID)65+    validateField(id.deviceName, "device_name", MAX_DEVICE_NAME)66+    validateHex(id.longTermPublicKeyHex, "long_term_public_key_hex", 32)67+    id.signingPublicKeyHex?.let { validateHex(it, "signing_public_key_hex", 32) }68+    validateField(id.relayUrl, "relay_url", MAX_RELAY_URL)69+    val fields = PairIdentityFields(70+        deviceId = id.deviceId,71+        deviceName = id.deviceName,72+        deviceType = id.deviceType.wireValue,73+        longTermPublicKeyHex = id.longTermPublicKeyHex,74+        signingPublicKeyHex = id.signingPublicKeyHex,75+        wsPort = id.wsPort,76+        relayUrl = id.relayUrl,77+    )78+    return PairIdentityJson.encodeToString(PairIdentityFields.serializer(), fields)79+        .toByteArray(StandardCharsets.UTF_8)3880 }3981 4082 internal fun parsePairIdentityFrame(bytes: ByteArray): PairIdentity {41-    val map = miniJsonObject(String(bytes, StandardCharsets.UTF_8))42-    require(map["type"] == "PAIR_IDENTITY") { "expected PAIR_IDENTITY frame" }43-    val deviceType = when (map["device_type"]) {83+    val fields = PairIdentityJson.decodeFromString(84+        PairIdentityFields.serializer(),85+        String(bytes, StandardCharsets.UTF_8),86+    )87+    require(fields.type == "PAIR_IDENTITY") { "expected PAIR_IDENTITY frame" }88+    validateField(fields.deviceId, "device_id", MAX_DEVICE_ID)89+    validateField(fields.deviceName, "device_name", MAX_DEVICE_NAME)90+    validateHex(fields.longTermPublicKeyHex, "long_term_public_key_hex", 32)91+    fields.signingPublicKeyHex?.takeIf { it.isNotBlank() }92+        ?.let { validateHex(it, "signing_public_key_hex", 32) }93+    val deviceType = when (fields.deviceType) {4494         "extension" -> DeviceType.EXTENSION4595         "android" -> DeviceType.ANDROID4696         "ios" -> DeviceType.IOS47-        else -> error("device_type missing")97+        else -> error("device_type invalid: ${fields.deviceType}")4898     }49-    val ltpk = map["long_term_public_key_hex"] ?: error("long_term_public_key_hex missing")50-    require(ltpk.matches(Regex("^[0-9a-f]{64}$"))) { "long_term_public_key_hex not 32-byte hex" }99+    fields.relayUrl?.let { validateField(it, "relay_url", MAX_RELAY_URL) }51100     return PairIdentity(52-        deviceId = map["device_id"] ?: error("device_id missing"),53-        deviceName = map["device_name"] ?: error("device_name missing"),101+        deviceId = fields.deviceId,102+        deviceName = fields.deviceName,54103         deviceType = deviceType,55-        longTermPublicKeyHex = ltpk,56-        signingPublicKeyHex = map["signing_public_key_hex"]?.takeIf { it.isNotBlank() },57-        wsPort = map["ws_port"]?.toIntOrNull(),58-        relayUrl = map["relay_url"]?.let(Rendezvous::normalizeRelayUrl),104+        longTermPublicKeyHex = fields.longTermPublicKeyHex,105+        signingPublicKeyHex = fields.signingPublicKeyHex?.takeIf { it.isNotBlank() },106+        wsPort = fields.wsPort,107+        relayUrl = fields.relayUrl?.let(Rendezvous::normalizeRelayUrl),59108     )60109 }61110 62-63-private fun jsonEscape(value: String): String = buildString(value.length) {64-    for (ch in value) when (ch) {65-        '\\' -> append("\\\\"); '"' -> append("\\\"")66-        '\n' -> append("\\n"); '\r' -> append("\\r"); '\t' -> append("\\t")67-        else -> append(ch)111+private fun validateField(value: String, name: String, maxLen: Int) {112+    require(value.isNotEmpty()) { "$name missing" }113+    require(value.length <= maxLen) { "$name exceeds $maxLen chars" }114+    // JSON strings cannot carry raw control bytes; reject them at the trust115+    // boundary so a peer that hands us a name with an embedded NUL or newline116+    // can't truncate the frame even if the encoder slips up.117+    for (ch in value) {118+        require(ch.code >= 0x20 && ch.code != 0x7f) { "$name contains control character" }68119     }69120 }70121 71-private fun miniJsonObject(text: String): Map<String, String> {72-    val trimmed = text.trim()73-    require(trimmed.startsWith("{") && trimmed.endsWith("}")) { "expected JSON object" }74-    val body = trimmed.substring(1, trimmed.length - 1)75-    val out = linkedMapOf<String, String>()76-    var i = 077-    while (i < body.length) {78-        while (i < body.length && body[i].isWhitespace()) i++79-        if (i >= body.length || body[i] != '"') break80-        i++81-        val keyStart = i82-        while (i < body.length && body[i] != '"') i++83-        val key = body.substring(keyStart, i); i++84-        while (i < body.length && (body[i].isWhitespace() || body[i] == ':')) i++85-        val value: String86-        if (i < body.length && body[i] == '"') {87-            i++88-            val vStart = i89-            while (i < body.length && body[i] != '"') i++90-            value = body.substring(vStart, i); i++91-        } else {92-            val vStart = i93-            while (i < body.length && body[i] != ',') i++94-            value = body.substring(vStart, i).trim()95-        }96-        out[key] = value97-        while (i < body.length && body[i].isWhitespace()) i++98-        if (i < body.length && body[i] == ',') i++99-    }100-    return out122+private fun validateHex(value: String, name: String, byteLen: Int) {123+    require(value.length == byteLen * 2) { "$name not $byteLen-byte hex" }124+    require(value.all { it in '0'..'9' || it in 'a'..'f' }) { "$name not lowercase hex" }101125 }102126 103127 /**

view file

app/src/test/java/com/peachpasswords/sync/PairIdentityFramesTest.kt+94 −0
@@ -0,0 +1,94 @@1+package com.peachpasswords.sync2+3+import org.junit.Assert.assertEquals4+import org.junit.Assert.assertNull5+import org.junit.Assert.assertTrue6+import org.junit.Assert.fail7+import org.junit.Test8+9+/**10+ * Regression coverage for M-08: the hand-rolled PAIR_IDENTITY encoder escaped11+ * `"`, `\`, and control bytes; the parser stopped at the next unescaped quote12+ * and never unescaped. Names containing those characters truncated the frame13+ * and corrupted everything after it. The kotlinx.serialization-backed codec14+ * round-trips every JSON escape and validates fields at the trust boundary.15+ */16+private const val MAX_DEVICE_NAME_FOR_TEST = 6417+18+class PairIdentityFramesTest {19+    private val base = LocalDeviceIdentity(20+        deviceId = "client-1",21+        deviceName = "Phone",22+        deviceType = DeviceType.ANDROID,23+        longTermPublicKeyHex = "cd".repeat(32),24+        signingPublicKeyHex = "ef".repeat(32),25+        relayUrl = "wss://relay.peachpasswords.com",26+    )27+28+    @Test29+    fun `round-trips a device name containing every JSON escape character`() {30+        val tricky = "a\"b\\c\nd\te\rf"31+        val wire = encodePairIdentityFrame(base.copy(deviceName = tricky))32+        val parsed = parsePairIdentityFrame(wire)33+        assertEquals(tricky, parsed.deviceName)34+    }35+36+    @Test37+    fun `decoder rejects a name with a raw embedded newline`() {38+        val raw = """{"type":"PAIR_IDENTITY","device_id":"d","device_name":"bad${"\n"}name","device_type":"android","long_term_public_key_hex":"${"cd".repeat(32)}","signing_public_key_hex":"${"ef".repeat(32)}","relay_url":"wss://r.example.com"}"""39+        try {40+            parsePairIdentityFrame(raw.toByteArray())41+            fail("expected control-char rejection")42+        } catch (e: IllegalArgumentException) {43+            assertTrue(e.message!!.contains("device_name"))44+        }45+    }46+47+    @Test48+    fun `decoder unescapes a literal backslash-quote in the name`() {49+        // The legacy parser stopped at the first unescaped quote and read50+        // `a\` as the value, then desynced the rest of the frame.51+        val raw = """{"type":"PAIR_IDENTITY","device_id":"d","device_name":"a\"b","device_type":"android","long_term_public_key_hex":"${"cd".repeat(32)}","signing_public_key_hex":"${"ef".repeat(32)}","relay_url":"wss://r.example.com"}"""52+        val parsed = parsePairIdentityFrame(raw.toByteArray())53+        assertEquals("a\"b", parsed.deviceName)54+    }55+56+    @Test57+    fun `decoder rejects a non-hex long term public key`() {58+        val raw = """{"type":"PAIR_IDENTITY","device_id":"d","device_name":"n","device_type":"android","long_term_public_key_hex":"${"zz".repeat(32)}","signing_public_key_hex":"${"ef".repeat(32)}","relay_url":"wss://r.example.com"}"""59+        try {60+            parsePairIdentityFrame(raw.toByteArray())61+            fail("expected hex rejection")62+        } catch (e: IllegalArgumentException) {63+            assertTrue(e.message!!.contains("hex"))64+        }65+    }66+67+    @Test68+    fun `decoder rejects a device name longer than the cap`() {69+        val long = "x".repeat(MAX_DEVICE_NAME_FOR_TEST + 1)70+        val wire = encodePairIdentityFrame(base.copy(deviceName = long))71+        try {72+            parsePairIdentityFrame(wire)73+            fail("expected length rejection")74+        } catch (e: IllegalArgumentException) {75+            assertTrue(e.message!!.contains("device_name"))76+        }77+    }78+79+    @Test80+    fun `signing key is null when omitted from the wire`() {81+        val omitted = """{"type":"PAIR_IDENTITY","device_id":"d","device_name":"n","device_type":"android","long_term_public_key_hex":"${"cd".repeat(32)}","relay_url":"wss://r.example.com"}"""82+        val parsed = parsePairIdentityFrame(omitted.toByteArray())83+        assertNull(parsed.signingPublicKeyHex)84+    }85+86+    @Test87+    fun `wire format preserves the type discriminator and snake_case keys`() {88+        val wire = String(encodePairIdentityFrame(base), Charsets.UTF_8)89+        assertTrue("frame must include type discriminator",90+            wire.contains("\"type\":\"PAIR_IDENTITY\""))91+        assertTrue("frame must use snake_case keys", wire.contains("\"device_id\""))92+        assertTrue("frame must use snake_case keys", wire.contains("\"long_term_public_key_hex\""))93+    }94+}

view file