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
↓ peach-android-49472b1d.tar.gzatlas at this save
files changed3 files
app/src/main/java/com/peachpasswords/sync/CodePairingServer.kt+27 −7app/src/test/java/com/peachpasswords/sync/CodePairingServerTest.kt+34 −0kepr.build+4 −0app/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(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 }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