1package com.peachpasswords.sync23import java.nio.charset.StandardCharsets4import java.security.MessageDigest56789101112131415data class PairIdentity(16 val deviceId: String,17 val deviceName: String,18 val deviceType: DeviceType,19 val longTermPublicKeyHex: String,20 val signingPublicKeyHex: String?,21 val wsPort: Int?,22 val relayUrl: String?,23)2425internal fun encodePairIdentityFrame(id: LocalChirpIdentity): 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)38}3940internal 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"]) {44 "extension" -> DeviceType.EXTENSION45 "android" -> DeviceType.ANDROID46 "ios" -> DeviceType.IOS47 else -> error("device_type missing")48 }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" }51 return PairIdentity(52 deviceId = map["device_id"] ?: error("device_id missing"),53 deviceName = map["device_name"] ?: error("device_name missing"),54 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),59 )60}616263private 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)68 }69}7071private 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 out101}102103104105106107108internal fun identityMatchesAuthenticatedPeer(109 known: PairedDevice,110 presented: PairIdentity,111 authenticatedKey: ByteArray,112): Boolean {113 val expected = known.longTermPublicKeyHex ?: return false114 return known.deviceId == presented.deviceId &&115 CryptoCompare.hex(expected, presented.longTermPublicKeyHex) &&116 MessageDigest.isEqual(authenticatedKey, hexToPubkey(presented.longTermPublicKeyHex))117}