a4a291aa27a1

dial the address carried in a pairing code instead of probing for a host; hello presents the token as hex (v4.0.24)

time
2026-07-24 21:36 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
pairing code transport
parent
4022468191e3
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/desktop-pairing.test.ts+124 −0src/background/desktop-pairing.ts+45 −24
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach Password Manager",5-  "version": "4.0.23",5+  "version": "4.0.24",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.0.23",4+  "version": "4.0.24",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "4.0.23",3+  "version": "4.0.24",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/desktop-pairing.test.ts+124 −0
@@ -24,6 +24,7 @@2424 2525 // REAL SAS implementation — reused, not re-derived (ground truth §5).2626 import { derivePairingSAS } from "../shared/chirp-handshake";27+import { encodePairingCode } from "../shared/pairing-code";2728 import type { LocalIdentity } from "../shared/chirp-handshake";2829 import type { PairedDevice } from "../shared/sync-types";2930 @@ -264,5 +265,128 @@264265 265266     expect(errors).toEqual(["vault_locked"]);266267     expect(deps.findHost).not.toHaveBeenCalled();268+  });269+});270+271+// ── pairing-code path (the phone is the listener) ──────────────────────────272+273+const PHONE_TOKEN = Uint8Array.of(0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04);274+const PHONE_CODE = encodePairingCode("192.168.4.77", 51555, PHONE_TOKEN);275+const PHONE_LONGTERM_HEX = "f".repeat(64);276+277+/** Deps whose socket replays an Android peer's ACK + PAIR_IDENTITY. */278+function makePhoneDeps(deviceType: string | undefined = "android") {279+  const identity: Record<string, unknown> = {280+    type: "PAIR_IDENTITY",281+    device_id: "android-001",282+    device_name: "Pixel 8",283+    long_term_public_key_hex: PHONE_LONGTERM_HEX,284+    ws_port: 8765,285+  };286+  identity.device_type = deviceType;287+  return makeDeps({288+    openSocket: vi.fn(async () => makeFakeSocket([289+      enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: HELPER_EPHEMERAL_HEX }),290+      enc(identity),291+    ]).sock),292+  });293+}294+295+describe("pairWithDesktopHelper — pairing code", () => {296+  it("dials the address in the code instead of probing for a host", async () => {297+    const { deps } = makePhoneDeps();298+    const { cb, errors } = makeCallbacks();299+300+    await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true);301+302+    expect(errors).toHaveLength(0);303+    expect(deps.findHost).not.toHaveBeenCalled();304+    expect(deps.openSocket).toHaveBeenCalledWith("192.168.4.77", 51555);305+  });306+307+  it("presents the token as hex in the plaintext hello, not a 6-digit code", async () => {308+    const sockets: ReturnType<typeof makeFakeSocket>[] = [];309+    const { deps } = makeDeps({310+      openSocket: vi.fn(async () => {311+        const s = makeFakeSocket([312+          enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: HELPER_EPHEMERAL_HEX }),313+          enc({314+            type: "PAIR_IDENTITY",315+            device_id: "android-001",316+            device_name: "Pixel 8",317+            device_type: "android",318+            long_term_public_key_hex: PHONE_LONGTERM_HEX,319+            ws_port: 8765,320+          }),321+        ]);322+        sockets.push(s);323+        return s.sock;324+      }),325+    });326+    const { cb } = makeCallbacks();327+328+    await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true);329+330+    const hello = dec(sockets[0].sent[0]);331+    expect(hello.type).toBe("PEACH_HELLO");332+    expect(hello.token).toBe("deadbeef01020304");333+    expect(hello.code).toBeUndefined();334+  });335+336+  it("persists the phone with the device type it announced", async () => {337+    const { deps, persisted } = makePhoneDeps();338+    const { cb, completed } = makeCallbacks();339+340+    await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true);341+342+    expect(persisted).toHaveLength(1);343+    expect(persisted[0].deviceType).toBe("android");344+    expect(persisted[0].deviceId).toBe("android-001");345+    expect(persisted[0].longTermPublicKeyHex).toBe(PHONE_LONGTERM_HEX);346+    expect(persisted[0].trustSource).toBe("direct");347+    expect(persisted[0].wsPort).toBe(8765);348+    expect(completed).toHaveLength(1);349+  });350+351+  it("falls back to 'android', never 'desktop', when the peer omits its type", async () => {352+    const { deps, persisted } = makePhoneDeps("");353+    const { cb } = makeCallbacks();354+355+    await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true);356+357+    expect(persisted[0].deviceType).toBe("android");358+  });359+360+  it("caches the coded IP for the next sync round", async () => {361+    const cacheLastKnownIp = vi.fn(async () => undefined);362+    const { deps } = makePhoneDeps();363+    deps.cacheLastKnownIp = cacheLastKnownIp;364+    const { cb } = makeCallbacks();365+366+    await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true);367+368+    expect(cacheLastKnownIp).toHaveBeenCalledWith(PHONE_LONGTERM_HEX, "192.168.4.77");369+  });370+371+  it("reports no_host_found when the coded endpoint refuses the connection", async () => {372+    const { deps } = makeDeps({373+      openSocket: vi.fn(async () => { throw new Error("ECONNREFUSED"); }),374+    });375+    const { cb, errors } = makeCallbacks();376+377+    await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => true);378+379+    expect(errors).toEqual(["no_host_found"]);380+    expect(deps.persistDevice).not.toHaveBeenCalled();381+  });382+383+  it("still aborts on SAS rejection — the token is not the security boundary", async () => {384+    const { deps, persisted } = makePhoneDeps();385+    const { cb, errors } = makeCallbacks();386+387+    await pairWithDesktopHelper(PHONE_CODE, cb, deps, async () => false);388+389+    expect(errors).toEqual(["sas_mismatch"]);390+    expect(persisted).toHaveLength(0);267391   });268392 });

view file

src/background/desktop-pairing.ts+45 −24
@@ -1,23 +1,36 @@1-// Desktop helper code-pairing client (Change 3).1+// Code-pairing client — the browser always dials out, never listens.22 //3-// Pairs the extension with the `peach-sync` desktop helper:4-//   1. probe the /24 for a host accepting WebSocket on the pairing port5-//   2. open ws://<host>:491426-//   3. send PEACH_HELLO { code } in PLAINTEXT7-//   4. await PEACH_HELLO_ACK { ephemeralPublicKeyHex } OR socket close8-//        (socket close before ACK == wrong/expired code — peach-sync sends3+// Two code formats reach this module, and they differ ONLY in how the4+// endpoint is resolved and what the plaintext hello carries:5+//6+//   - Pairing code (`A74H-48J3-KI32-OEP4`, pairing-code.ts) — the primary7+//     path. The phone is the listener and the code carries its IP, port and8+//     one-time token, so there is nothing to discover: dial it directly.9+//   - Legacy 6-digit helper code — the speculative `peach-sync` desktop10+//     helper, which has no address of its own, so the host is probed for.11+//12+// Everything after the hello is one protocol, shared by both:13+//   1. open ws://<host>:<port>14+//   2. send PEACH_HELLO { token } (pairing code) or { code } (legacy) in15+//      PLAINTEXT16+//   3. await PEACH_HELLO_ACK { ephemeralPublicKeyHex } OR socket close17+//        (socket close before ACK == wrong/expired code — the listener sends918 //         NO error frame; it silently closes and rotates the code)10-//   5. run Noise NK as INITIATOR against the helper's ephemeral key11-//   6. exchange PAIR_IDENTITY (send ours incl. vault_id, then read theirs)12-//   7. derive the SAS, surface it for user confirmation13-//   8. on confirm -> persist the PairedDevice (deviceType "desktop"); on14-//        reject -> abort without persisting19+//   4. run Noise NK as INITIATOR against the listener's ephemeral key20+//   5. exchange PAIR_IDENTITY (send ours incl. vault_id, then read theirs)21+//   6. derive the SAS, surface it for user confirmation22+//   7. on confirm -> persist the PairedDevice; on reject -> abort without23+//        persisting1524 //1625 // ROLE: the BROWSER is the NK INITIATOR here — the OPPOSITE of the audio1726 // chirp flow (chirp-handshake.ts), where the extension is the responder.18-// The helper's `ephemeralPublicKeyHex` is the NK responder static `s`.27+// The listener's `ephemeralPublicKeyHex` is the NK responder static `s`.28+//29+// The browser can never accept an incoming connection (MV3 has no listening30+// socket), which is why the dialler role is structural and not a choice.1931 2032 import { noiseNKInitiator } from "../shared/noise-js";33+import { parsePairingCode } from "../shared/pairing-code";2134 import { derivePairingSAS } from "../shared/chirp-handshake";2235 import type { LocalIdentity } from "../shared/chirp-handshake";2336 import type { DeviceType, PairedDevice } from "../shared/sync-types";@@ -122,9 +135,7 @@122135   deps: DesktopPairingDeps,123136   confirm: (sas: string) => Promise<boolean>,124137 ): Promise<void> {125-  // The helper compares the code byte-for-byte against a [6]u8. Normalise to126-  // exactly 6 ASCII digits, zero-padded, no separators (ground truth §2).127-  const normalizedCode = String(code).replace(/\D/g, "").padStart(6, "0").slice(-6);138+  const pairing = parsePairingCode(code);128139 129140   let identity: LocalIdentity;130141   try {@@ -134,8 +145,10 @@134145     return;135146   }136147 137-  // 1) Find a host on the pairing port.138-  const host = await deps.findHost(DESKTOP_PAIRING_PORT);148+  // 1) Resolve the endpoint. A pairing code already carries one; a legacy149+  //    helper code does not, so the /24 has to be probed for it.150+  const port = pairing?.port ?? DESKTOP_PAIRING_PORT;151+  const host = pairing?.ip ?? (await deps.findHost(DESKTOP_PAIRING_PORT));139152   if (!host) {140153     cb.onError("no_host_found");141154     return;@@ -144,15 +157,22 @@144157   // 2) Open the WebSocket.145158   let sock: DesktopPairingSocket;146159   try {147-    sock = await deps.openSocket(host, DESKTOP_PAIRING_PORT);160+    sock = await deps.openSocket(host, port);148161   } catch {149162     cb.onError("no_host_found");150163     return;151164   }152165 153166   try {154-    // 3) Send PEACH_HELLO (PLAINTEXT binary frame).155-    sock.send(encodeJson({ type: "PEACH_HELLO", code: normalizedCode }));167+    // 3) Send PEACH_HELLO (PLAINTEXT binary frame). The legacy helper compares168+    //    the code byte-for-byte against a [6]u8, so that form is normalised to169+    //    exactly 6 zero-padded ASCII digits (ground truth §2); a pairing token170+    //    is sent as the hex it decoded to.171+    sock.send(encodeJson(172+      pairing173+        ? { type: "PEACH_HELLO", token: pairing.tokenHex }174+        : { type: "PEACH_HELLO", code: String(code).replace(/\D/g, "").padStart(6, "0").slice(-6) },175+    ));156176 157177     // 4) Await PEACH_HELLO_ACK OR socket close.158178     //    peach-sync sends NO error frame; a wrong/expired code closes the@@ -219,12 +239,13 @@219239       return;220240     }221241 222-    // 8) Persist. deviceType "desktop"; ws_port from the helper frame,223-    //    defaulting to 8765 (the helper always announces 8765, but guard it).242+    // 8) Persist. ws_port comes from the peer frame, defaulting to 8765 (both243+    //    peers announce it, but guard it). The device-type fallback follows the244+    //    code form so a phone never lands in the registry labelled "desktop".224245     const device: PairedDevice = {225246       deviceId: theirFrame.device_id,226247       deviceName: theirFrame.device_name,227-      deviceType: (theirFrame.device_type as DeviceType) || "desktop",248+      deviceType: (theirFrame.device_type as DeviceType) || (pairing ? "android" : "desktop"),228249       longTermPublicKeyHex: theirFrame.long_term_public_key_hex,229250       signingPublicKeyHex: theirFrame.signing_public_key_hex,230251       trustSource: "direct",

view file