CodePairingServer.kt

10.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 text 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: {type:"PEACH_HELLO_ACK", ephemeralPublicKeyHex:"<64 hex>"}33 *      (that key is the phone's Noise NK static `s`)34 *   5. Noise NK, phone as RESPONDER, over binary frames35 *   6. one encrypted PAIR_IDENTITY frame each way, BROWSER FIRST36 *   7. both derive the same 6-digit SAS from the handshake hash, and BOTH gate37 *      persistence on the user confirming it38 *39 * Step 7 is why [PairingSession] hands the SAS out and waits: the older40 * [MdnsPairingServer] wrote the peer into [PairedDeviceStore] as soon as the41 * handshake completed, before any human had compared anything, which makes the42 * SAS decorative. Nothing is persisted here until [PairingSession.confirm] is43 * called with true.44 */45sealed interface PairingEvent {46    /** The listener is up; show [code] to the user. */47    data class Listening(val code: String, val ip: String, val port: Int) : PairingEvent4849    /** A peer completed the handshake. Show [sas]; nothing is stored yet. */50    data class AwaitingConfirmation(val sas: String, val deviceName: String, val session: PairingSession) : PairingEvent5152    data class Paired(val deviceName: String) : PairingEvent5354    /** The code's window elapsed without a pairing; the user must start again. */55    data object Expired : PairingEvent5657    /** Includes the silent-close case: a peer presented a bad token. */58    data class Rejected(val reason: String) : PairingEvent59}6061class PairingSession internal constructor() {62    private val decision = CompletableDeferred<Boolean>()63    fun confirm(matches: Boolean) { decision.complete(matches) }64    internal suspend fun await(): Boolean = decision.await()65}6667@Singleton68class CodePairingServer @Inject constructor(69    // The interface, not the concrete store: the store needs Android's70    // EncryptedSharedPreferences, which would make this whole class untestable71    // off device — and the token/window logic is exactly what wants testing.72    private val deviceStore: PairedDeviceRegistry,73) {74    private var serverSocket: ServerSocket? = null7576    companion object {77        /** Doc says 5–10 minutes; the low end is plenty to walk to a laptop. */78        const val PAIRING_WINDOW_MS = 5L * 60 * 10007980        /** Interfaces whose address a LAN peer cannot route back to. */81        private val TUNNEL_PREFIXES = listOf("tun", "ppp", "ipsec", "rmnet", "rndis", "ap")82    }8384    /**85     * Bind, publish a code, and accept pairing attempts until the flow is86     * cancelled. One token per call: after a successful pairing the caller87     * should stop this flow, which closes the pairing window while leaving any88     * long-lived sync listener alone.89     */90    fun start(91        scope: CoroutineScope,92        ourStaticPrivateKey: ByteArray,93        ourStaticPublicKey: ByteArray,94        localIdentity: LocalChirpIdentity,95        windowMs: Long = PAIRING_WINDOW_MS,96    ): Flow<PairingEvent> = callbackFlow {97        val ip = localIpv4()98        if (ip == null) {99            trySend(PairingEvent.Rejected("no_local_network"))100            close()101            return@callbackFlow102        }103104        val socket = withContext(Dispatchers.IO) { ServerSocket(0) }105        serverSocket = socket106        val token = PairingCode.newToken()107        val tokenHex = token.joinToString("") { "%02x".format(it) }108109        trySend(PairingEvent.Listening(PairingCode.encode(ip, socket.localPort, token), ip, socket.localPort))110111        // The window is not a race against an attacker — there isn't a112        // realistic one on your own LAN for a single-use code. It just stops an113        // abandoned code from staying live indefinitely on a phone in a pocket.114        val expiryJob = scope.launch {115            delay(windowMs)116            trySend(PairingEvent.Expired)117            stop()118        }119120        val acceptJob = scope.launch(Dispatchers.IO) {121            while (isActive && !socket.isClosed) {122                val client = runCatching { socket.accept() }.getOrNull() ?: break123                launch {124                    val event = handleClient(client, tokenHex, ourStaticPrivateKey, ourStaticPublicKey, localIdentity)125                    trySend(event)126                    // One code, one device. A rejected or failed attempt leaves127                    // the window open so the user can retry without generating128                    // a new code; only a real pairing closes it.129                    if (event is PairingEvent.Paired) {130                        expiryJob.cancel()131                        stop()132                    }133                }134            }135        }136137        awaitClose {138            expiryJob.cancel()139            acceptJob.cancel()140            stop()141        }142    }143144    fun stop() {145        runCatching { serverSocket?.close() }146        serverSocket = null147    }148149    private suspend fun handleClient(150        client: Socket,151        expectedTokenHex: String,152        ourStaticPrivateKey: ByteArray,153        ourStaticPublicKey: ByteArray,154        localIdentity: LocalChirpIdentity,155    ): PairingEvent = withContext(Dispatchers.IO) {156        val ws = WebSocketConnection(client, client.getInputStream(), client.getOutputStream())157        try {158            if (!ws.accept()) {159                ws.closeSilently()160                return@withContext PairingEvent.Rejected("not_a_websocket")161            }162163            val hello = ws.receive() as? WsMessage.Text164                ?: return@withContext rejectSilently(ws, "no_hello")165            val helloJson = runCatching { JSONObject(hello.text) }.getOrNull()166                ?: return@withContext rejectSilently(ws, "bad_hello")167            if (helloJson.optString("type") != "PEACH_HELLO") {168                return@withContext rejectSilently(ws, "bad_hello")169            }170            if (!constantTimeEquals(helloJson.optString("token"), expectedTokenHex)) {171                return@withContext rejectSilently(ws, "bad_token")172            }173174            ws.sendText(175                JSONObject()176                    .put("type", "PEACH_HELLO_ACK")177                    .put("ephemeralPublicKeyHex", ourStaticPublicKey.joinToString("") { "%02x".format(it) })178                    .toString(),179            )180181            val session = noiseNKResponder(182                ourStaticPrivateKey,183                ourStaticPublicKey,184                { frame -> ws.send(frame) },185                { (ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed") },186            )187188            // Browser first, then us — matching the contract's ordering.189            val theirIdentity = parsePairIdentityFrame(190                session.decrypt((ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed")),191            )192            ws.send(session.encrypt(encodePairIdentityFrame(localIdentity)))193194            val sas = SASDerivation.derive(session.handshakeHash)195            val device = pairIdentityToPairedDevice(theirIdentity)196            val pending = PairingSession()197            val name = device.deviceName?.takeIf { it.isNotBlank() } ?: "Unknown device"198199            // Hand the SAS to the UI and block here. Persisting before the user200            // has compared the digits would make the comparison meaningless.201            val confirmation = PairingEvent.AwaitingConfirmation(sas, name, pending)202            confirmationSink?.invoke(confirmation)203            if (!pending.await()) {204                ws.closeSilently()205                return@withContext PairingEvent.Rejected("sas_mismatch")206            }207208            deviceStore.upsert(device)209            PairingEvent.Paired(name)210        } catch (e: Exception) {211            ws.closeSilently()212            PairingEvent.Rejected(e.message ?: "pairing_failed")213        } finally {214            runCatching { client.close() }215        }216    }217218    /**219     * Set by the ViewModel so the SAS reaches the UI while [handleClient] is220     * still blocked inside the connection.221     */222    var confirmationSink: ((PairingEvent.AwaitingConfirmation) -> Unit)? = null223224    private fun rejectSilently(ws: WebSocketConnection, reason: String): PairingEvent {225        ws.closeSilently()226        return PairingEvent.Rejected(reason)227    }228229    /** The token is low-value, but comparing it in constant time costs nothing. */230    private fun constantTimeEquals(a: String, b: String): Boolean {231        if (a.length != b.length) return false232        var diff = 0233        for (i in a.indices) diff = diff or (a[i].code xor b[i].code)234        return diff == 0235    }236237    /**238     * The address to put in the code — it has to be the one the browser can239     * actually dial, which is the Wi-Fi address.240     *241     * Interface order is not meaningful, so taking the first site-local IPv4242     * can hand back a VPN (tun/ppp) or tethering (ap/rndis) address. Both are243     * site-local and both produce a code that looks fine and then times out244     * with nothing to debug, so Wi-Fi-ish names are preferred explicitly and245     * tunnels are excluded rather than merely deprioritised.246     */247    internal fun localIpv4(): String? = runCatching {248        val candidates = NetworkInterface.getNetworkInterfaces().toList()249            .filter { it.isUp && !it.isLoopback && !it.isVirtual }250            .filterNot { iface -> TUNNEL_PREFIXES.any { iface.name.startsWith(it) } }251252        fun addressOf(iface: NetworkInterface): String? = iface.inetAddresses.toList()253            .filterIsInstance<Inet4Address>()254            .firstOrNull { it.isSiteLocalAddress }255            ?.hostAddress256257        candidates.firstOrNull { it.name.startsWith("wlan") }?.let(::addressOf)258            ?: candidates.firstNotNullOfOrNull(::addressOf)259    }.getOrNull()260}