CodePairingServer.kt

16.4 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        adoptionSourceDeps: AdoptionSourceDeps? = null,102    ): Flow<PairingEvent> = callbackFlow {103        val ip = localIpv4()104        if (ip == null) {105            trySend(PairingEvent.Rejected("no_local_network"))106            close()107            return@callbackFlow108        }109110        val socket = withContext(Dispatchers.IO) { ServerSocket(0) }111        serverSocket = socket112        val token = PairingCode.newToken()113        val tokenHex = token.joinToString("") { "%02x".format(it) }114115        trySend(PairingEvent.Listening(PairingCode.encode(ip, socket.localPort, token), ip, socket.localPort))116117        // The window is not a race against an attacker — there isn't a118        // realistic one on your own LAN for a single-use code. It just stops an119        // abandoned code from staying live indefinitely on a phone in a pocket.120        val expiryJob = scope.launch {121            delay(windowMs)122            trySend(PairingEvent.Expired)123            stop()124            this@callbackFlow.close()125        }126127        val acceptJob = scope.launch(Dispatchers.IO) {128            while (isActive && !socket.isClosed) {129                val client = runCatching { socket.accept() }.getOrNull() ?: break130                launch {131                    val event = handleClient(132                        client,133                        tokenHex,134                        ourStaticPrivateKey,135                        ourStaticPublicKey,136                        localIdentity,137                        adoptionSourceDeps,138                    )139                    trySend(event)140                    // One code, one device. A rejected or failed attempt leaves141                    // the window open so the user can retry without generating142                    // a new code; only a real pairing closes it.143                    if (event is PairingEvent.Paired) {144                        expiryJob.cancel()145                        stop()146                        this@callbackFlow.close()147                    }148                }149            }150        }151152        awaitClose {153            expiryJob.cancel()154            acceptJob.cancel()155            stop()156        }157    }158159    fun stop() {160        runCatching { serverSocket?.close() }161        serverSocket = null162    }163164    private suspend fun handleClient(165        client: Socket,166        expectedTokenHex: String,167        ourStaticPrivateKey: ByteArray,168        ourStaticPublicKey: ByteArray,169        localIdentity: LocalChirpIdentity,170        adoptionSourceDeps: AdoptionSourceDeps?,171    ): PairingEvent = withContext(Dispatchers.IO) {172        val ws = WebSocketConnection(client, client.getInputStream(), client.getOutputStream())173        try {174            if (!ws.accept()) {175                ws.closeSilently()176                return@withContext PairingEvent.Rejected("not_a_websocket")177            }178179            // The browser sends this as a BINARY frame — `openPairingSocket` in180            // peach-browser's pairing-dial.ts is a binary seam end to end181            // (`ws.binaryType = "arraybuffer"`, `send(bytes: Uint8Array)`).182            // Requiring text here rejected every real dial and closed the socket183            // without a word, which the browser reports as "invalid code" — so a184            // perfectly good code always failed. Text is still accepted so any185            // other caller keeps working.186            val helloText = when (val frame = ws.receive()) {187                is WsMessage.Binary -> String(frame.bytes, Charsets.UTF_8)188                is WsMessage.Text -> frame.text189                else -> return@withContext rejectSilently(ws, "no_hello")190            }191            val helloJson = runCatching { JSONObject(helloText) }.getOrNull()192                ?: return@withContext rejectSilently(ws, "bad_hello")193            if (helloJson.optString("type") != "PEACH_HELLO") {194                return@withContext rejectSilently(ws, "bad_hello")195            }196            if (!constantTimeEquals(helloJson.optString("token"), expectedTokenHex)) {197                return@withContext rejectSilently(ws, "bad_token")198            }199            val flow = helloJson.optString("flow").ifBlank { null }200            if (flow != null && flow != "adoption") {201                return@withContext rejectSilently(ws, "unsupported_flow")202            }203204            // Binary, for the same reason: the browser reads every frame as205            // `new Uint8Array(ev.data as ArrayBuffer)`. A text frame hands it a206            // string, which converts to a ZERO-LENGTH array rather than207            // throwing — and a zero-length ACK is also read as an invalid code.208            ws.send(209                JSONObject()210                    .put("type", "PEACH_HELLO_ACK")211                    .put("ephemeralPublicKeyHex", ourStaticPublicKey.joinToString("") { "%02x".format(it) })212                    .toString()213                    .toByteArray(Charsets.UTF_8),214            )215216            val session = noiseNKResponder(217                ourStaticPrivateKey,218                ourStaticPublicKey,219                { frame -> ws.send(frame) },220                { (ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed") },221            )222223            if (flow == "adoption") {224                val deps = adoptionSourceDeps225                    ?: return@withContext rejectSilently(ws, "adoption_unavailable")226                return@withContext handleAdoption(227                    ws,228                    session,229                    localIdentity,230                    deps,231                )232            }233234            // E1: the initiator proves it holds the private half of the key it235            // will advertise. Bind that proof to this NK transcript before any236            // identity frame is accepted or SAS consent is requested.237            val authenticatedInitiatorKey = performResponderPairingAuth(238                session = session,239                ourPrivateKey = ourStaticPrivateKey,240                ourPublicKey = ourStaticPublicKey,241                sendEncryptedJson = { plaintext -> ws.send(session.encrypt(plaintext)) },242                receiveEncryptedJson = {243                    session.decrypt(244                        (ws.receive() as? WsMessage.Binary)?.bytes245                            ?: throw IllegalStateException("peer closed"),246                    )247                },248            )249250            // Browser first, then us — matching the contract's ordering.251            val theirIdentity = parsePairIdentityFrame(252                session.decrypt((ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed")),253            )254            require(255                java.security.MessageDigest.isEqual(256                    authenticatedInitiatorKey,257                    hexToPubkey(theirIdentity.longTermPublicKeyHex),258                ),259            ) { "PAIR_IDENTITY key does not match proven initiator key" }260            ws.send(session.encrypt(encodePairIdentityFrame(localIdentity)))261262            val sas = SASDerivation.derive(session.handshakeHash)263            val device = Rendezvous.bindPairState(264                device = pairIdentityToPairedDevice(theirIdentity),265                handshakeHash = session.handshakeHash,266                // The device displaying the code chooses the mesh relay.267                relayUrl = localIdentity.relayUrl,268            )269            val pending = PairingSession()270            val name = device.deviceName?.takeIf { it.isNotBlank() } ?: "Unknown device"271272            // Hand the SAS to the UI and block here. Persisting before the user273            // has compared the digits would make the comparison meaningless.274            val confirmation = PairingEvent.AwaitingConfirmation(sas, name, pending)275            confirmationSink?.invoke(confirmation)276            if (!pending.await()) {277                ws.closeSilently()278                return@withContext PairingEvent.Rejected("sas_mismatch")279            }280281            deviceStore.upsert(device)282            PairingEvent.Paired(name)283        } catch (e: Exception) {284            ws.closeSilently()285            PairingEvent.Rejected(e.message ?: "pairing_failed")286        } finally {287            runCatching { client.close() }288        }289    }290291    private suspend fun handleAdoption(292        ws: WebSocketConnection,293        session: NoiseNKSession,294        localIdentity: LocalChirpIdentity,295        sourceDeps: AdoptionSourceDeps,296    ): PairingEvent {297        val channel = object : PairingTransport {298            override suspend fun send(msg: ByteArray) = ws.send(session.encrypt(msg))299            override suspend fun receive(): ByteArray = session.decrypt(300                (ws.receive() as? WsMessage.Binary)?.bytes301                    ?: throw IllegalStateException("peer closed"),302            )303            override suspend fun close() = ws.closeSilently()304        }305        val request = receiveFrame(channel)306        require(isAdoptRequest(request)) { "expected ADOPT_REQUEST" }307        request as AdoptionFrame.Request308309        val pending = PairingSession()310        val name = request.deviceName.ifBlank { "New device" }311        confirmationSink?.invoke(312            PairingEvent.AwaitingConfirmation(313                SASDerivation.derive(session.handshakeHash),314                name,315                pending,316            ),317        )318        if (!pending.await()) return PairingEvent.Rejected("sas_mismatch")319320        val pairState = AdoptionPairState(321            rendezvousSecretHex = Rendezvous.bytesToHex(322                Rendezvous.deriveSecret(session.handshakeHash),323            ),324            relayUrl = localIdentity.relayUrl,325        )326        val boundDeps = object : AdoptionSourceDeps {327            override val sourceIdentity = localIdentity328            override val pairState = pairState329            override suspend fun rootKeyHex() = sourceDeps.rootKeyHex()330            override suspend fun vaultId() = sourceDeps.vaultId()331            override suspend fun license() = sourceDeps.license()332            override suspend fun onConsent(request: AdoptionFrame.Request) = true333            override suspend fun upsertPairedDevice(device: PairedDevice) {334                sourceDeps.upsertPairedDevice(device)335            }336        }337        return if (grantAdoption(channel, request, boundDeps) != null) {338            PairingEvent.Paired(name)339        } else {340            PairingEvent.Rejected("adoption_rejected")341        }342    }343344    /**345     * Set by the ViewModel so the SAS reaches the UI while [handleClient] is346     * still blocked inside the connection.347     */348    var confirmationSink: ((PairingEvent.AwaitingConfirmation) -> Unit)? = null349350    private fun rejectSilently(ws: WebSocketConnection, reason: String): PairingEvent {351        ws.closeSilently()352        return PairingEvent.Rejected(reason)353    }354355    /** The token is low-value, but comparing it in constant time costs nothing. */356    private fun constantTimeEquals(a: String, b: String): Boolean {357        if (a.length != b.length) return false358        var diff = 0359        for (i in a.indices) diff = diff or (a[i].code xor b[i].code)360        return diff == 0361    }362363    /**364     * The address to put in the code — it has to be the one the browser can365     * actually dial, which is the Wi-Fi address.366     *367     * Interface order is not meaningful, so taking the first site-local IPv4368     * can hand back a VPN (tun/ppp) or tethering (ap/rndis) address. Both are369     * site-local and both produce a code that looks fine and then times out370     * with nothing to debug, so Wi-Fi-ish names are preferred explicitly and371     * tunnels are excluded rather than merely deprioritised.372     */373    internal fun localIpv4(): String? = runCatching {374        val candidates = NetworkInterface.getNetworkInterfaces().toList()375            .filter { it.isUp && !it.isLoopback && !it.isVirtual }376            .filterNot { iface -> TUNNEL_PREFIXES.any { iface.name.startsWith(it) } }377378        fun addressOf(iface: NetworkInterface): String? = iface.inetAddresses.toList()379            .filterIsInstance<Inet4Address>()380            .firstOrNull { it.isSiteLocalAddress }381            ?.hostAddress382383        candidates.firstOrNull { it.name.startsWith("wlan") }?.let(::addressOf)384            ?: candidates.firstNotNullOfOrNull(::addressOf)385    }.getOrNull()386}