CodePairingServer.kt

12.2 KB · raw

1package com.peachpasswords.sync23import kotlinx.coroutines.CompletableDeferred4import kotlinx.coroutines.CoroutineScope5import kotlinx.coroutines.Dispatchers6import kotlinx.coroutines.channels.awaitClose7import kotlinx.coroutines.delay8import kotlinx.coroutines.flow.Flow9import kotlinx.coroutines.flow.callbackFlow10import kotlinx.coroutines.isActive11import kotlinx.coroutines.launch12import kotlinx.coroutines.withContext13import org.json.JSONObject14import java.net.Inet4Address15import java.net.NetworkInterface16import java.net.ServerSocket17import java.net.Socket18import javax.inject.Inject19import javax.inject.Singleton2021/**22 * Code-based pairing, phone side. The phone listens and displays a code; the23 * browser dials out and presents the token from it.24 *25 * Wire contract (peach-browser is the authority — `.agent/CONTINUITY.md`26 * 2026-07-24T17:36Z, implemented there in `shared/pairing-dial.ts`):27 *28 *   1. browser dials `ws://<ip>:<port>` taken from the code29 *   2. browser → phone, plaintext BINARY frame: {type:"PEACH_HELLO", token:"<lowercase hex>"}30 *   3. phone verifies the token; on mismatch it closes the socket with NO error31 *      frame — that silence is the invalid-code signal32 *   4. phone → browser, plaintext BINARY: {type:"PEACH_HELLO_ACK", ephemeralPublicKeyHex:"<64 hex>"}33 *      (that key is the phone's Noise NK static `s`)34 *35 * Every frame in this exchange is binary, including the two JSON ones. This36 * document said "text" until 2026-07-24 and the code followed it; the browser's37 * socket is binary end to end, so pairing failed for every correct code. Verify38 * claims like this against `pairing-dial.ts`, not against this comment.39 *   5. Noise NK, phone as RESPONDER, over binary frames40 *   6. one encrypted PAIR_IDENTITY frame each way, BROWSER FIRST41 *   7. both derive the same 6-digit SAS from the handshake hash, and BOTH gate42 *      persistence on the user confirming it43 *44 * Step 7 is why [PairingSession] hands the SAS out and waits: the older45 * [MdnsPairingServer] wrote the peer into [PairedDeviceStore] as soon as the46 * handshake completed, before any human had compared anything, which makes the47 * SAS decorative. Nothing is persisted here until [PairingSession.confirm] is48 * called with true.49 */50sealed interface PairingEvent {51    /** The listener is up; show [code] to the user. */52    data class Listening(val code: String, val ip: String, val port: Int) : PairingEvent5354    /** A peer completed the handshake. Show [sas]; nothing is stored yet. */55    data class AwaitingConfirmation(val sas: String, val deviceName: String, val session: PairingSession) : PairingEvent5657    data class Paired(val deviceName: String) : PairingEvent5859    /** The code's window elapsed without a pairing; the user must start again. */60    data object Expired : PairingEvent6162    /** Includes the silent-close case: a peer presented a bad token. */63    data class Rejected(val reason: String) : PairingEvent64}6566class PairingSession internal constructor() {67    private val decision = CompletableDeferred<Boolean>()68    fun confirm(matches: Boolean) { decision.complete(matches) }69    internal suspend fun await(): Boolean = decision.await()70}7172@Singleton73class CodePairingServer @Inject constructor(74    // The interface, not the concrete store: the store needs Android's75    // EncryptedSharedPreferences, which would make this whole class untestable76    // off device — and the token/window logic is exactly what wants testing.77    private val deviceStore: PairedDeviceRegistry,78) {79    private var serverSocket: ServerSocket? = null8081    companion object {82        /** Doc says 5–10 minutes; the low end is plenty to walk to a laptop. */83        const val PAIRING_WINDOW_MS = 5L * 60 * 10008485        /** Interfaces whose address a LAN peer cannot route back to. */86        private val TUNNEL_PREFIXES = listOf("tun", "ppp", "ipsec", "rmnet", "rndis", "ap")87    }8889    /**90     * Bind, publish a code, and accept pairing attempts until the flow is91     * cancelled. One token per call: after a successful pairing the caller92     * should stop this flow, which closes the pairing window while leaving any93     * long-lived sync listener alone.94     */95    fun start(96        scope: CoroutineScope,97        ourStaticPrivateKey: ByteArray,98        ourStaticPublicKey: ByteArray,99        localIdentity: LocalChirpIdentity,100        windowMs: Long = PAIRING_WINDOW_MS,101    ): Flow<PairingEvent> = callbackFlow {102        val ip = localIpv4()103        if (ip == null) {104            trySend(PairingEvent.Rejected("no_local_network"))105            close()106            return@callbackFlow107        }108109        val socket = withContext(Dispatchers.IO) { ServerSocket(0) }110        serverSocket = socket111        val token = PairingCode.newToken()112        val tokenHex = token.joinToString("") { "%02x".format(it) }113114        trySend(PairingEvent.Listening(PairingCode.encode(ip, socket.localPort, token), ip, socket.localPort))115116        // The window is not a race against an attacker — there isn't a117        // realistic one on your own LAN for a single-use code. It just stops an118        // abandoned code from staying live indefinitely on a phone in a pocket.119        val expiryJob = scope.launch {120            delay(windowMs)121            trySend(PairingEvent.Expired)122            stop()123        }124125        val acceptJob = scope.launch(Dispatchers.IO) {126            while (isActive && !socket.isClosed) {127                val client = runCatching { socket.accept() }.getOrNull() ?: break128                launch {129                    val event = handleClient(client, tokenHex, ourStaticPrivateKey, ourStaticPublicKey, localIdentity)130                    trySend(event)131                    // One code, one device. A rejected or failed attempt leaves132                    // the window open so the user can retry without generating133                    // a new code; only a real pairing closes it.134                    if (event is PairingEvent.Paired) {135                        expiryJob.cancel()136                        stop()137                    }138                }139            }140        }141142        awaitClose {143            expiryJob.cancel()144            acceptJob.cancel()145            stop()146        }147    }148149    fun stop() {150        runCatching { serverSocket?.close() }151        serverSocket = null152    }153154    private suspend fun handleClient(155        client: Socket,156        expectedTokenHex: String,157        ourStaticPrivateKey: ByteArray,158        ourStaticPublicKey: ByteArray,159        localIdentity: LocalChirpIdentity,160    ): PairingEvent = withContext(Dispatchers.IO) {161        val ws = WebSocketConnection(client, client.getInputStream(), client.getOutputStream())162        try {163            if (!ws.accept()) {164                ws.closeSilently()165                return@withContext PairingEvent.Rejected("not_a_websocket")166            }167168            // The browser sends this as a BINARY frame — `openPairingSocket` in169            // peach-browser's pairing-dial.ts is a binary seam end to end170            // (`ws.binaryType = "arraybuffer"`, `send(bytes: Uint8Array)`).171            // Requiring text here rejected every real dial and closed the socket172            // without a word, which the browser reports as "invalid code" — so a173            // perfectly good code always failed. Text is still accepted so any174            // other caller keeps working.175            val helloText = when (val frame = ws.receive()) {176                is WsMessage.Binary -> String(frame.bytes, Charsets.UTF_8)177                is WsMessage.Text -> frame.text178                else -> return@withContext rejectSilently(ws, "no_hello")179            }180            val helloJson = runCatching { JSONObject(helloText) }.getOrNull()181                ?: return@withContext rejectSilently(ws, "bad_hello")182            if (helloJson.optString("type") != "PEACH_HELLO") {183                return@withContext rejectSilently(ws, "bad_hello")184            }185            if (!constantTimeEquals(helloJson.optString("token"), expectedTokenHex)) {186                return@withContext rejectSilently(ws, "bad_token")187            }188189            // Binary, for the same reason: the browser reads every frame as190            // `new Uint8Array(ev.data as ArrayBuffer)`. A text frame hands it a191            // string, which converts to a ZERO-LENGTH array rather than192            // throwing — and a zero-length ACK is also read as an invalid code.193            ws.send(194                JSONObject()195                    .put("type", "PEACH_HELLO_ACK")196                    .put("ephemeralPublicKeyHex", ourStaticPublicKey.joinToString("") { "%02x".format(it) })197                    .toString()198                    .toByteArray(Charsets.UTF_8),199            )200201            val session = noiseNKResponder(202                ourStaticPrivateKey,203                ourStaticPublicKey,204                { frame -> ws.send(frame) },205                { (ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed") },206            )207208            // Browser first, then us — matching the contract's ordering.209            val theirIdentity = parsePairIdentityFrame(210                session.decrypt((ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed")),211            )212            ws.send(session.encrypt(encodePairIdentityFrame(localIdentity)))213214            val sas = SASDerivation.derive(session.handshakeHash)215            val device = Rendezvous.bindPairState(216                device = pairIdentityToPairedDevice(theirIdentity),217                handshakeHash = session.handshakeHash,218                // The device displaying the code chooses the mesh relay.219                relayUrl = localIdentity.relayUrl,220            )221            val pending = PairingSession()222            val name = device.deviceName?.takeIf { it.isNotBlank() } ?: "Unknown device"223224            // Hand the SAS to the UI and block here. Persisting before the user225            // has compared the digits would make the comparison meaningless.226            val confirmation = PairingEvent.AwaitingConfirmation(sas, name, pending)227            confirmationSink?.invoke(confirmation)228            if (!pending.await()) {229                ws.closeSilently()230                return@withContext PairingEvent.Rejected("sas_mismatch")231            }232233            deviceStore.upsert(device)234            PairingEvent.Paired(name)235        } catch (e: Exception) {236            ws.closeSilently()237            PairingEvent.Rejected(e.message ?: "pairing_failed")238        } finally {239            runCatching { client.close() }240        }241    }242243    /**244     * Set by the ViewModel so the SAS reaches the UI while [handleClient] is245     * still blocked inside the connection.246     */247    var confirmationSink: ((PairingEvent.AwaitingConfirmation) -> Unit)? = null248249    private fun rejectSilently(ws: WebSocketConnection, reason: String): PairingEvent {250        ws.closeSilently()251        return PairingEvent.Rejected(reason)252    }253254    /** The token is low-value, but comparing it in constant time costs nothing. */255    private fun constantTimeEquals(a: String, b: String): Boolean {256        if (a.length != b.length) return false257        var diff = 0258        for (i in a.indices) diff = diff or (a[i].code xor b[i].code)259        return diff == 0260    }261262    /**263     * The address to put in the code — it has to be the one the browser can264     * actually dial, which is the Wi-Fi address.265     *266     * Interface order is not meaningful, so taking the first site-local IPv4267     * can hand back a VPN (tun/ppp) or tethering (ap/rndis) address. Both are268     * site-local and both produce a code that looks fine and then times out269     * with nothing to debug, so Wi-Fi-ish names are preferred explicitly and270     * tunnels are excluded rather than merely deprioritised.271     */272    internal fun localIpv4(): String? = runCatching {273        val candidates = NetworkInterface.getNetworkInterfaces().toList()274            .filter { it.isUp && !it.isLoopback && !it.isVirtual }275            .filterNot { iface -> TUNNEL_PREFIXES.any { iface.name.startsWith(it) } }276277        fun addressOf(iface: NetworkInterface): String? = iface.inetAddresses.toList()278            .filterIsInstance<Inet4Address>()279            .firstOrNull { it.isSiteLocalAddress }280            ?.hostAddress281282        candidates.firstOrNull { it.name.startsWith("wlan") }?.let(::addressOf)283            ?: candidates.firstNotNullOfOrNull(::addressOf)284    }.getOrNull()285}