49472b1df6f3

accept a binary PEACH_HELLO and answer with a binary ACK, which is what the browser's pairing socket speaks

time
2026-07-25 02:55 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
pairing frame type
parent
dd4d47fe6cff
3 files app/src/main/java/com/peachpasswords/sync/CodePairingServer.kt+27 −7app/src/test/java/com/peachpasswords/sync/CodePairingServerTest.kt+34 −0kepr.build+4 −0
app/src/main/java/com/peachpasswords/sync/CodePairingServer.kt+27 −7
@@ -26,11 +26,16 @@2626  * 2026-07-24T17:36Z, implemented there in `shared/pairing-dial.ts`):2727  *2828  *   1. browser dials `ws://<ip>:<port>` taken from the code29- *   2. browser → phone, plaintext text frame: {type:"PEACH_HELLO", token:"<lowercase hex>"}29+ *   2. browser → phone, plaintext BINARY frame: {type:"PEACH_HELLO", token:"<lowercase hex>"}3030  *   3. phone verifies the token; on mismatch it closes the socket with NO error3131  *      frame — that silence is the invalid-code signal32- *   4. phone → browser, plaintext: {type:"PEACH_HELLO_ACK", ephemeralPublicKeyHex:"<64 hex>"}32+ *   4. phone → browser, plaintext BINARY: {type:"PEACH_HELLO_ACK", ephemeralPublicKeyHex:"<64 hex>"}3333  *      (that key is the phone's Noise NK static `s`)34+ *35+ * Every frame in this exchange is binary, including the two JSON ones. This36+ * document said "text" until 2026-07-24 and the code followed it; the browser's37+ * socket is binary end to end, so pairing failed for every correct code. Verify38+ * claims like this against `pairing-dial.ts`, not against this comment.3439  *   5. Noise NK, phone as RESPONDER, over binary frames3540  *   6. one encrypted PAIR_IDENTITY frame each way, BROWSER FIRST3641  *   7. both derive the same 6-digit SAS from the handshake hash, and BOTH gate@@ -160,9 +165,19 @@160165                 return@withContext PairingEvent.Rejected("not_a_websocket")161166             }162167 163-            val hello = ws.receive() as? WsMessage.Text164-                ?: return@withContext rejectSilently(ws, "no_hello")165-            val helloJson = runCatching { JSONObject(hello.text) }.getOrNull()168+            // The browser sends this as a BINARY frame — `openPairingSocket` in169+            // peach-browser's pairing-dial.ts is a binary seam end to end170+            // (`ws.binaryType = "arraybuffer"`, `send(bytes: Uint8Array)`).171+            // Requiring text here rejected every real dial and closed the socket172+            // without a word, which the browser reports as "invalid code" — so a173+            // perfectly good code always failed. Text is still accepted so any174+            // other caller keeps working.175+            val helloText = when (val frame = ws.receive()) {176+                is WsMessage.Binary -> String(frame.bytes, Charsets.UTF_8)177+                is WsMessage.Text -> frame.text178+                else -> return@withContext rejectSilently(ws, "no_hello")179+            }180+            val helloJson = runCatching { JSONObject(helloText) }.getOrNull()166181                 ?: return@withContext rejectSilently(ws, "bad_hello")167182             if (helloJson.optString("type") != "PEACH_HELLO") {168183                 return@withContext rejectSilently(ws, "bad_hello")@@ -171,11 +186,16 @@171186                 return@withContext rejectSilently(ws, "bad_token")172187             }173188 174-            ws.sendText(189+            // Binary, for the same reason: the browser reads every frame as190+            // `new Uint8Array(ev.data as ArrayBuffer)`. A text frame hands it a191+            // string, which converts to a ZERO-LENGTH array rather than192+            // throwing — and a zero-length ACK is also read as an invalid code.193+            ws.send(175194                 JSONObject()176195                     .put("type", "PEACH_HELLO_ACK")177196                     .put("ephemeralPublicKeyHex", ourStaticPublicKey.joinToString("") { "%02x".format(it) })178-                    .toString(),197+                    .toString()198+                    .toByteArray(Charsets.UTF_8),179199             )180200 181201             val session = noiseNKResponder(

view file

app/src/test/java/com/peachpasswords/sync/CodePairingServerTest.kt+34 −0
@@ -10,6 +10,7 @@1010 import okhttp3.Response1111 import okhttp3.WebSocket1212 import okhttp3.WebSocketListener13+import okio.ByteString.Companion.toByteString1314 import org.junit.Assert.assertEquals1415 import org.junit.Assert.assertTrue1516 import org.junit.Test@@ -47,6 +48,7 @@4748         val opened = CompletableFuture<Boolean>()4849         val closed = CompletableFuture<Boolean>()4950         val frames = java.util.concurrent.LinkedBlockingQueue<String>()51+        val binaryFrames = java.util.concurrent.LinkedBlockingQueue<ByteArray>()5052         lateinit var ws: WebSocket5153 5254         fun connect(port: Int) {@@ -55,6 +57,7 @@5557                 object : WebSocketListener() {5658                     override fun onOpen(webSocket: WebSocket, response: Response) { opened.complete(true) }5759                     override fun onMessage(webSocket: WebSocket, text: String) { frames.add(text) }60+                    override fun onMessage(webSocket: WebSocket, bytes: okio.ByteString) { binaryFrames.add(bytes.toByteArray()) }5861                     override fun onFailure(webSocket: WebSocket, t: Throwable, r: Response?) { closed.complete(true) }5962                     override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { closed.complete(true) }6063                 },@@ -100,6 +103,37 @@100103 101104             assertTrue("socket should be closed on a bad token", c.closed.get(5, TimeUnit.SECONDS))102105             assertEquals("nothing should be sent back — silence is the signal", 0, c.frames.size)106+        } finally {107+            srv.stop()108+        }109+    }110+111+    /**112+     * The browser's pairing socket is binary end to end (`openPairingSocket` in113+     * peach-browser's pairing-dial.ts sets `binaryType = "arraybuffer"` and114+     * sends `Uint8Array`). This listener required a TEXT hello and answered with115+     * a TEXT ack, so every correct code was silently closed on and the browser116+     * reported "invalid code" — pairing could not succeed at all. Neither half117+     * fails loudly, which is exactly why both are pinned here.118+     */119+    @Test fun `a binary hello is accepted and answered with a binary ack`() = runBlocking {120+        val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)121+        val srv = server()122+        try {123+            val listening = scope.run(srv).await<PairingEvent.Listening>()124+            val token = PairingCode.parse(listening.code)!!.tokenHex125+126+            val c = Client()127+            c.connect(listening.port)128+            c.ws.send("""{"type":"PEACH_HELLO","token":"$token"}""".toByteArray().toByteString())129+130+            val ack = c.binaryFrames.poll(10, TimeUnit.SECONDS)131+            assertTrue("the phone must answer a binary hello, not close on it", ack != null)132+            assertTrue(133+                "the ack must be a BINARY frame — a text one decodes to zero bytes in the browser",134+                c.frames.isEmpty(),135+            )136+            assertTrue(String(ack!!), String(ack).contains("PEACH_HELLO_ACK"))103137         } finally {104138             srv.stop()105139         }

view file

kepr.build+4 −0
@@ -0,0 +1,4 @@1+# command = <your build command here>2+# output = <path to built binary>3+trigger = on-promote4+platforms = native

view file