package com.peachpasswords.sync import java.nio.charset.StandardCharsets import java.security.MessageDigest /** * The PAIR_IDENTITY frame: who a peer claims to be, exchanged inside the * encrypted session once and pinned thereafter. * * Lived in the retired LAN listeners (`MdnsPairingServer` / * `SyncReconnectListener`) until relay-only sync * (docs/sync-lan-retirement.md). Nothing here is LAN-specific — the relay * reconnect responder speaks the same frame — so it moved rather than went. */ data class PairIdentity( val deviceId: String, val deviceName: String, val deviceType: DeviceType, val longTermPublicKeyHex: String, val signingPublicKeyHex: String?, val wsPort: Int?, val relayUrl: String?, ) internal fun encodePairIdentityFrame(id: LocalDeviceIdentity): ByteArray { val sb = StringBuilder() sb.append('{') sb.append("\"type\":\"PAIR_IDENTITY\"") sb.append(",\"device_id\":\"").append(jsonEscape(id.deviceId)).append('"') sb.append(",\"device_name\":\"").append(jsonEscape(id.deviceName)).append('"') sb.append(",\"device_type\":\"").append(id.deviceType.wireValue).append('"') sb.append(",\"long_term_public_key_hex\":\"").append(id.longTermPublicKeyHex).append('"') sb.append(",\"signing_public_key_hex\":\"").append(id.signingPublicKeyHex).append('"') id.wsPort?.let { sb.append(",\"ws_port\":").append(it) } sb.append(",\"relay_url\":\"").append(jsonEscape(id.relayUrl)).append('"') sb.append('}') return sb.toString().toByteArray(StandardCharsets.UTF_8) } internal fun parsePairIdentityFrame(bytes: ByteArray): PairIdentity { val map = miniJsonObject(String(bytes, StandardCharsets.UTF_8)) require(map["type"] == "PAIR_IDENTITY") { "expected PAIR_IDENTITY frame" } val deviceType = when (map["device_type"]) { "extension" -> DeviceType.EXTENSION "android" -> DeviceType.ANDROID "ios" -> DeviceType.IOS else -> error("device_type missing") } val ltpk = map["long_term_public_key_hex"] ?: error("long_term_public_key_hex missing") require(ltpk.matches(Regex("^[0-9a-f]{64}$"))) { "long_term_public_key_hex not 32-byte hex" } return PairIdentity( deviceId = map["device_id"] ?: error("device_id missing"), deviceName = map["device_name"] ?: error("device_name missing"), deviceType = deviceType, longTermPublicKeyHex = ltpk, signingPublicKeyHex = map["signing_public_key_hex"]?.takeIf { it.isNotBlank() }, wsPort = map["ws_port"]?.toIntOrNull(), relayUrl = map["relay_url"]?.let(Rendezvous::normalizeRelayUrl), ) } private fun jsonEscape(value: String): String = buildString(value.length) { for (ch in value) when (ch) { '\\' -> append("\\\\"); '"' -> append("\\\"") '\n' -> append("\\n"); '\r' -> append("\\r"); '\t' -> append("\\t") else -> append(ch) } } private fun miniJsonObject(text: String): Map { val trimmed = text.trim() require(trimmed.startsWith("{") && trimmed.endsWith("}")) { "expected JSON object" } val body = trimmed.substring(1, trimmed.length - 1) val out = linkedMapOf() var i = 0 while (i < body.length) { while (i < body.length && body[i].isWhitespace()) i++ if (i >= body.length || body[i] != '"') break i++ val keyStart = i while (i < body.length && body[i] != '"') i++ val key = body.substring(keyStart, i); i++ while (i < body.length && (body[i].isWhitespace() || body[i] == ':')) i++ val value: String if (i < body.length && body[i] == '"') { i++ val vStart = i while (i < body.length && body[i] != '"') i++ value = body.substring(vStart, i); i++ } else { val vStart = i while (i < body.length && body[i] != ',') i++ value = body.substring(vStart, i).trim() } out[key] = value while (i < body.length && body[i].isWhitespace()) i++ if (i < body.length && body[i] == ',') i++ } return out } /** * A presented identity is only trusted when it matches the paired record AND * the key Noise actually authenticated. The frame is self-asserted; the * authenticated static key is not, so the comparison is anchored there. */ internal fun identityMatchesAuthenticatedPeer( known: PairedDevice, presented: PairIdentity, authenticatedKey: ByteArray, ): Boolean { val expected = known.longTermPublicKeyHex ?: return false return known.deviceId == presented.deviceId && CryptoCompare.hex(expected, presented.longTermPublicKeyHex) && MessageDigest.isEqual(authenticatedKey, hexToPubkey(presented.longTermPublicKeyHex)) }