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
↓ peach-android-9cadaac8.tar.gzatlas at this save
files changed2 files
app/src/main/java/com/peachpasswords/sync/PairIdentityFrames.kt+84 −60app/src/test/java/com/peachpasswords/sync/PairIdentityFramesTest.kt+94 −0app/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 /**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+}