CodePairingServer.kt

16.7 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 8-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        var noiseSession: NoiseNKSession? = null174        try {175            if (!ws.accept()) {176                ws.closeSilently()177                return@withContext PairingEvent.Rejected("not_a_websocket")178            }179180            // The browser sends this as a BINARY frame — `openPairingSocket` in181            // peach-browser's pairing-dial.ts is a binary seam end to end182            // (`ws.binaryType = "arraybuffer"`, `send(bytes: Uint8Array)`).183            // Requiring text here rejected every real dial and closed the socket184            // without a word, which the browser reports as "invalid code" — so a185            // perfectly good code always failed. Text is still accepted so any186            // other caller keeps working.187            val helloText = when (val frame = ws.receive()) {188                is WsMessage.Binary -> String(frame.bytes, Charsets.UTF_8)189                is WsMessage.Text -> frame.text190                else -> return@withContext rejectSilently(ws, "no_hello")191            }192            val helloJson = runCatching { JSONObject(helloText) }.getOrNull()193                ?: return@withContext rejectSilently(ws, "bad_hello")194            if (helloJson.optString("type") != "PEACH_HELLO") {195                return@withContext rejectSilently(ws, "bad_hello")196            }197            if (!constantTimeEquals(helloJson.optString("token"), expectedTokenHex)) {198                return@withContext rejectSilently(ws, "bad_token")199            }200            val flow = helloJson.optString("flow").ifBlank { null }201            if (flow != null && flow != "adoption") {202                return@withContext rejectSilently(ws, "unsupported_flow")203            }204205            // Binary, for the same reason: the browser reads every frame as206            // `new Uint8Array(ev.data as ArrayBuffer)`. A text frame hands it a207            // string, which converts to a ZERO-LENGTH array rather than208            // throwing — and a zero-length ACK is also read as an invalid code.209            ws.send(210                JSONObject()211                    .put("type", "PEACH_HELLO_ACK")212                    .put("ephemeralPublicKeyHex", ourStaticPublicKey.joinToString("") { "%02x".format(it) })213                    .toString()214                    .toByteArray(Charsets.UTF_8),215            )216217            val session = noiseNKResponder(218                ourStaticPrivateKey,219                ourStaticPublicKey,220                { frame -> ws.send(frame) },221                { (ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed") },222            )223            noiseSession = session224225            if (flow == "adoption") {226                val deps = adoptionSourceDeps227                    ?: return@withContext rejectSilently(ws, "adoption_unavailable")228                return@withContext handleAdoption(229                    ws,230                    session,231                    localIdentity,232                    deps,233                )234            }235236            // E1: the initiator proves it holds the private half of the key it237            // will advertise. Bind that proof to this NK transcript before any238            // identity frame is accepted or SAS consent is requested.239            val authenticatedInitiatorKey = performResponderPairingAuth(240                session = session,241                ourPrivateKey = ourStaticPrivateKey,242                ourPublicKey = ourStaticPublicKey,243                sendEncryptedJson = { plaintext -> ws.send(session.encrypt(plaintext)) },244                receiveEncryptedJson = {245                    session.decrypt(246                        (ws.receive() as? WsMessage.Binary)?.bytes247                            ?: throw IllegalStateException("peer closed"),248                    )249                },250            )251252            // Browser first, then us — matching the contract's ordering.253            val theirIdentity = parsePairIdentityFrame(254                session.decrypt((ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed")),255            )256            require(257                java.security.MessageDigest.isEqual(258                    authenticatedInitiatorKey,259                    hexToPubkey(theirIdentity.longTermPublicKeyHex),260                ),261            ) { "PAIR_IDENTITY key does not match proven initiator key" }262            ws.send(session.encrypt(encodePairIdentityFrame(localIdentity)))263264            val sas = SASDerivation.derive(session.handshakeHash)265            val device = Rendezvous.bindPairState(266                device = pairIdentityToPairedDevice(theirIdentity),267                handshakeHash = session.handshakeHash,268                // The device displaying the code chooses the mesh relay.269                relayUrl = localIdentity.relayUrl,270            )271            val pending = PairingSession()272            val name = device.deviceName?.takeIf { it.isNotBlank() } ?: "Unknown device"273274            // Hand the SAS to the UI and block here. Persisting before the user275            // has compared the digits would make the comparison meaningless.276            val confirmation = PairingEvent.AwaitingConfirmation(sas, name, pending)277            confirmationSink?.invoke(confirmation)278            if (!pending.await()) {279                ws.closeSilently()280                return@withContext PairingEvent.Rejected("sas_mismatch")281            }282283            // A direct code pairing means a physically present user confirmed284            // the SAS on both screens — the only thing allowed to lift a285            // terminal revocation (see PairedDeviceStore.revoke).286            deviceStore.clearRevocation(device.deviceId)287            deviceStore.upsert(device)288            PairingEvent.Paired(name)289        } catch (e: Exception) {290            ws.closeSilently()291            PairingEvent.Rejected(e.message ?: "pairing_failed")292        } finally {293            noiseSession?.close()294            runCatching { client.close() }295        }296    }297298    private suspend fun handleAdoption(299        ws: WebSocketConnection,300        session: NoiseNKSession,301        localIdentity: LocalChirpIdentity,302        sourceDeps: AdoptionSourceDeps,303    ): PairingEvent {304        val channel = object : PairingTransport {305            override suspend fun send(msg: ByteArray) = ws.send(session.encrypt(msg))306            override suspend fun receive(): ByteArray = session.decrypt(307                (ws.receive() as? WsMessage.Binary)?.bytes308                    ?: throw IllegalStateException("peer closed"),309            )310            override suspend fun close() = ws.closeSilently()311        }312        val request = receiveFrame(channel)313        require(isAdoptRequest(request)) { "expected ADOPT_REQUEST" }314        request as AdoptionFrame.Request315316        val pending = PairingSession()317        val name = request.deviceName.ifBlank { "New device" }318        confirmationSink?.invoke(319            PairingEvent.AwaitingConfirmation(320                SASDerivation.derive(session.handshakeHash),321                name,322                pending,323            ),324        )325        if (!pending.await()) return PairingEvent.Rejected("sas_mismatch")326327        val pairState = AdoptionPairState(328            rendezvousSecretHex = Rendezvous.bytesToHex(329                Rendezvous.deriveSecret(session.handshakeHash),330            ),331            relayUrl = localIdentity.relayUrl,332        )333        val boundDeps = object : AdoptionSourceDeps {334            override val sourceIdentity = localIdentity335            override val pairState = pairState336            override suspend fun rootKeyHex() = sourceDeps.rootKeyHex()337            override suspend fun vaultId() = sourceDeps.vaultId()338            override suspend fun license() = sourceDeps.license()339            override suspend fun onConsent(request: AdoptionFrame.Request) = true340            override suspend fun upsertPairedDevice(device: PairedDevice) {341                sourceDeps.upsertPairedDevice(device)342            }343        }344        return if (grantAdoption(channel, request, boundDeps) != null) {345            PairingEvent.Paired(name)346        } else {347            PairingEvent.Rejected("adoption_rejected")348        }349    }350351    /**352     * Set by the ViewModel so the SAS reaches the UI while [handleClient] is353     * still blocked inside the connection.354     */355    var confirmationSink: ((PairingEvent.AwaitingConfirmation) -> Unit)? = null356357    private fun rejectSilently(ws: WebSocketConnection, reason: String): PairingEvent {358        ws.closeSilently()359        return PairingEvent.Rejected(reason)360    }361362    /** The token is low-value, but comparing it in constant time costs nothing. */363    private fun constantTimeEquals(a: String, b: String): Boolean {364        return CryptoCompare.utf8(a, b)365    }366367    /**368     * The address to put in the code — it has to be the one the browser can369     * actually dial, which is the Wi-Fi address.370     *371     * Interface order is not meaningful, so taking the first site-local IPv4372     * can hand back a VPN (tun/ppp) or tethering (ap/rndis) address. Both are373     * site-local and both produce a code that looks fine and then times out374     * with nothing to debug, so Wi-Fi-ish names are preferred explicitly and375     * tunnels are excluded rather than merely deprioritised.376     */377    internal fun localIpv4(): String? = runCatching {378        val candidates = NetworkInterface.getNetworkInterfaces().toList()379            .filter { it.isUp && !it.isLoopback && !it.isVirtual }380            .filterNot { iface -> TUNNEL_PREFIXES.any { iface.name.startsWith(it) } }381382        fun addressOf(iface: NetworkInterface): String? = iface.inetAddresses.toList()383            .filterIsInstance<Inet4Address>()384            .firstOrNull { it.isSiteLocalAddress }385            ?.hostAddress386387        candidates.firstOrNull { it.name.startsWith("wlan") }?.let(::addressOf)388            ?: candidates.firstNotNullOfOrNull(::addressOf)389    }.getOrNull()390}