ChirpPairing.kt

23.1 KB · raw

1package com.peachpasswords.sync23import kotlinx.coroutines.async4import kotlinx.coroutines.coroutineScope5import kotlinx.coroutines.cancelAndJoin6import kotlinx.coroutines.delay7import kotlinx.coroutines.isActive8import kotlinx.coroutines.withTimeoutOrNull9import java.security.SecureRandom10import java.util.concurrent.atomic.AtomicBoolean11import org.bouncycastle.crypto.params.X25519PrivateKeyParameters1213const val CHIRP_PAIRING_PORT: Int = 491421415interface PairingTransport {16    suspend fun send(msg: ByteArray)17    suspend fun receive(): ByteArray18    suspend fun close()19}2021data class PairingEndpoint(22    val ip: String,23    val port: Int,24)2526data class PairingServer(27    val transport: PairingTransport,28    val peerIp: String,29)3031data class PairingHandshakeResult(32    val device: PairedDevice,33    val sas: String,34    /**35     * The peer's signed Pro license blob, if it advertised one. Activated36     * (signature-verified) ONLY after the SAS is confirmed. Never persisted.37     */38    val peerLicense: String? = null,39)4041enum class PairingRole {42    INITIATOR,43    RESPONDER,44}4546interface ChirpPairingDeps {47    suspend fun fetchLocalIdentity(): LocalChirpIdentity48    suspend fun completePairing(paired: PairedDevice)49    suspend fun confirmSAS(sas: String): Boolean50    suspend fun cacheLastKnownIp(pubkeyHex: String, ip: String)51    suspend fun getLocalSubnet(): String?52    suspend fun openPairingTransport(endpoint: PairingEndpoint): PairingTransport53    suspend fun openPairingServer(): PairingServer54    suspend fun openAudio(): AudioIO55    val chirpTimeoutMs: Long56    fun onProgress(progress: PairingProgress)5758    /**59     * Activate a Pro license the (now SAS-confirmed) peer advertised. The60     * implementation verifies the signature and ignores it if already Pro.61     * Default no-op so existing callers/tests need no change.62     */63    suspend fun activatePeerLicense(license: String) {}64}6566class ChirpPairingFailure(67    val phase: PairingPhase,68    message: String,69) : Error(message)7071interface AudioIO {72    val sampleRate: Int73    suspend fun play(waveform: FloatArray)74    suspend fun pullSamples(): FloatArray75    suspend fun close()76}7778interface PairingKeyPair {79    val publicKey: ByteArray80    val secretKey: ByteArray81}8283private data class PairingX25519KeyPair(84    override val publicKey: ByteArray,85    override val secretKey: ByteArray,86) : PairingKeyPair8788data class PairingAttempt(89    val ephemeralPrivateKey: ByteArray,90    val ephemeralPublicKey: ByteArray,91    val sessionNonce: ByteArray,92    val outgoingPayload: ChirpPayload,93)9495fun createPairingAttempt(deviceType: Byte): PairingAttempt {96    val kp = generatePairingKeyPair()97    val nonce = ByteArray(16)98    secureRandom().nextBytes(nonce)99    return PairingAttempt(100        ephemeralPrivateKey = kp.secretKey,101        ephemeralPublicKey = kp.publicKey,102        sessionNonce = nonce,103        outgoingPayload = ChirpPayload(104            version = CHIRP_PROTOCOL_VERSION,105            deviceType = deviceType,106            ephemeralPublicKey = kp.publicKey,107            sessionNonce = nonce,108        ),109    )110}111112data class ChirpExchangeResult(113    val remoteChirp: ChirpPayload,114)115116data class ChirpExchangeOptions(117    val attempt: PairingAttempt,118    val timeoutMs: Long = DEFAULT_CHIRP_TIMEOUT_MS,119    val onDiscard: ((ChirpDecodeError.Kind) -> Unit)? = null,120    val onChirpPlayed: ((Int) -> Unit)? = null,121)122123enum class ChirpExchangeErrorKind {124    TIMEOUT,125    VERSION_MISMATCH,126    MIC_DENIED,127    AUDIO_UNAVAILABLE,128}129130class ChirpExchangeError(131    val kind: ChirpExchangeErrorKind,132    message: String? = null,133) : Error(message ?: kind.name.lowercase())134135private const val CHIRP_LOOP_INTERVAL_MS = 4_000L136private const val DEFAULT_CHIRP_TIMEOUT_MS = 90_000L137private const val LISTEN_FRAME_SAMPLES = 4096138139fun generatePairingKeyPair(): PairingKeyPair {140    val priv = X25519PrivateKeyParameters(secureRandom())141    return PairingX25519KeyPair(142        publicKey = priv.generatePublicKey().encoded,143        secretKey = priv.encoded,144    )145}146147private fun secureRandom(): SecureRandom = SecureRandom()148149private fun sameBytes(a: ByteArray, b: ByteArray): Boolean {150    if (a.size != b.size) return false151    for (i in a.indices) {152        if (a[i] != b[i]) return false153    }154    return true155}156157/**158 * Run the two-chirp exchange against a provided audio interface.159 *160 * This is a direct port of the browser coordinator logic. The codec and161 * audio bridge are injectable so the protocol can be unit-tested before the162 * Android-native ggwave bridge lands.163 */164suspend fun runChirpExchange(165    io: AudioIO,166    opts: ChirpExchangeOptions,167    codecFactory: ChirpCodecFactory = ::createChirpCodec,168): ChirpExchangeResult {169    return coroutineScope {170        val codec = codecFactory(171            ChirpCodecOptions(172                sampleRate = io.sampleRate,173                protocol = GgwaveProtocol.ULTRASOUND_FASTEST,174                volumePercent = 10,175            ),176        )177        val ourBytes = encodeChirpPayload(opts.attempt.outgoingPayload)178        val ourWaveform = codec.encode(ourBytes)179180        val deadline = System.currentTimeMillis() + opts.timeoutMs181        val stopped = AtomicBoolean(false)182183        val playLoop = async {184            var playCount = 0185            while (isActive && !stopped.get() && System.currentTimeMillis() < deadline) {186                try {187                    io.play(ourWaveform)188                    playCount++189                    opts.onChirpPlayed?.invoke(playCount)190                } catch (_: Throwable) {191                    // Play failures are non-fatal; keep listening.192                }193                if (stopped.get()) break194                val remaining = deadline - System.currentTimeMillis()195                val wait = minOf(CHIRP_LOOP_INTERVAL_MS, maxOf(0L, remaining))196                if (wait <= 0L) break197                delay(wait)198            }199        }200201        try {202            while (System.currentTimeMillis() < deadline && !stopped.get()) {203                val samples = io.pullSamples()204                if (samples.isEmpty()) {205                    delay(50)206                    continue207                }208209                val decoded = codec.decode(samples)210                if (decoded == null) continue211                if (decoded.size != CHIRP_PAYLOAD_LENGTH) {212                    opts.onDiscard?.invoke(ChirpDecodeError.Kind.LENGTH)213                    continue214                }215216                try {217                    val payload = decodeChirpPayload(decoded)218                    if (sameBytes(payload.ephemeralPublicKey, opts.attempt.ephemeralPublicKey)) {219                        continue220                    }221                    if (payload.version != CHIRP_PROTOCOL_VERSION) {222                        throw ChirpExchangeError(ChirpExchangeErrorKind.VERSION_MISMATCH)223                    }224                    stopped.set(true)225                    return@coroutineScope ChirpExchangeResult(remoteChirp = payload)226                } catch (err: ChirpExchangeError) {227                    throw err228                } catch (err: ChirpDecodeError) {229                    if (err.kind == ChirpDecodeError.Kind.VERSION) {230                        throw ChirpExchangeError(ChirpExchangeErrorKind.VERSION_MISMATCH)231                    }232                    opts.onDiscard?.invoke(err.kind)233                    continue234                }235            }236            throw ChirpExchangeError(ChirpExchangeErrorKind.TIMEOUT)237        } finally {238            stopped.set(true)239            codec.free()240            playLoop.cancelAndJoin()241            try {242                io.close()243            } catch (_: Throwable) {244                // ignore245            }246        }247    }248}249250suspend fun openAndroidAudioIO(): AudioIO = openPcmFloatAudioIO()251252suspend fun runChirpPairing(253    deps: ChirpPairingDeps,254    role: PairingRole = PairingRole.RESPONDER,255    codecFactory: ChirpCodecFactory = ::createChirpCodec,256): PairedDevice {257    deps.onProgress(PairingProgress(phase = PairingPhase.IDLE))258259    val localIdentity = deps.fetchLocalIdentity()260261    if (role == PairingRole.RESPONDER && localIdentity.deviceType != DeviceType.EXTENSION) {262        throw ChirpPairingFailure(PairingPhase.HANDSHAKE_FAILED, "responder requires extension identity")263    }264    if (role == PairingRole.INITIATOR && localIdentity.deviceType == DeviceType.EXTENSION) {265        throw ChirpPairingFailure(PairingPhase.HANDSHAKE_FAILED, "initiator requires non-extension identity")266    }267268    deps.onProgress(PairingProgress(phase = PairingPhase.LISTENING))269270    val deviceTypeByte = when (localIdentity.deviceType) {271        DeviceType.ANDROID -> deviceTypeToByte(DeviceType.ANDROID)272        DeviceType.IOS -> deviceTypeToByte(DeviceType.IOS)273        DeviceType.DESKTOP -> deviceTypeToByte(DeviceType.DESKTOP)274        DeviceType.EXTENSION -> deviceTypeToByte(DeviceType.EXTENSION)275    }276    val attempt = createPairingAttempt(deviceTypeByte)277278    val remoteChirpPublicKey: ByteArray279    try {280        val audio = deps.openAudio()281        val result = runChirpExchange(282            io = audio,283            opts = ChirpExchangeOptions(284                attempt = attempt,285                timeoutMs = deps.chirpTimeoutMs,286            ),287            codecFactory = codecFactory,288        )289        remoteChirpPublicKey = result.remoteChirp.ephemeralPublicKey290    } catch (err: ChirpExchangeError) {291        val phase = when (err.kind) {292            ChirpExchangeErrorKind.TIMEOUT -> PairingPhase.TIMEOUT293            ChirpExchangeErrorKind.VERSION_MISMATCH -> PairingPhase.VERSION_MISMATCH294            ChirpExchangeErrorKind.MIC_DENIED -> PairingPhase.MIC_DENIED295            ChirpExchangeErrorKind.AUDIO_UNAVAILABLE -> PairingPhase.HANDSHAKE_FAILED296        }297        deps.onProgress(PairingProgress(phase = phase, errorMessage = err.message))298        throw ChirpPairingFailure(phase, err.message ?: phase.wireValue)299    }300301    deps.onProgress(PairingProgress(phase = PairingPhase.HANDSHAKE))302303    if (role == PairingRole.RESPONDER) {304        return runResponderHandshake(deps, attempt, localIdentity)305    }306    return runInitiatorHandshake(deps, remoteChirpPublicKey, localIdentity)307}308309private suspend fun runResponderHandshake(310    deps: ChirpPairingDeps,311    attempt: PairingAttempt,312    localIdentity: LocalChirpIdentity,313): PairedDevice {314    val subnet = deps.getLocalSubnet()315    if (subnet.isNullOrBlank()) {316        deps.onProgress(PairingProgress(phase = PairingPhase.HANDSHAKE_FAILED, errorMessage = "no_subnet"))317        throw ChirpPairingFailure(PairingPhase.HANDSHAKE_FAILED, "could not determine local subnet")318    }319320    val peerEndpoint = firstResponsivePeer(deps, subnet, CHIRP_PAIRING_PORT)321    if (peerEndpoint == null) {322        deps.onProgress(PairingProgress(phase = PairingPhase.HANDSHAKE_FAILED, errorMessage = "peer_not_found"))323        throw ChirpPairingFailure(PairingPhase.HANDSHAKE_FAILED, "could not reach paired peer on subnet")324    }325326    val transport = deps.openPairingTransport(peerEndpoint)327    val result = try {328        runPairingResponder(329            ourEphemeralPrivateKey = attempt.ephemeralPrivateKey,330            ourEphemeralPublicKey = attempt.ephemeralPublicKey,331            ourIdentity = localIdentity,332            transport = transport,333        )334    } catch (err: Throwable) {335        try {336            transport.close()337        } catch (_: Throwable) {338            // ignore339        }340        deps.onProgress(PairingProgress(phase = PairingPhase.HANDSHAKE_FAILED, errorMessage = err.message))341        throw ChirpPairingFailure(PairingPhase.HANDSHAKE_FAILED, err.message ?: "handshake failed")342    }343    transport.close()344345    val paired = gateOnSAS(deps, result)346    activatePeerLicenseIfAny(deps, result)347    deps.completePairing(paired)348    deps.cacheLastKnownIp(paired.longTermPublicKeyHex.orEmpty(), peerEndpoint.ip)349    deps.onProgress(PairingProgress(phase = PairingPhase.PAIRED, peerDeviceName = paired.deviceName))350    return paired351}352353private suspend fun gateOnSAS(354    deps: ChirpPairingDeps,355    result: PairingHandshakeResult,356): PairedDevice {357    val confirmed = try {358        deps.confirmSAS(result.sas)359    } catch (err: Throwable) {360        deps.onProgress(PairingProgress(phase = PairingPhase.HANDSHAKE_FAILED, errorMessage = err.message))361        throw ChirpPairingFailure(PairingPhase.HANDSHAKE_FAILED, err.message ?: "handshake failed")362    }363    if (!confirmed) {364        deps.onProgress(PairingProgress(phase = PairingPhase.HANDSHAKE_FAILED, errorMessage = "sas_rejected"))365        throw ChirpPairingFailure(366            PairingPhase.HANDSHAKE_FAILED,367            "pairing rejected - the verification codes did not match",368        )369    }370    return result.device371}372373/**374 * If the (now SAS-confirmed) peer advertised a Pro license, hand it to the375 * deps to verify + activate. Best-effort: a bad/duplicate license never fails376 * the pairing — the device link still succeeds.377 */378private suspend fun activatePeerLicenseIfAny(379    deps: ChirpPairingDeps,380    result: PairingHandshakeResult,381) {382    val license = result.peerLicense ?: return383    try {384        deps.activatePeerLicense(license)385    } catch (_: Throwable) {386        // Invalid signature, already-Pro, or storage error — pairing stands.387    }388}389390private suspend fun runInitiatorHandshake(391    deps: ChirpPairingDeps,392    remoteChirpPublicKey: ByteArray,393    localIdentity: LocalChirpIdentity,394): PairedDevice {395    val server = try {396        deps.openPairingServer()397    } catch (err: Throwable) {398        deps.onProgress(PairingProgress(phase = PairingPhase.HANDSHAKE_FAILED, errorMessage = err.message))399        throw ChirpPairingFailure(PairingPhase.HANDSHAKE_FAILED, err.message ?: "handshake failed")400    }401402    val result = try {403        runPairingInitiator(remoteChirpPublicKey, localIdentity, server.transport)404    } catch (err: Throwable) {405        try {406            server.transport.close()407        } catch (_: Throwable) {408            // ignore409        }410        deps.onProgress(PairingProgress(phase = PairingPhase.HANDSHAKE_FAILED, errorMessage = err.message))411        throw ChirpPairingFailure(PairingPhase.HANDSHAKE_FAILED, err.message ?: "handshake failed")412    }413    server.transport.close()414415    val paired = gateOnSAS(deps, result)416    activatePeerLicenseIfAny(deps, result)417    deps.completePairing(paired)418    deps.cacheLastKnownIp(paired.longTermPublicKeyHex.orEmpty(), server.peerIp)419    deps.onProgress(PairingProgress(phase = PairingPhase.PAIRED, peerDeviceName = paired.deviceName))420    return paired421}422423private suspend fun firstResponsivePeer(424    deps: ChirpPairingDeps,425    subnet: String,426    port: Int,427): PairingEndpoint? {428    val probeTimeoutMs = 250L429    val batch = 16430    for (start in 1..254 step batch) {431        val end = minOf(start + batch - 1, 254)432        val ips = (start..end).map { "$subnet.$it" }433        val results = coroutineScope {434            ips.map { ip ->435                async {436                    try {437                        val transport = withTimeoutOrNull(probeTimeoutMs) {438                            deps.openPairingTransport(PairingEndpoint(ip = ip, port = port))439                        }440                        if (transport != null) {441                            transport.close()442                            PairingEndpoint(ip = ip, port = port)443                        } else {444                            null445                        }446                    } catch (_: Throwable) {447                        null448                    }449                }450            }.mapNotNull { it.await() }451        }452        val hit = results.firstOrNull()453        if (hit != null) return hit454    }455    return null456}457458private suspend fun runPairingInitiator(459    remoteEphemeralPublicKey: ByteArray,460    ourIdentity: LocalChirpIdentity,461    transport: PairingTransport,462): PairingHandshakeResult {463    val session = noiseNKInitiator(464        remoteEphemeralPublicKey,465        transport::send,466        transport::receive,467    )468469    transport.send(470        session.encrypt(471            encodePairingFrame(472                PairingFrame(473                    type = "PAIR_IDENTITY",474                    deviceId = ourIdentity.deviceId,475                    deviceName = ourIdentity.deviceName,476                    deviceType = ourIdentity.deviceType,477                    longTermPublicKeyHex = ourIdentity.longTermPublicKeyHex,478                    signingPublicKeyHex = ourIdentity.signingPublicKeyHex,479                    wsPort = ourIdentity.wsPort,480                    license = ourIdentity.license,481                ),482            ),483        ),484    )485486    val theirCipher = transport.receive()487    val theirPlain = session.decrypt(theirCipher)488    val theirFrame = decodePairingFrame(theirPlain)489    return PairingHandshakeResult(490        device = frameToPairedDevice(theirFrame),491        sas = SASDerivation.derive(session.handshakeHash),492        peerLicense = theirFrame.license,493    )494}495496private suspend fun runPairingResponder(497    ourEphemeralPrivateKey: ByteArray,498    ourEphemeralPublicKey: ByteArray,499    ourIdentity: LocalChirpIdentity,500    transport: PairingTransport,501): PairingHandshakeResult {502    val session = noiseNKResponder(503        ourEphemeralPrivateKey,504        ourEphemeralPublicKey,505        transport::send,506        transport::receive,507    )508509    val theirCipher = transport.receive()510    val theirPlain = session.decrypt(theirCipher)511    val theirFrame = decodePairingFrame(theirPlain)512513    transport.send(514        session.encrypt(515            encodePairingFrame(516                PairingFrame(517                    type = "PAIR_IDENTITY",518                    deviceId = ourIdentity.deviceId,519                    deviceName = ourIdentity.deviceName,520                    deviceType = ourIdentity.deviceType,521                    longTermPublicKeyHex = ourIdentity.longTermPublicKeyHex,522                    signingPublicKeyHex = ourIdentity.signingPublicKeyHex,523                    wsPort = ourIdentity.wsPort,524                    license = ourIdentity.license,525                ),526            ),527        ),528    )529530    return PairingHandshakeResult(531        device = frameToPairedDevice(theirFrame),532        sas = SASDerivation.derive(session.handshakeHash),533        peerLicense = theirFrame.license,534    )535}536537internal data class PairingFrame(538    val type: String,539    val deviceId: String,540    val deviceName: String,541    val deviceType: DeviceType,542    val longTermPublicKeyHex: String,543    val signingPublicKeyHex: String? = null,544    val wsPort: Int? = null,545    // Optional signed Pro license blob — additive; peers that don't understand546    // it ignore it. Activated by the receiver only after SAS confirmation.547    val license: String? = null,548)549550internal fun encodePairingFrame(frame: PairingFrame): ByteArray {551    val builder = StringBuilder()552    builder.append('{')553    builder.append("\"type\":\"").append(frame.type).append('\"')554    builder.append(",\"device_id\":\"").append(escapeJson(frame.deviceId)).append('\"')555    builder.append(",\"device_name\":\"").append(escapeJson(frame.deviceName)).append('\"')556    builder.append(",\"device_type\":\"").append(frame.deviceType.wireValue).append('\"')557    builder.append(",\"long_term_public_key_hex\":\"").append(frame.longTermPublicKeyHex).append('\"')558    if (!frame.signingPublicKeyHex.isNullOrBlank()) {559        builder.append(",\"signing_public_key_hex\":\"").append(frame.signingPublicKeyHex).append('\"')560    }561    if (frame.wsPort != null) {562        builder.append(",\"ws_port\":").append(frame.wsPort)563    }564    if (!frame.license.isNullOrBlank()) {565        builder.append(",\"license\":\"").append(escapeJson(frame.license)).append('\"')566    }567    builder.append('}')568    return builder.toString().encodeToByteArray()569}570571internal fun decodePairingFrame(bytes: ByteArray): PairingFrame {572    val text = bytes.decodeToString()573    val parsed = simpleJsonObject(text)574    require(parsed["type"] == "PAIR_IDENTITY") { "expected PAIR_IDENTITY frame" }575    val deviceId = parsed["device_id"] ?: error("device_id missing")576    val deviceName = parsed["device_name"] ?: error("device_name missing")577    val deviceType = when (parsed["device_type"]) {578        "extension" -> DeviceType.EXTENSION579        "android" -> DeviceType.ANDROID580        "ios" -> DeviceType.IOS581        "desktop" -> DeviceType.DESKTOP582        else -> error("device_type missing")583    }584    val longTermPublicKeyHex = parsed["long_term_public_key_hex"] ?: error("long_term_public_key_hex missing")585    require(longTermPublicKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {586        "long_term_public_key_hex is not 32-byte hex"587    }588    val signingPublicKeyHex = parsed["signing_public_key_hex"]?.takeIf { it.isNotBlank() }589    if (signingPublicKeyHex != null) {590        require(signingPublicKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {591            "signing_public_key_hex is not 32-byte hex"592        }593    }594    val wsPort = parsed["ws_port"]?.toIntOrNull()595    val license = parsed["license"]?.takeIf { it.isNotBlank() }596    return PairingFrame(597        type = "PAIR_IDENTITY",598        deviceId = deviceId,599        deviceName = deviceName,600        deviceType = deviceType,601        longTermPublicKeyHex = longTermPublicKeyHex,602        signingPublicKeyHex = signingPublicKeyHex,603        wsPort = wsPort,604        license = license,605    )606}607608private fun frameToPairedDevice(frame: PairingFrame): PairedDevice {609    return PairedDevice(610        deviceId = frame.deviceId,611        deviceName = frame.deviceName,612        deviceType = frame.deviceType.wireValue,613        longTermPublicKeyHex = frame.longTermPublicKeyHex,614        signingPublicKeyHex = frame.signingPublicKeyHex,615        trustSource = "direct",616        wsPort = frame.wsPort,617        pairedAt = System.currentTimeMillis(),618    )619}620621private fun escapeJson(value: String): String {622    return buildString(value.length) {623        for (ch in value) {624            when (ch) {625                '\\' -> append("\\\\")626                '"' -> append("\\\"")627                '\b' -> append("\\b")628                '\u000C' -> append("\\f")629                '\n' -> append("\\n")630                '\r' -> append("\\r")631                '\t' -> append("\\t")632                else -> append(ch)633            }634        }635    }636}637638private fun simpleJsonObject(text: String): Map<String, String> {639    val trimmed = text.trim()640    require(trimmed.startsWith("{") && trimmed.endsWith("}")) { "expected JSON object" }641    val body = trimmed.substring(1, trimmed.length - 1).trim()642    if (body.isEmpty()) return emptyMap()643    val out = linkedMapOf<String, String>()644    var i = 0645    while (i < body.length) {646        while (i < body.length && body[i].isWhitespace()) i++647        require(i < body.length && body[i] == '"') { "expected string key" }648        i++649        val keyStart = i650        while (i < body.length && body[i] != '"') i++651        val key = body.substring(keyStart, i)652        i++653        while (i < body.length && (body[i].isWhitespace() || body[i] == ':')) i++654        val value: String655        if (i < body.length && body[i] == '"') {656            i++657            val valueStart = i658            while (i < body.length && body[i] != '"') i++659            value = body.substring(valueStart, i)660            i++661        } else {662            val valueStart = i663            while (i < body.length && body[i] != ',') i++664            value = body.substring(valueStart, i).trim()665        }666        out[key] = value667        while (i < body.length && body[i].isWhitespace()) i++668        if (i < body.length && body[i] == ',') i++669    }670    return out671}