PairIdentityFrames.kt

6.0 KB · raw

1package com.peachpasswords.sync23import kotlinx.serialization.SerialName4import kotlinx.serialization.Serializable5import kotlinx.serialization.json.Json6import java.nio.charset.StandardCharsets7import java.security.MessageDigest89/**10 * The PAIR_IDENTITY frame: who a peer claims to be, exchanged inside the11 * encrypted session once and pinned thereafter.12 *13 * Lived in the retired LAN listeners (`MdnsPairingServer` /14 * `SyncReconnectListener`) until relay-only sync15 * (docs/sync-lan-retirement.md). Nothing here is LAN-specific — the relay16 * reconnect responder speaks the same frame — so it moved rather than went.17 */18data class PairIdentity(19    val deviceId: String,20    val deviceName: String,21    val deviceType: DeviceType,22    val longTermPublicKeyHex: String,23    val signingPublicKeyHex: String?,24    val wsPort: Int?,25    val relayUrl: String?,26)2728/**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@Serializable39private 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)4950// 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.53private const val MAX_DEVICE_ID = 6454private const val MAX_DEVICE_NAME = MAX_DEVICE_NAME_CHARS55private const val MAX_PUBKEY_HEX = 6456private const val MAX_RELAY_URL = 20485758private val PairIdentityJson = Json {59    encodeDefaults = true60    ignoreUnknownKeys = true61}6263internal fun encodePairIdentityFrame(id: LocalDeviceIdentity): ByteArray {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)80}8182internal fun parsePairIdentityFrame(bytes: ByteArray): PairIdentity {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) {94        "extension" -> DeviceType.EXTENSION95        "android" -> DeviceType.ANDROID96        "ios" -> DeviceType.IOS97        else -> error("device_type invalid: ${fields.deviceType}")98    }99    fields.relayUrl?.let { validateField(it, "relay_url", MAX_RELAY_URL) }100    return PairIdentity(101        deviceId = fields.deviceId,102        deviceName = fields.deviceName,103        deviceType = deviceType,104        longTermPublicKeyHex = fields.longTermPublicKeyHex,105        signingPublicKeyHex = fields.signingPublicKeyHex?.takeIf { it.isNotBlank() },106        wsPort = fields.wsPort,107        relayUrl = fields.relayUrl?.let(Rendezvous::normalizeRelayUrl),108    )109}110111private 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 escapes control chars (newlines, tabs, etc.) as \uXXXX, so legitimate115    // device names containing them round-trip correctly. Only NUL is universally116    // problematic — kotlinx.serialization can emit it but parsers downstream117    // often truncate at it.118    for (ch in value) {119        require(ch.code != 0x00) { "$name contains NUL" }120    }121}122123private fun validateHex(value: String, name: String, byteLen: Int) {124    require(value.length == byteLen * 2) { "$name not $byteLen-byte hex" }125    require(value.all { it in '0'..'9' || it in 'a'..'f' }) { "$name not lowercase hex" }126}127128/**129 * A presented identity is only trusted when it matches the paired record AND130 * the key Noise actually authenticated. The frame is self-asserted; the131 * authenticated static key is not, so the comparison is anchored there.132 */133internal fun identityMatchesAuthenticatedPeer(134    known: PairedDevice,135    presented: PairIdentity,136    authenticatedKey: ByteArray,137): Boolean {138    val expected = known.longTermPublicKeyHex ?: return false139    return known.deviceId == presented.deviceId &&140        CryptoCompare.hex(expected, presented.longTermPublicKeyHex) &&141        MessageDigest.isEqual(authenticatedKey, hexToPubkey(presented.longTermPublicKeyHex))142}