CodePairingServer.kt

13.3 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            // E1: the initiator proves it holds the private half of the key it209            // will advertise. Bind that proof to this NK transcript before any210            // identity frame is accepted or SAS consent is requested.211            val authenticatedInitiatorKey = performResponderPairingAuth(212                session = session,213                ourPrivateKey = ourStaticPrivateKey,214                ourPublicKey = ourStaticPublicKey,215                sendEncryptedJson = { plaintext -> ws.send(session.encrypt(plaintext)) },216                receiveEncryptedJson = {217                    session.decrypt(218                        (ws.receive() as? WsMessage.Binary)?.bytes219                            ?: throw IllegalStateException("peer closed"),220                    )221                },222            )223224            // Browser first, then us — matching the contract's ordering.225            val theirIdentity = parsePairIdentityFrame(226                session.decrypt((ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed")),227            )228            require(229                java.security.MessageDigest.isEqual(230                    authenticatedInitiatorKey,231                    hexToPubkey(theirIdentity.longTermPublicKeyHex),232                ),233            ) { "PAIR_IDENTITY key does not match proven initiator key" }234            ws.send(session.encrypt(encodePairIdentityFrame(localIdentity)))235236            val sas = SASDerivation.derive(session.handshakeHash)237            val device = Rendezvous.bindPairState(238                device = pairIdentityToPairedDevice(theirIdentity),239                handshakeHash = session.handshakeHash,240                // The device displaying the code chooses the mesh relay.241                relayUrl = localIdentity.relayUrl,242            )243            val pending = PairingSession()244            val name = device.deviceName?.takeIf { it.isNotBlank() } ?: "Unknown device"245246            // Hand the SAS to the UI and block here. Persisting before the user247            // has compared the digits would make the comparison meaningless.248            val confirmation = PairingEvent.AwaitingConfirmation(sas, name, pending)249            confirmationSink?.invoke(confirmation)250            if (!pending.await()) {251                ws.closeSilently()252                return@withContext PairingEvent.Rejected("sas_mismatch")253            }254255            deviceStore.upsert(device)256            PairingEvent.Paired(name)257        } catch (e: Exception) {258            ws.closeSilently()259            PairingEvent.Rejected(e.message ?: "pairing_failed")260        } finally {261            runCatching { client.close() }262        }263    }264265    /**266     * Set by the ViewModel so the SAS reaches the UI while [handleClient] is267     * still blocked inside the connection.268     */269    var confirmationSink: ((PairingEvent.AwaitingConfirmation) -> Unit)? = null270271    private fun rejectSilently(ws: WebSocketConnection, reason: String): PairingEvent {272        ws.closeSilently()273        return PairingEvent.Rejected(reason)274    }275276    /** The token is low-value, but comparing it in constant time costs nothing. */277    private fun constantTimeEquals(a: String, b: String): Boolean {278        if (a.length != b.length) return false279        var diff = 0280        for (i in a.indices) diff = diff or (a[i].code xor b[i].code)281        return diff == 0282    }283284    /**285     * The address to put in the code — it has to be the one the browser can286     * actually dial, which is the Wi-Fi address.287     *288     * Interface order is not meaningful, so taking the first site-local IPv4289     * can hand back a VPN (tun/ppp) or tethering (ap/rndis) address. Both are290     * site-local and both produce a code that looks fine and then times out291     * with nothing to debug, so Wi-Fi-ish names are preferred explicitly and292     * tunnels are excluded rather than merely deprioritised.293     */294    internal fun localIpv4(): String? = runCatching {295        val candidates = NetworkInterface.getNetworkInterfaces().toList()296            .filter { it.isUp && !it.isLoopback && !it.isVirtual }297            .filterNot { iface -> TUNNEL_PREFIXES.any { iface.name.startsWith(it) } }298299        fun addressOf(iface: NetworkInterface): String? = iface.inetAddresses.toList()300            .filterIsInstance<Inet4Address>()301            .firstOrNull { it.isSiteLocalAddress }302            ?.hostAddress303304        candidates.firstOrNull { it.name.startsWith("wlan") }?.let(::addressOf)305            ?: candidates.firstNotNullOfOrNull(::addressOf)306    }.getOrNull()307}