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
↓ peach-android-894f871d.tar.gzatlas at this save
files changed13 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 −0app/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"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+}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+}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+}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+}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 }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")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 = Modifierapp/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+}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+}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)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+}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+}