package com.peachpasswords.sync import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json 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?, ) /** * Wire shape — snake_case keys and the literal `type` discriminator must match * the browser's `shared/sync-types.ts` PAIR_IDENTITY frame byte-for-byte. The * hand-rolled encoder/parser this replaced disagreed on escaping: the encoder * escaped `"`, `\`, and control bytes, the parser stopped at the next unescaped * quote and never unescaped, so a device name containing `"` or a newline * truncated the frame and corrupted everything after it. kotlinx.serialization * round-trips every JSON escape correctly, and the field validators below * bound the values at the trust boundary. */ @Serializable private data class PairIdentityFields( val type: String = "PAIR_IDENTITY", @SerialName("device_id") val deviceId: String, @SerialName("device_name") val deviceName: String, @SerialName("device_type") val deviceType: String, @SerialName("long_term_public_key_hex") val longTermPublicKeyHex: String, @SerialName("signing_public_key_hex") val signingPublicKeyHex: String? = null, @SerialName("ws_port") val wsPort: Int? = null, @SerialName("relay_url") val relayUrl: String? = null, ) // Field length caps. The browser clamps `device_name` to 64 (DeviceIdentity.ts // mirrors `setDeviceName`, which already enforces 64). Keys are 32-byte hex // (64 chars). A wss URL has no realistic upper bound we want to trust past 2 KiB. private const val MAX_DEVICE_ID = 64 private const val MAX_DEVICE_NAME = 64 private const val MAX_PUBKEY_HEX = 64 private const val MAX_RELAY_URL = 2048 private val PairIdentityJson = Json { encodeDefaults = false ignoreUnknownKeys = true } internal fun encodePairIdentityFrame(id: LocalDeviceIdentity): ByteArray { validateField(id.deviceId, "device_id", MAX_DEVICE_ID) validateField(id.deviceName, "device_name", MAX_DEVICE_NAME) validateHex(id.longTermPublicKeyHex, "long_term_public_key_hex", 32) id.signingPublicKeyHex?.let { validateHex(it, "signing_public_key_hex", 32) } validateField(id.relayUrl, "relay_url", MAX_RELAY_URL) val fields = PairIdentityFields( deviceId = id.deviceId, deviceName = id.deviceName, deviceType = id.deviceType.wireValue, longTermPublicKeyHex = id.longTermPublicKeyHex, signingPublicKeyHex = id.signingPublicKeyHex, wsPort = id.wsPort, relayUrl = id.relayUrl, ) return PairIdentityJson.encodeToString(PairIdentityFields.serializer(), fields) .toByteArray(StandardCharsets.UTF_8) } internal fun parsePairIdentityFrame(bytes: ByteArray): PairIdentity { val fields = PairIdentityJson.decodeFromString( PairIdentityFields.serializer(), String(bytes, StandardCharsets.UTF_8), ) require(fields.type == "PAIR_IDENTITY") { "expected PAIR_IDENTITY frame" } validateField(fields.deviceId, "device_id", MAX_DEVICE_ID) validateField(fields.deviceName, "device_name", MAX_DEVICE_NAME) validateHex(fields.longTermPublicKeyHex, "long_term_public_key_hex", 32) fields.signingPublicKeyHex?.takeIf { it.isNotBlank() } ?.let { validateHex(it, "signing_public_key_hex", 32) } val deviceType = when (fields.deviceType) { "extension" -> DeviceType.EXTENSION "android" -> DeviceType.ANDROID "ios" -> DeviceType.IOS else -> error("device_type invalid: ${fields.deviceType}") } fields.relayUrl?.let { validateField(it, "relay_url", MAX_RELAY_URL) } return PairIdentity( deviceId = fields.deviceId, deviceName = fields.deviceName, deviceType = deviceType, longTermPublicKeyHex = fields.longTermPublicKeyHex, signingPublicKeyHex = fields.signingPublicKeyHex?.takeIf { it.isNotBlank() }, wsPort = fields.wsPort, relayUrl = fields.relayUrl?.let(Rendezvous::normalizeRelayUrl), ) } private fun validateField(value: String, name: String, maxLen: Int) { require(value.isNotEmpty()) { "$name missing" } require(value.length <= maxLen) { "$name exceeds $maxLen chars" } // JSON strings cannot carry raw control bytes; reject them at the trust // boundary so a peer that hands us a name with an embedded NUL or newline // can't truncate the frame even if the encoder slips up. for (ch in value) { require(ch.code >= 0x20 && ch.code != 0x7f) { "$name contains control character" } } } private fun validateHex(value: String, name: String, byteLen: Int) { require(value.length == byteLen * 2) { "$name not $byteLen-byte hex" } require(value.all { it in '0'..'9' || it in 'a'..'f' }) { "$name not lowercase hex" } } /** * 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)) }