PairIdentityFrames.kt

4.7 KB · raw

1package com.peachpasswords.sync23import java.nio.charset.StandardCharsets4import java.security.MessageDigest56/**7 * The PAIR_IDENTITY frame: who a peer claims to be, exchanged inside the8 * encrypted session once and pinned thereafter.9 *10 * Lived in the retired LAN listeners (`MdnsPairingServer` /11 * `SyncReconnectListener`) until relay-only sync12 * (docs/sync-lan-retirement.md). Nothing here is LAN-specific — the relay13 * reconnect responder speaks the same frame — so it moved rather than went.14 */15data 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: 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)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}102103/**104 * A presented identity is only trusted when it matches the paired record AND105 * the key Noise actually authenticated. The frame is self-asserted; the106 * authenticated static key is not, so the comparison is anchored there.107 */108internal 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}