CodePairingServer.kt

8.6 KB · raw

1package com.peachpasswords.sync23import kotlinx.coroutines.CompletableDeferred4import kotlinx.coroutines.CoroutineScope5import kotlinx.coroutines.Dispatchers6import kotlinx.coroutines.channels.awaitClose7import kotlinx.coroutines.flow.Flow8import kotlinx.coroutines.flow.callbackFlow9import kotlinx.coroutines.isActive10import kotlinx.coroutines.launch11import kotlinx.coroutines.withContext12import org.json.JSONObject13import java.net.Inet4Address14import java.net.NetworkInterface15import java.net.ServerSocket16import java.net.Socket17import javax.inject.Inject18import javax.inject.Singleton1920/**21 * Code-based pairing, phone side. The phone listens and displays a code; the22 * browser dials out and presents the token from it.23 *24 * Wire contract (peach-browser is the authority — `.agent/CONTINUITY.md`25 * 2026-07-24T17:36Z, implemented there in `shared/pairing-dial.ts`):26 *27 *   1. browser dials `ws://<ip>:<port>` taken from the code28 *   2. browser → phone, plaintext text frame: {type:"PEACH_HELLO", token:"<lowercase hex>"}29 *   3. phone verifies the token; on mismatch it closes the socket with NO error30 *      frame — that silence is the invalid-code signal31 *   4. phone → browser, plaintext: {type:"PEACH_HELLO_ACK", ephemeralPublicKeyHex:"<64 hex>"}32 *      (that key is the phone's Noise NK static `s`)33 *   5. Noise NK, phone as RESPONDER, over binary frames34 *   6. one encrypted PAIR_IDENTITY frame each way, BROWSER FIRST35 *   7. both derive the same 6-digit SAS from the handshake hash, and BOTH gate36 *      persistence on the user confirming it37 *38 * Step 7 is why [PairingSession] hands the SAS out and waits: the older39 * [MdnsPairingServer] wrote the peer into [PairedDeviceStore] as soon as the40 * handshake completed, before any human had compared anything, which makes the41 * SAS decorative. Nothing is persisted here until [PairingSession.confirm] is42 * called with true.43 */44sealed interface PairingEvent {45    /** The listener is up; show [code] to the user. */46    data class Listening(val code: String, val ip: String, val port: Int) : PairingEvent4748    /** A peer completed the handshake. Show [sas]; nothing is stored yet. */49    data class AwaitingConfirmation(val sas: String, val deviceName: String, val session: PairingSession) : PairingEvent5051    data class Paired(val deviceName: String) : PairingEvent5253    /** Includes the silent-close case: a peer presented a bad token. */54    data class Rejected(val reason: String) : PairingEvent55}5657class PairingSession internal constructor() {58    private val decision = CompletableDeferred<Boolean>()59    fun confirm(matches: Boolean) { decision.complete(matches) }60    internal suspend fun await(): Boolean = decision.await()61}6263@Singleton64class CodePairingServer @Inject constructor(65    private val deviceStore: PairedDeviceStore,66) {67    private var serverSocket: ServerSocket? = null6869    /**70     * Bind, publish a code, and accept pairing attempts until the flow is71     * cancelled. One token per call: after a successful pairing the caller72     * should stop this flow, which closes the pairing window while leaving any73     * long-lived sync listener alone.74     */75    fun start(76        scope: CoroutineScope,77        ourStaticPrivateKey: ByteArray,78        ourStaticPublicKey: ByteArray,79        localIdentity: LocalChirpIdentity,80    ): Flow<PairingEvent> = callbackFlow {81        val ip = localIpv4()82        if (ip == null) {83            trySend(PairingEvent.Rejected("no_local_network"))84            close()85            return@callbackFlow86        }8788        val socket = withContext(Dispatchers.IO) { ServerSocket(0) }89        serverSocket = socket90        val token = PairingCode.newToken()91        val tokenHex = token.joinToString("") { "%02x".format(it) }9293        trySend(PairingEvent.Listening(PairingCode.encode(ip, socket.localPort, token), ip, socket.localPort))9495        val acceptJob = scope.launch(Dispatchers.IO) {96            while (isActive && !socket.isClosed) {97                val client = runCatching { socket.accept() }.getOrNull() ?: break98                launch {99                    trySend(100                        handleClient(client, tokenHex, ourStaticPrivateKey, ourStaticPublicKey, localIdentity),101                    )102                }103            }104        }105106        awaitClose {107            acceptJob.cancel()108            stop()109        }110    }111112    fun stop() {113        runCatching { serverSocket?.close() }114        serverSocket = null115    }116117    private suspend fun handleClient(118        client: Socket,119        expectedTokenHex: String,120        ourStaticPrivateKey: ByteArray,121        ourStaticPublicKey: ByteArray,122        localIdentity: LocalChirpIdentity,123    ): PairingEvent = withContext(Dispatchers.IO) {124        val ws = WebSocketConnection(client, client.getInputStream(), client.getOutputStream())125        try {126            if (!ws.accept()) {127                ws.closeSilently()128                return@withContext PairingEvent.Rejected("not_a_websocket")129            }130131            val hello = ws.receive() as? WsMessage.Text132                ?: return@withContext rejectSilently(ws, "no_hello")133            val helloJson = runCatching { JSONObject(hello.text) }.getOrNull()134                ?: return@withContext rejectSilently(ws, "bad_hello")135            if (helloJson.optString("type") != "PEACH_HELLO") {136                return@withContext rejectSilently(ws, "bad_hello")137            }138            if (!constantTimeEquals(helloJson.optString("token"), expectedTokenHex)) {139                return@withContext rejectSilently(ws, "bad_token")140            }141142            ws.sendText(143                JSONObject()144                    .put("type", "PEACH_HELLO_ACK")145                    .put("ephemeralPublicKeyHex", ourStaticPublicKey.joinToString("") { "%02x".format(it) })146                    .toString(),147            )148149            val session = noiseNKResponder(150                ourStaticPrivateKey,151                ourStaticPublicKey,152                { frame -> ws.send(frame) },153                { (ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed") },154            )155156            // Browser first, then us — matching the contract's ordering.157            val theirIdentity = parsePairIdentityFrame(158                session.decrypt((ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed")),159            )160            ws.send(session.encrypt(encodePairIdentityFrame(localIdentity)))161162            val sas = SASDerivation.derive(session.handshakeHash)163            val device = pairIdentityToPairedDevice(theirIdentity)164            val pending = PairingSession()165            val name = device.deviceName?.takeIf { it.isNotBlank() } ?: "Unknown device"166167            // Hand the SAS to the UI and block here. Persisting before the user168            // has compared the digits would make the comparison meaningless.169            val confirmation = PairingEvent.AwaitingConfirmation(sas, name, pending)170            confirmationSink?.invoke(confirmation)171            if (!pending.await()) {172                ws.closeSilently()173                return@withContext PairingEvent.Rejected("sas_mismatch")174            }175176            deviceStore.upsert(device)177            PairingEvent.Paired(name)178        } catch (e: Exception) {179            ws.closeSilently()180            PairingEvent.Rejected(e.message ?: "pairing_failed")181        } finally {182            runCatching { client.close() }183        }184    }185186    /**187     * Set by the ViewModel so the SAS reaches the UI while [handleClient] is188     * still blocked inside the connection.189     */190    var confirmationSink: ((PairingEvent.AwaitingConfirmation) -> Unit)? = null191192    private fun rejectSilently(ws: WebSocketConnection, reason: String): PairingEvent {193        ws.closeSilently()194        return PairingEvent.Rejected(reason)195    }196197    /** The token is low-value, but comparing it in constant time costs nothing. */198    private fun constantTimeEquals(a: String, b: String): Boolean {199        if (a.length != b.length) return false200        var diff = 0201        for (i in a.indices) diff = diff or (a[i].code xor b[i].code)202        return diff == 0203    }204205    /**206     * The address to put in the code. Picks a site-local IPv4 on an up,207     * non-loopback interface — a VPN or tethering interface would produce a208     * code the browser cannot dial.209     */210    private fun localIpv4(): String? =211        runCatching {212            NetworkInterface.getNetworkInterfaces().toList()213                .filter { it.isUp && !it.isLoopback && !it.isVirtual }214                .flatMap { it.inetAddresses.toList() }215                .filterIsInstance<Inet4Address>()216                .firstOrNull { it.isSiteLocalAddress }217                ?.hostAddress218        }.getOrNull()219}