894f871df762

add WebSocket pairing listener, Crockford pairing code, and SAS-gated code pairing screen

time
2026-07-24 23:07 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
code pairing listener
parent
578a68466d1c
13 files app/src/main/AndroidManifest.xml+9 −0app/src/main/java/com/peachpasswords/sync/CodePairingServer.kt+219 −0app/src/main/java/com/peachpasswords/sync/PairingCode.kt+122 −0app/src/main/java/com/peachpasswords/sync/PeachSyncService.kt+97 −0app/src/main/java/com/peachpasswords/sync/PeachWebSocket.kt+173 −0app/src/main/java/com/peachpasswords/ui/PeachAppNavHost.kt+2 −0app/src/main/java/com/peachpasswords/ui/PeachNav.kt+1 −0app/src/main/java/com/peachpasswords/ui/pairing/ChirpPairScreen.kt+1 −1app/src/main/java/com/peachpasswords/ui/pairing/CodePairScreen.kt+155 −0app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+92 −0app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+3 −3app/src/test/java/com/peachpasswords/sync/PairingCodeTest.kt+63 −0app/src/test/java/com/peachpasswords/sync/PeachWebSocketTest.kt+116 −0
app/src/main/AndroidManifest.xml+9 −0
@@ -12,6 +12,8 @@1212     <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />1313     <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />1414     <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />15+    <!-- Android 14+ requires the specific type alongside the generic permission. -->16+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />1517     <uses-permission android:name="android.permission.CAMERA" />1618     <uses-feature android:name="android.hardware.camera.any" android:required="false" />1719 @@ -73,6 +75,13 @@7375                 android:name="android.support.FILE_PROVIDER_PATHS"7476                 android:resource="@xml/file_paths" />7577         </provider>78+79+        <!-- Keeps the phone dialable for sync; a browser extension can never80+             listen, so this side has to stay reachable. -->81+        <service82+            android:name=".sync.PeachSyncService"83+            android:exported="false"84+            android:foregroundServiceType="dataSync" />7685 7786         <service7887             android:name=".autofill.PeachAutofillService"

view file

app/src/main/java/com/peachpasswords/sync/CodePairingServer.kt+219 −0
@@ -0,0 +1,219 @@1+package com.peachpasswords.sync2+3+import kotlinx.coroutines.CompletableDeferred4+import kotlinx.coroutines.CoroutineScope5+import kotlinx.coroutines.Dispatchers6+import kotlinx.coroutines.channels.awaitClose7+import kotlinx.coroutines.flow.Flow8+import kotlinx.coroutines.flow.callbackFlow9+import kotlinx.coroutines.isActive10+import kotlinx.coroutines.launch11+import kotlinx.coroutines.withContext12+import org.json.JSONObject13+import java.net.Inet4Address14+import java.net.NetworkInterface15+import java.net.ServerSocket16+import java.net.Socket17+import javax.inject.Inject18+import javax.inject.Singleton19+20+/**21+ * Code-based pairing, phone side. The phone listens and displays a code; the22+ * browser dials out and presents the token from it.23+ *24+ * Wire contract (peach-browser is the authority — `.agent/CONTINUITY.md`25+ * 2026-07-24T17:36Z, implemented there in `shared/pairing-dial.ts`):26+ *27+ *   1. browser dials `ws://<ip>:<port>` taken from the code28+ *   2. browser → phone, plaintext text frame: {type:"PEACH_HELLO", token:"<lowercase hex>"}29+ *   3. phone verifies the token; on mismatch it closes the socket with NO error30+ *      frame — that silence is the invalid-code signal31+ *   4. phone → browser, plaintext: {type:"PEACH_HELLO_ACK", ephemeralPublicKeyHex:"<64 hex>"}32+ *      (that key is the phone's Noise NK static `s`)33+ *   5. Noise NK, phone as RESPONDER, over binary frames34+ *   6. one encrypted PAIR_IDENTITY frame each way, BROWSER FIRST35+ *   7. both derive the same 6-digit SAS from the handshake hash, and BOTH gate36+ *      persistence on the user confirming it37+ *38+ * Step 7 is why [PairingSession] hands the SAS out and waits: the older39+ * [MdnsPairingServer] wrote the peer into [PairedDeviceStore] as soon as the40+ * handshake completed, before any human had compared anything, which makes the41+ * SAS decorative. Nothing is persisted here until [PairingSession.confirm] is42+ * called with true.43+ */44+sealed interface PairingEvent {45+    /** The listener is up; show [code] to the user. */46+    data class Listening(val code: String, val ip: String, val port: Int) : PairingEvent47+48+    /** A peer completed the handshake. Show [sas]; nothing is stored yet. */49+    data class AwaitingConfirmation(val sas: String, val deviceName: String, val session: PairingSession) : PairingEvent50+51+    data class Paired(val deviceName: String) : PairingEvent52+53+    /** Includes the silent-close case: a peer presented a bad token. */54+    data class Rejected(val reason: String) : PairingEvent55+}56+57+class PairingSession internal constructor() {58+    private val decision = CompletableDeferred<Boolean>()59+    fun confirm(matches: Boolean) { decision.complete(matches) }60+    internal suspend fun await(): Boolean = decision.await()61+}62+63+@Singleton64+class CodePairingServer @Inject constructor(65+    private val deviceStore: PairedDeviceStore,66+) {67+    private var serverSocket: ServerSocket? = null68+69+    /**70+     * Bind, publish a code, and accept pairing attempts until the flow is71+     * cancelled. One token per call: after a successful pairing the caller72+     * should stop this flow, which closes the pairing window while leaving any73+     * long-lived sync listener alone.74+     */75+    fun start(76+        scope: CoroutineScope,77+        ourStaticPrivateKey: ByteArray,78+        ourStaticPublicKey: ByteArray,79+        localIdentity: LocalChirpIdentity,80+    ): Flow<PairingEvent> = callbackFlow {81+        val ip = localIpv4()82+        if (ip == null) {83+            trySend(PairingEvent.Rejected("no_local_network"))84+            close()85+            return@callbackFlow86+        }87+88+        val socket = withContext(Dispatchers.IO) { ServerSocket(0) }89+        serverSocket = socket90+        val token = PairingCode.newToken()91+        val tokenHex = token.joinToString("") { "%02x".format(it) }92+93+        trySend(PairingEvent.Listening(PairingCode.encode(ip, socket.localPort, token), ip, socket.localPort))94+95+        val acceptJob = scope.launch(Dispatchers.IO) {96+            while (isActive && !socket.isClosed) {97+                val client = runCatching { socket.accept() }.getOrNull() ?: break98+                launch {99+                    trySend(100+                        handleClient(client, tokenHex, ourStaticPrivateKey, ourStaticPublicKey, localIdentity),101+                    )102+                }103+            }104+        }105+106+        awaitClose {107+            acceptJob.cancel()108+            stop()109+        }110+    }111+112+    fun stop() {113+        runCatching { serverSocket?.close() }114+        serverSocket = null115+    }116+117+    private suspend fun handleClient(118+        client: Socket,119+        expectedTokenHex: String,120+        ourStaticPrivateKey: ByteArray,121+        ourStaticPublicKey: ByteArray,122+        localIdentity: LocalChirpIdentity,123+    ): PairingEvent = withContext(Dispatchers.IO) {124+        val ws = WebSocketConnection(client, client.getInputStream(), client.getOutputStream())125+        try {126+            if (!ws.accept()) {127+                ws.closeSilently()128+                return@withContext PairingEvent.Rejected("not_a_websocket")129+            }130+131+            val hello = ws.receive() as? WsMessage.Text132+                ?: return@withContext rejectSilently(ws, "no_hello")133+            val helloJson = runCatching { JSONObject(hello.text) }.getOrNull()134+                ?: return@withContext rejectSilently(ws, "bad_hello")135+            if (helloJson.optString("type") != "PEACH_HELLO") {136+                return@withContext rejectSilently(ws, "bad_hello")137+            }138+            if (!constantTimeEquals(helloJson.optString("token"), expectedTokenHex)) {139+                return@withContext rejectSilently(ws, "bad_token")140+            }141+142+            ws.sendText(143+                JSONObject()144+                    .put("type", "PEACH_HELLO_ACK")145+                    .put("ephemeralPublicKeyHex", ourStaticPublicKey.joinToString("") { "%02x".format(it) })146+                    .toString(),147+            )148+149+            val session = noiseNKResponder(150+                ourStaticPrivateKey,151+                ourStaticPublicKey,152+                { frame -> ws.send(frame) },153+                { (ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed") },154+            )155+156+            // Browser first, then us — matching the contract's ordering.157+            val theirIdentity = parsePairIdentityFrame(158+                session.decrypt((ws.receive() as? WsMessage.Binary)?.bytes ?: throw IllegalStateException("peer closed")),159+            )160+            ws.send(session.encrypt(encodePairIdentityFrame(localIdentity)))161+162+            val sas = SASDerivation.derive(session.handshakeHash)163+            val device = pairIdentityToPairedDevice(theirIdentity)164+            val pending = PairingSession()165+            val name = device.deviceName?.takeIf { it.isNotBlank() } ?: "Unknown device"166+167+            // Hand the SAS to the UI and block here. Persisting before the user168+            // has compared the digits would make the comparison meaningless.169+            val confirmation = PairingEvent.AwaitingConfirmation(sas, name, pending)170+            confirmationSink?.invoke(confirmation)171+            if (!pending.await()) {172+                ws.closeSilently()173+                return@withContext PairingEvent.Rejected("sas_mismatch")174+            }175+176+            deviceStore.upsert(device)177+            PairingEvent.Paired(name)178+        } catch (e: Exception) {179+            ws.closeSilently()180+            PairingEvent.Rejected(e.message ?: "pairing_failed")181+        } finally {182+            runCatching { client.close() }183+        }184+    }185+186+    /**187+     * Set by the ViewModel so the SAS reaches the UI while [handleClient] is188+     * still blocked inside the connection.189+     */190+    var confirmationSink: ((PairingEvent.AwaitingConfirmation) -> Unit)? = null191+192+    private fun rejectSilently(ws: WebSocketConnection, reason: String): PairingEvent {193+        ws.closeSilently()194+        return PairingEvent.Rejected(reason)195+    }196+197+    /** The token is low-value, but comparing it in constant time costs nothing. */198+    private fun constantTimeEquals(a: String, b: String): Boolean {199+        if (a.length != b.length) return false200+        var diff = 0201+        for (i in a.indices) diff = diff or (a[i].code xor b[i].code)202+        return diff == 0203+    }204+205+    /**206+     * The address to put in the code. Picks a site-local IPv4 on an up,207+     * non-loopback interface — a VPN or tethering interface would produce a208+     * code the browser cannot dial.209+     */210+    private fun localIpv4(): String? =211+        runCatching {212+            NetworkInterface.getNetworkInterfaces().toList()213+                .filter { it.isUp && !it.isLoopback && !it.isVirtual }214+                .flatMap { it.inetAddresses.toList() }215+                .filterIsInstance<Inet4Address>()216+                .firstOrNull { it.isSiteLocalAddress }217+                ?.hostAddress218+        }.getOrNull()219+}

view file

app/src/main/java/com/peachpasswords/sync/PairingCode.kt+122 −0
@@ -0,0 +1,122 @@1+package com.peachpasswords.sync2+3+import java.security.SecureRandom4+5+/**6+ * The compact join code this phone displays and the user types into the7+ * browser. Exact port of the browser's `shared/pairing-code.ts`, which is the8+ * authoritative definition of the wire format — the browser only ever decodes,9+ * the phone is the only side that generates.10+ *11+ * Payload: IPv4 (4 bytes) | port (2 bytes, big-endian) | token (>= 4 bytes),12+ * in Crockford's Base32, displayed in groups of four:13+ *14+ *     A74H-48J3-KI32-OEP415+ *16+ * Token length is deliberately open — everything after the port is token — so17+ * this side can pick anywhere in the 4–10 byte range with no coordinated change18+ * on the browser.19+ *20+ * SECURITY: the token travels in the clear in the hello frame and is NOT the21+ * security boundary; the user-compared SAS after the Noise NK handshake is. The22+ * token only stops an unrelated host on the same LAN from consuming the pairing23+ * window.24+ */25+object PairingCode {26+    private const val ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"27+    private const val IP_BYTES = 428+    private const val PORT_BYTES = 229+    private const val MIN_TOKEN_BYTES = 430+31+    /** 8 bytes — comfortably inside the browser's 4–10 range, 23 characters. */32+    const val TOKEN_BYTES = 833+34+    private val rng = SecureRandom()35+36+    data class Parsed(val ip: String, val port: Int, val tokenHex: String)37+38+    fun newToken(): ByteArray = ByteArray(TOKEN_BYTES).also(rng::nextBytes)39+40+    fun encode(ip: String, port: Int, token: ByteArray): String {41+        val octets = ip.split(".")42+        require(octets.size == IP_BYTES) { "ip must be dotted-quad IPv4" }43+        require(port in 1..65535) { "invalid port: $port" }44+        require(token.size >= MIN_TOKEN_BYTES) { "token must be at least $MIN_TOKEN_BYTES bytes" }45+46+        val bytes = ByteArray(IP_BYTES + PORT_BYTES + token.size)47+        for (i in 0 until IP_BYTES) {48+            val n = octets[i].toIntOrNull()49+            require(n != null && n in 0..255) { "invalid IPv4 octet: ${octets[i]}" }50+            bytes[i] = n.toByte()51+        }52+        bytes[4] = ((port shr 8) and 0xff).toByte()53+        bytes[5] = (port and 0xff).toByte()54+        token.copyInto(bytes, IP_BYTES + PORT_BYTES)55+        return group(encodeCrockford(bytes))56+    }57+58+    /** Null for anything that is not a well-formed code, never an exception. */59+    fun parse(input: String): Parsed? {60+        val bytes = decodeCrockford(input) ?: return null61+        if (bytes.size < IP_BYTES + PORT_BYTES + MIN_TOKEN_BYTES) return null62+        val port = ((bytes[4].toInt() and 0xff) shl 8) or (bytes[5].toInt() and 0xff)63+        if (port == 0) return null64+        return Parsed(65+            ip = (0 until IP_BYTES).joinToString(".") { (bytes[it].toInt() and 0xff).toString() },66+            port = port,67+            tokenHex = bytes.copyOfRange(IP_BYTES + PORT_BYTES, bytes.size).toHex(),68+        )69+    }70+71+    fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }72+73+    private fun group(chars: String): String =74+        chars.chunked(4).joinToString("-")75+76+    private fun encodeCrockford(bytes: ByteArray): String {77+        val s = StringBuilder()78+        var acc = 079+        var bits = 080+        for (b in bytes) {81+            acc = (acc shl 8) or (b.toInt() and 0xff)82+            bits += 883+            while (bits >= 5) {84+                bits -= 585+                s.append(ALPHABET[(acc shr bits) and 0x1f])86+            }87+        }88+        if (bits > 0) s.append(ALPHABET[(acc shl (5 - bits)) and 0x1f])89+        return s.toString()90+    }91+92+    private fun decodeCrockford(input: String): ByteArray? {93+        // Crockford's alphabet omits I, L, O and U; decoding folds I/L -> 1 and94+        // O -> 0 so a code misread off a phone screen still resolves.95+        val clean = buildString {96+            for (c in input.uppercase()) when (c) {97+                ' ', '-', '\t', '\n' -> Unit98+                'I', 'L' -> append('1')99+                'O' -> append('0')100+                else -> append(c)101+            }102+        }103+        if (clean.isEmpty()) return null104+105+        // Trailing bits that don't complete a byte are padding and are dropped.106+        val out = ByteArray(clean.length * 5 / 8)107+        var acc = 0108+        var bits = 0109+        var o = 0110+        for (ch in clean) {111+            val v = ALPHABET.indexOf(ch)112+            if (v < 0) return null113+            acc = (acc shl 5) or v114+            bits += 5115+            if (bits >= 8) {116+                bits -= 8117+                out[o++] = ((acc shr bits) and 0xff).toByte()118+            }119+        }120+        return out121+    }122+}

view file

app/src/main/java/com/peachpasswords/sync/PeachSyncService.kt+97 −0
@@ -0,0 +1,97 @@1+package com.peachpasswords.sync2+3+import android.app.Notification4+import android.app.NotificationChannel5+import android.app.NotificationManager6+import android.app.PendingIntent7+import android.app.Service8+import android.content.Context9+import android.content.Intent10+import android.os.Build11+import android.os.IBinder12+import androidx.core.app.NotificationCompat13+import com.peachpasswords.MainActivity14+import com.peachpasswords.R15+import dagger.hilt.android.AndroidEntryPoint16+import kotlinx.coroutines.CoroutineScope17+import kotlinx.coroutines.SupervisorJob18+import kotlinx.coroutines.cancel19+20+/**21+ * Keeps the pairing/sync listener reachable.22+ *23+ * A browser extension can never listen for a connection, so the phone has to be24+ * the side that is dialable — and a phone that is only dialable while its app25+ * is foregrounded is not dialable in any useful sense. Doze and App Standby26+ * will take an ordinary background socket away. This is the same standard27+ * pattern Syncthing and Tailscale use for exactly the same reason: a foreground28+ * service with a visible, honest notification.29+ *30+ * The notification is deliberately plain about what it is. A persistent31+ * notification the user does not understand gets the app force-stopped, which32+ * is the one outcome that breaks sync entirely.33+ */34+@AndroidEntryPoint35+class PeachSyncService : Service() {36+37+    private val scope = CoroutineScope(SupervisorJob())38+39+    override fun onBind(intent: Intent?): IBinder? = null40+41+    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {42+        startForeground(NOTIFICATION_ID, buildNotification())43+        // START_STICKY: if the system reclaims us under pressure, come back —44+        // a listener that silently stays down looks identical to a peer that45+        // has simply gone offline, and the user gets no signal either way.46+        return START_STICKY47+    }48+49+    override fun onDestroy() {50+        scope.cancel()51+        super.onDestroy()52+    }53+54+    private fun buildNotification(): Notification {55+        val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager56+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {57+            manager.createNotificationChannel(58+                NotificationChannel(CHANNEL_ID, "Peach sync", NotificationManager.IMPORTANCE_LOW).apply {59+                    description = "Shown while Peach is reachable for syncing on your network."60+                    setShowBadge(false)61+                },62+            )63+        }64+        val open = PendingIntent.getActivity(65+            this,66+            0,67+            Intent(this, MainActivity::class.java),68+            PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,69+        )70+        return NotificationCompat.Builder(this, CHANNEL_ID)71+            .setContentTitle("Peach sync is active")72+            .setContentText("Your other devices can reach this phone on your network.")73+            .setSmallIcon(R.drawable.ic_launcher_monochrome)74+            .setOngoing(true)75+            .setPriority(NotificationCompat.PRIORITY_LOW)76+            .setContentIntent(open)77+            .build()78+    }79+80+    companion object {81+        private const val CHANNEL_ID = "peach_sync"82+        private const val NOTIFICATION_ID = 420183+84+        fun start(context: Context) {85+            val intent = Intent(context, PeachSyncService::class.java)86+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {87+                context.startForegroundService(intent)88+            } else {89+                context.startService(intent)90+            }91+        }92+93+        fun stop(context: Context) {94+            context.stopService(Intent(context, PeachSyncService::class.java))95+        }96+    }97+}

view file

app/src/main/java/com/peachpasswords/sync/PeachWebSocket.kt+173 −0
@@ -0,0 +1,173 @@1+package com.peachpasswords.sync2+3+import java.io.InputStream4+import java.io.OutputStream5+import java.net.Socket6+import java.security.MessageDigest7+import java.util.Base648+9+/**10+ * A minimal RFC 6455 server endpoint — just enough of the protocol to talk to a11+ * browser extension's `new WebSocket(...)`.12+ *13+ * Android already had a pairing listener ([MdnsPairingServer]), but it spoke14+ * raw length-prefixed TCP frames. A browser extension cannot open a raw socket;15+ * `ws://` is the only outbound connection it can make. Since the browser is16+ * always the dialer and the phone always the listener, that asymmetry is the17+ * whole reason this exists — so the phone has to be the one that speaks18+ * WebSocket, and this is the smallest thing that does.19+ *20+ * Deliberately not implemented, because the peer is one known client and not21+ * the open web: no extensions/compression negotiation, no subprotocols, no22+ * fragmentation (the browser sends each frame whole), and no client mode.23+ * Continuation frames are rejected rather than silently mishandled.24+ */25+private const val WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"26+27+sealed interface WsMessage {28+    data class Text(val text: String) : WsMessage29+    data class Binary(val bytes: ByteArray) : WsMessage30+    data object Closed : WsMessage31+}32+33+class WebSocketConnection(34+    private val socket: Socket,35+    private val input: InputStream,36+    private val output: OutputStream,37+) {38+    /**39+     * Complete the HTTP upgrade. Returns false if this is not a WebSocket40+     * handshake, in which case the caller should just drop the socket — an41+     * unexpected plain-HTTP probe on the LAN is not worth answering.42+     */43+    fun accept(): Boolean {44+        val request = readHttpHeaders() ?: return false45+        val key = request.entries.firstOrNull { it.key.equals("sec-websocket-key", true) }?.value46+            ?: return false47+        val accept = Base64.getEncoder().encodeToString(48+            MessageDigest.getInstance("SHA-1").digest((key + WS_GUID).toByteArray(Charsets.US_ASCII)),49+        )50+        output.write(51+            (52+                "HTTP/1.1 101 Switching Protocols\r\n" +53+                    "Upgrade: websocket\r\n" +54+                    "Connection: Upgrade\r\n" +55+                    "Sec-WebSocket-Accept: $accept\r\n\r\n"56+                ).toByteArray(Charsets.US_ASCII),57+        )58+        output.flush()59+        return true60+    }61+62+    fun sendText(text: String) = writeFrame(0x1, text.toByteArray(Charsets.UTF_8))63+64+    fun send(bytes: ByteArray) = writeFrame(0x2, bytes)65+66+    /** Blocks until a whole message arrives. [WsMessage.Closed] ends the conversation. */67+    fun receive(): WsMessage {68+        while (true) {69+            val b0 = input.read()70+            if (b0 < 0) return WsMessage.Closed71+            val fin = (b0 and 0x80) != 072+            when (val opcode = b0 and 0x0f) {73+                0x8 -> return WsMessage.Closed74+                0x9 -> { writeFrame(0xA, readPayload()); continue } // ping → pong75+                0xA -> { readPayload(); continue }                  // pong, ignored76+                0x1, 0x2 -> {77+                    if (!fin) return WsMessage.Closed78+                    val payload = readPayload()79+                    return if (opcode == 0x1) WsMessage.Text(payload.toString(Charsets.UTF_8))80+                    else WsMessage.Binary(payload)81+                }82+                else -> return WsMessage.Closed83+            }84+        }85+    }86+87+    /**88+     * Close without a status frame. The wire contract makes this silence the89+     * signal for a wrong or expired token — a phone that answered with an error90+     * would tell an unrelated LAN host that it is a Peach device holding an open91+     * pairing window.92+     */93+    fun closeSilently() {94+        runCatching { socket.close() }95+    }96+97+    private fun readHttpHeaders(): Map<String, String>? {98+        val head = StringBuilder()99+        // Read to the blank line, bounded so a non-HTTP peer can't stream forever.100+        while (head.length < 8192 && !head.endsWith("\r\n\r\n")) {101+            val c = input.read()102+            if (c < 0) return null103+            head.append(c.toChar())104+        }105+        val lines = head.toString().split("\r\n")106+        if (lines.firstOrNull()?.startsWith("GET ") != true) return null107+        return lines.drop(1)108+            .mapNotNull { line ->109+                val i = line.indexOf(':')110+                if (i <= 0) null else line.substring(0, i).trim() to line.substring(i + 1).trim()111+            }112+            .toMap()113+    }114+115+    private fun readPayload(): ByteArray {116+        val b1 = input.read()117+        if (b1 < 0) return ByteArray(0)118+        val masked = (b1 and 0x80) != 0119+        var len = (b1 and 0x7f).toLong()120+        when (len) {121+            126L -> len = ((readByte() shl 8) or readByte()).toLong()122+            127L -> {123+                len = 0124+                repeat(8) { len = (len shl 8) or readByte().toLong() }125+            }126+        }127+        // A vault sync frame is large but bounded; refuse an absurd length128+        // rather than trying to allocate it.129+        require(len in 0..(16L * 1024 * 1024)) { "ws frame too large: $len" }130+131+        val mask = if (masked) ByteArray(4) { readByte().toByte() } else null132+        val payload = ByteArray(len.toInt())133+        var read = 0134+        while (read < payload.size) {135+            val n = input.read(payload, read, payload.size - read)136+            if (n < 0) break137+            read += n138+        }139+        if (mask != null) {140+            for (i in payload.indices) payload[i] = (payload[i].toInt() xor mask[i % 4].toInt()).toByte()141+        }142+        return payload143+    }144+145+    private fun readByte(): Int {146+        val b = input.read()147+        if (b < 0) throw java.io.EOFException("websocket closed mid-frame")148+        return b149+    }150+151+    /** Server-to-client frames are never masked (RFC 6455 §5.1). */152+    private fun writeFrame(opcode: Int, payload: ByteArray) {153+        output.write(0x80 or opcode)154+        when {155+            payload.size < 126 -> output.write(payload.size)156+            payload.size <= 0xffff -> {157+                output.write(126)158+                output.write((payload.size shr 8) and 0xff)159+                output.write(payload.size and 0xff)160+            }161+            else -> {162+                output.write(127)163+                repeat(4) { output.write(0) }164+                output.write((payload.size shr 24) and 0xff)165+                output.write((payload.size shr 16) and 0xff)166+                output.write((payload.size shr 8) and 0xff)167+                output.write(payload.size and 0xff)168+            }169+        }170+        output.write(payload)171+        output.flush()172+    }173+}

view file

app/src/main/java/com/peachpasswords/ui/PeachAppNavHost.kt+2 −0
@@ -26,6 +26,7 @@2626 import com.peachpasswords.ui.lock.LockScreen2727 import com.peachpasswords.ui.onboarding.OnboardingScreen2828 import com.peachpasswords.ui.pairing.ChirpPairScreen29+import com.peachpasswords.ui.pairing.CodePairScreen2930 import com.peachpasswords.ui.recovery.RecoveryWordEntryScreen3031 import com.peachpasswords.ui.settings.PanicScreen3132 import com.peachpasswords.ui.settings.SettingsScreen@@ -230,6 +231,7 @@230231             )231232         }232233         composable(PeachNav.ChirpPair.route) { ChirpPairScreen(onBack = back) }234+        composable(PeachNav.CodePair.route) { CodePairScreen(onBack = back) }233235         composable(PeachNav.Health.route) {234236             VaultHealthScreen(onBack = back, onEditEntry = { nav(PeachNav.EntryDetail(it)) })235237         }

view file

app/src/main/java/com/peachpasswords/ui/PeachNav.kt+1 −0
@@ -40,6 +40,7 @@4040     data object CodexGenerate : PeachNav("codex/generate")4141     data object CodexRestore : PeachNav("codex/restore")4242     data object ChirpPair : PeachNav("chirp/pair")43+    data object CodePair : PeachNav("pair/code")4344     data object Health : PeachNav("health")4445     data object Trash : PeachNav("trash")4546     data object Import : PeachNav("import")

view file

app/src/main/java/com/peachpasswords/ui/pairing/ChirpPairScreen.kt+1 −1
@@ -196,7 +196,7 @@196196 }197197 198198 @Composable199-private fun PillButton(label: String, color: Color, filled: Boolean, onClick: () -> Unit) {199+internal fun PillButton(label: String, color: Color, filled: Boolean, onClick: () -> Unit) {200200     val accent = LocalPeachAccent.current201201     Box(202202         modifier = Modifier

view file

app/src/main/java/com/peachpasswords/ui/pairing/CodePairScreen.kt+155 −0
@@ -0,0 +1,155 @@1+package com.peachpasswords.ui.pairing2+3+import androidx.compose.foundation.layout.Arrangement4+import androidx.compose.foundation.layout.Box5+import androidx.compose.foundation.layout.Column6+import androidx.compose.foundation.layout.Spacer7+import androidx.compose.foundation.layout.fillMaxSize8+import androidx.compose.foundation.layout.fillMaxWidth9+import androidx.compose.foundation.layout.height10+import androidx.compose.foundation.layout.padding11+import androidx.compose.foundation.layout.systemBarsPadding12+import androidx.compose.material3.Text13+import androidx.compose.runtime.Composable14+import androidx.compose.runtime.LaunchedEffect15+import androidx.compose.runtime.getValue16+import androidx.compose.ui.Alignment17+import androidx.compose.ui.Modifier18+import androidx.compose.ui.text.font.FontStyle19+import androidx.compose.ui.text.style.TextAlign20+import androidx.compose.ui.unit.dp21+import androidx.compose.ui.unit.sp22+import androidx.hilt.navigation.compose.hiltViewModel23+import androidx.lifecycle.compose.collectAsStateWithLifecycle24+import com.peachpasswords.ui.components.BirdAnimation25+import com.peachpasswords.ui.components.PairingAnimState26+import com.peachpasswords.ui.theme.LocalPeachAccent27+import com.peachpasswords.ui.theme.PeachSpacing28+import com.peachpasswords.ui.theme.PeachType29+import com.peachpasswords.ui.theme.ShellBackground30+import com.peachpasswords.ui.theme.peachColors31+32+/**33+ * Pair a device by code. The phone shows the code; the browser types it in and34+ * dials. Mirrors the extension's own phase structure (enter → connecting → sas35+ * → paired), minus the entry field — the phone never types a code, it publishes36+ * one.37+ */38+@Composable39+fun CodePairScreen(40+    onBack: () -> Unit,41+    vm: CodePairViewModel = hiltViewModel(),42+) {43+    val state by vm.state.collectAsStateWithLifecycle()44+45+    LaunchedEffect(Unit) { vm.start() }46+47+    val animState = when (state) {48+        is CodePairUiState.Paired -> PairingAnimState.CONNECTED49+        is CodePairUiState.ConfirmSas -> PairingAnimState.LISTENING50+        else -> PairingAnimState.WAITING51+    }52+53+    ShellBackground {54+        Column(55+            modifier = Modifier56+                .fillMaxSize()57+                .systemBarsPadding()58+                .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),59+        ) {60+            Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {61+                BirdAnimation(animState, Modifier.fillMaxWidth().height(220.dp))62+            }63+64+            when (val s = state) {65+                is CodePairUiState.Starting -> Headline("Starting…", "Opening a pairing window on your network.")66+67+                is CodePairUiState.ShowingCode -> {68+                    Headline(69+                        "Type this on your computer",70+                        "Open Peach in your browser, choose “Pair a device”, and enter this code. " +71+                            "Both devices need to be on the same Wi-Fi.",72+                    )73+                    Spacer(Modifier.height(PeachSpacing.lg))74+                    Text(75+                        text = s.code,76+                        color = LocalPeachAccent.current,77+                        fontFamily = PeachType.Mono,78+                        fontSize = 26.sp,79+                        letterSpacing = 2.sp,80+                        textAlign = TextAlign.Center,81+                        modifier = Modifier.fillMaxWidth(),82+                    )83+                }84+85+                is CodePairUiState.ConfirmSas -> {86+                    Headline(87+                        "Verify the code",88+                        "Only continue if this code is identical on both screens. " +89+                            "This is what proves you're talking to your own device.",90+                    )91+                    Spacer(Modifier.height(PeachSpacing.lg))92+                    Text(93+                        text = s.sas,94+                        color = LocalPeachAccent.current,95+                        fontFamily = PeachType.Mono,96+                        fontSize = 38.sp,97+                        letterSpacing = 6.sp,98+                        textAlign = TextAlign.Center,99+                        modifier = Modifier.fillMaxWidth(),100+                    )101+                    Spacer(Modifier.height(PeachSpacing.lg))102+                    PillButton("They match — pair", LocalPeachAccent.current, filled = true) { vm.confirmSas(true) }103+                    Spacer(Modifier.height(PeachSpacing.sm))104+                    PillButton("Cancel", peachColors.T2, filled = false) { vm.confirmSas(false); onBack() }105+                }106+107+                is CodePairUiState.Paired -> {108+                    Headline("Paired", "${s.deviceName} will now keep your vault in step over the local network.")109+                    Spacer(Modifier.height(PeachSpacing.lg))110+                    PillButton("Done", LocalPeachAccent.current, filled = true, onClick = onBack)111+                }112+113+                is CodePairUiState.Failed -> {114+                    Headline("Couldn't pair", failureCopy(s.reason))115+                    Spacer(Modifier.height(PeachSpacing.lg))116+                    PillButton("Back", LocalPeachAccent.current, filled = true, onClick = onBack)117+                }118+            }119+            Spacer(Modifier.height(PeachSpacing.md))120+        }121+    }122+}123+124+private fun failureCopy(reason: String): String = when (reason) {125+    "vault_locked" -> "Unlock your vault first — pairing shares the key that protects it."126+    "no_local_network" -> "This phone isn't on a Wi-Fi network, so there's no address to hand out."127+    "sas_mismatch" -> "The codes didn't match, so nothing was saved. That's the right outcome if you didn't expect a connection."128+    else -> "Something interrupted the connection. Try again."129+}130+131+@Composable132+private fun Headline(title: String, body: String) {133+    Column(134+        modifier = Modifier.fillMaxWidth(),135+        horizontalAlignment = Alignment.CenterHorizontally,136+        verticalArrangement = Arrangement.Center,137+    ) {138+        Text(139+            title,140+            color = peachColors.T0,141+            fontFamily = PeachType.Display,142+            fontStyle = FontStyle.Italic,143+            fontSize = 22.sp,144+            textAlign = TextAlign.Center,145+        )146+        Spacer(Modifier.height(PeachSpacing.xs))147+        Text(148+            body,149+            color = peachColors.T2,150+            fontFamily = PeachType.Body,151+            fontSize = 13.sp,152+            textAlign = TextAlign.Center,153+        )154+    }155+}

view file

app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+92 −0
@@ -0,0 +1,92 @@1+package com.peachpasswords.ui.pairing2+3+import android.content.Context4+import androidx.lifecycle.ViewModel5+import androidx.lifecycle.viewModelScope6+import com.peachpasswords.sync.CodePairingServer7+import com.peachpasswords.sync.PairingEvent8+import com.peachpasswords.sync.PairingSession9+import com.peachpasswords.sync.PeachSyncService10+import com.peachpasswords.sync.buildLocalIdentity11+import com.peachpasswords.sync.deriveLocalSyncKeypair12+import com.peachpasswords.sync.getOrCreateDeviceId13+import com.peachpasswords.vault.VaultCrypto14+import com.peachpasswords.vault.VaultRepository15+import dagger.hilt.android.lifecycle.HiltViewModel16+import dagger.hilt.android.qualifiers.ApplicationContext17+import kotlinx.coroutines.flow.MutableStateFlow18+import kotlinx.coroutines.flow.StateFlow19+import kotlinx.coroutines.flow.asStateFlow20+import kotlinx.coroutines.launch21+import javax.inject.Inject22+23+/**24+ * The phone half of code pairing: show a code, wait to be dialed.25+ *26+ * The browser drives the conversation from here on — it is always the dialer27+ * (see [CodePairingServer] for the wire contract), so there is nothing for this28+ * screen to do but publish the code and then present the SAS for comparison.29+ */30+sealed interface CodePairUiState {31+    data object Starting : CodePairUiState32+    data class ShowingCode(val code: String, val ip: String, val port: Int) : CodePairUiState33+    data class ConfirmSas(val sas: String, val deviceName: String, val session: PairingSession) : CodePairUiState34+    data class Paired(val deviceName: String) : CodePairUiState35+    data class Failed(val reason: String) : CodePairUiState36+}37+38+@HiltViewModel39+class CodePairViewModel @Inject constructor(40+    private val vault: VaultRepository,41+    private val server: CodePairingServer,42+    @ApplicationContext private val appContext: Context,43+) : ViewModel() {44+45+    private val _state = MutableStateFlow<CodePairUiState>(CodePairUiState.Starting)46+    val state: StateFlow<CodePairUiState> = _state.asStateFlow()47+48+    fun start() {49+        val master = vault.syncMasterKey() ?: run {50+            _state.value = CodePairUiState.Failed("vault_locked")51+            return52+        }53+        PeachSyncService.start(appContext)54+55+        val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))56+        val identity = buildLocalIdentity(appContext, master)57+        VaultCrypto.zero(master)58+59+        // The handler blocks inside the live connection while the user compares60+        // digits, so the SAS has to reach the UI out of band rather than as the61+        // connection's result.62+        server.confirmationSink = { pending ->63+            _state.value = CodePairUiState.ConfirmSas(pending.sas, pending.deviceName, pending.session)64+        }65+66+        viewModelScope.launch {67+            server.start(viewModelScope, keys.secretKey, keys.publicKey, identity).collect { event ->68+                _state.value = when (event) {69+                    is PairingEvent.Listening -> CodePairUiState.ShowingCode(event.code, event.ip, event.port)70+                    is PairingEvent.Paired -> CodePairUiState.Paired(event.deviceName)71+                    is PairingEvent.Rejected -> CodePairUiState.Failed(event.reason)72+                    // Delivered via confirmationSink; the flow only sees the outcome.73+                    is PairingEvent.AwaitingConfirmation -> _state.value74+                }75+            }76+        }77+    }78+79+    fun confirmSas(matches: Boolean) {80+        (_state.value as? CodePairUiState.ConfirmSas)?.session?.confirm(matches)81+    }82+83+    fun stop() {84+        server.confirmationSink = null85+        server.stop()86+    }87+88+    override fun onCleared() {89+        stop()90+        super.onCleared()91+    }92+}

view file

app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+3 −3
@@ -591,7 +591,7 @@591591                 Text("Peach Sync", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp); ProBadge(fg)592592             }593593             Text(594-                "Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.",594+                "Pair devices with a code over your local network and keep encrypted vault blobs in step without a central account.",595595                 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,596596                 modifier = Modifier.padding(top = 2.dp),597597             )@@ -607,12 +607,12 @@607607         Column(Modifier.weight(1f)) {608608             Text("Pair new device", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)609609             Text(610-                "Add a phone, another browser, or your desktop sync helper.",610+                "This phone shows a code; type it into Peach in your browser.",611611                 color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,612612                 modifier = Modifier.padding(top = 2.dp),613613             )614614         }615-        SubtleButton("pair", fg) { onNavigate(PeachNav.ChirpPair) }615+        SubtleButton("pair", fg) { onNavigate(PeachNav.CodePair) }616616     }617617 618618     SubLabel("PAIRED DEVICES", fg)

view file

app/src/test/java/com/peachpasswords/sync/PairingCodeTest.kt+63 −0
@@ -0,0 +1,63 @@1+package com.peachpasswords.sync2+3+import org.junit.Assert.assertEquals4+import org.junit.Assert.assertNull5+import org.junit.Test6+7+/**8+ * The phone is the only side that generates pairing codes and the browser is9+ * the only side that reads them, so a drift here is invisible until a real10+ * pairing fails with nothing to look at. The golden vector below was produced11+ * by running the browser's own `shared/pairing-code.ts` — not derived from this12+ * implementation — so it fails if either side moves.13+ */14+class PairingCodeTest {15+16+    // encodePairingCode("192.168.7.115", 49142, deadbeef01234567) in peach-browser.17+    private val vectorCode = "R2M0-EWXZ-YVFA-VFQF-04HM-ASR"18+    private val vectorToken = byteArrayOf(19+        0xde.toByte(), 0xad.toByte(), 0xbe.toByte(), 0xef.toByte(), 0x01, 0x23, 0x45, 0x67,20+    )21+22+    @Test fun `encodes the browser's golden vector`() {23+        assertEquals(vectorCode, PairingCode.encode("192.168.7.115", 49142, vectorToken))24+    }25+26+    @Test fun `parses the browser's golden vector`() {27+        val parsed = PairingCode.parse(vectorCode)!!28+        assertEquals("192.168.7.115", parsed.ip)29+        assertEquals(49142, parsed.port)30+        assertEquals("deadbeef01234567", parsed.tokenHex)31+    }32+33+    @Test fun `round-trips a freshly generated token`() {34+        val token = PairingCode.newToken()35+        val parsed = PairingCode.parse(PairingCode.encode("10.0.0.7", 8765, token))!!36+        assertEquals("10.0.0.7", parsed.ip)37+        assertEquals(8765, parsed.port)38+        assertEquals(token.joinToString("") { "%02x".format(it) }, parsed.tokenHex)39+    }40+41+    @Test fun `folds the ambiguous glyphs a user might read off a screen`() {42+        // I/L -> 1 and O -> 0, so the doc's own example resolves rather than43+        // failing on characters Crockford's alphabet excludes.44+        val typed = PairingCode.parse("r2mO-ewxz-yvfa-vfqf-O4hm-asr")45+        assertEquals(PairingCode.parse(vectorCode), typed)46+    }47+48+    @Test fun `rejects input that is not a pairing code`() {49+        assertNull(PairingCode.parse(""))50+        assertNull(PairingCode.parse("418207"))          // the 6-digit SAS51+        assertNull(PairingCode.parse("not-a-code!!"))    // U is not in the alphabet52+    }53+54+    @Test(expected = IllegalArgumentException::class)55+    fun `refuses to encode a zero port`() {56+        PairingCode.encode("10.0.0.1", 0, PairingCode.newToken())57+    }58+59+    @Test(expected = IllegalArgumentException::class)60+    fun `refuses a token below the browser's four-byte floor`() {61+        PairingCode.encode("10.0.0.1", 8765, byteArrayOf(1, 2, 3))62+    }63+}

view file

app/src/test/java/com/peachpasswords/sync/PeachWebSocketTest.kt+116 −0
@@ -0,0 +1,116 @@1+package com.peachpasswords.sync2+3+import org.junit.Assert.assertArrayEquals4+import org.junit.Assert.assertEquals5+import org.junit.Assert.assertTrue6+import org.junit.Test7+import okhttp3.OkHttpClient8+import okhttp3.Request9+import okhttp3.Response10+import okhttp3.WebSocket11+import okhttp3.WebSocketListener12+import okio.ByteString13+import okio.ByteString.Companion.toByteString14+import java.net.ServerSocket15+import java.util.concurrent.CompletableFuture16+import java.util.concurrent.LinkedBlockingQueue17+import java.util.concurrent.TimeUnit18+import kotlin.concurrent.thread19+20+/**21+ * Exercises the server against OkHttp's RFC 6455 client rather than a22+ * hand-rolled one. The whole point of [WebSocketConnection] is that a browser23+ * extension can talk to it, so testing it with an independent implementation of24+ * the same spec is the closest thing to that available off device — a client25+ * that shared my own misreading of the framing rules would prove nothing.26+ */27+class PeachWebSocketTest {28+29+    private class Received {30+        val text = LinkedBlockingQueue<String>()31+        val binary = LinkedBlockingQueue<ByteArray>()32+        val opened = CompletableFuture<Boolean>()33+    }34+35+    private fun listener(into: Received) = object : WebSocketListener() {36+        override fun onMessage(webSocket: WebSocket, text: String) { into.text.add(text) }37+        override fun onMessage(webSocket: WebSocket, bytes: ByteString) { into.binary.add(bytes.toByteArray()) }38+        override fun onOpen(webSocket: WebSocket, response: Response) { into.opened.complete(true) }39+    }40+41+    /** Server side: accept one client, upgrade, then echo whatever arrives. */42+    private fun echoServer(port: ServerSocket, onAccepted: (Boolean) -> Unit = {}) = thread {43+        val client = port.accept()44+        val ws = WebSocketConnection(client, client.getInputStream(), client.getOutputStream())45+        val upgraded = ws.accept()46+        onAccepted(upgraded)47+        if (!upgraded) return@thread48+        while (true) {49+            when (val msg = ws.receive()) {50+                is WsMessage.Text -> ws.sendText(msg.text)51+                is WsMessage.Binary -> ws.send(msg.bytes)52+                WsMessage.Closed -> return@thread53+            }54+        }55+    }56+57+    private fun connect(port: Int, into: Received): WebSocket {58+        val ws = OkHttpClient.Builder()59+            .readTimeout(10, TimeUnit.SECONDS)60+            .build()61+            .newWebSocket(Request.Builder().url("ws://127.0.0.1:$port").build(), listener(into))62+        into.opened.get(5, TimeUnit.SECONDS)63+        return ws64+    }65+66+    @Test fun `completes the upgrade handshake with a real client`() {67+        ServerSocket(0).use { server ->68+            val accepted = CompletableFuture<Boolean>()69+            echoServer(server) { accepted.complete(it) }70+            val ws = connect(server.localPort, Received())71+            assertTrue("server should accept the upgrade", accepted.get(5, TimeUnit.SECONDS))72+            ws.cancel()73+        }74+    }75+76+    @Test fun `round-trips the plaintext hello frame`() {77+        ServerSocket(0).use { server ->78+            echoServer(server)79+            val got = Received()80+            val ws = connect(server.localPort, got)81+            val hello = """{"type":"PEACH_HELLO","token":"deadbeef01234567"}"""82+            ws.send(hello)83+            assertEquals(hello, got.text.poll(5, TimeUnit.SECONDS))84+            ws.cancel()85+        }86+    }87+88+    @Test fun `round-trips binary frames across the length-encoding boundaries`() {89+        // 125 is the last 7-bit length, 126 starts the 16-bit header, and 7000090+        // crosses into the 64-bit one — a Noise-wrapped vault frame lands there.91+        ServerSocket(0).use { server ->92+            echoServer(server)93+            val got = Received()94+            val ws = connect(server.localPort, got)95+            for (size in intArrayOf(0, 1, 125, 126, 8192, 70_000)) {96+                val payload = ByteArray(size) { (it % 251).toByte() }97+                ws.send(payload.toByteString())98+                val echoed = got.binary.poll(10, TimeUnit.SECONDS)99+                assertArrayEquals("size $size did not survive the round trip", payload, echoed)100+            }101+            ws.cancel()102+        }103+    }104+105+    @Test fun `a plain HTTP request is refused rather than upgraded`() {106+        ServerSocket(0).use { server ->107+            val accepted = CompletableFuture<Boolean>()108+            echoServer(server) { accepted.complete(it) }109+            java.net.Socket("127.0.0.1", server.localPort).use { s ->110+                s.getOutputStream().write("GET / HTTP/1.1\r\nHost: x\r\n\r\n".toByteArray())111+                s.getOutputStream().flush()112+                assertTrue("no Sec-WebSocket-Key means no upgrade", !accepted.get(5, TimeUnit.SECONDS))113+            }114+        }115+    }116+}

view file