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
↓ peach-browser-a4a291aa.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/desktop-pairing.test.ts+124 −0src/background/desktop-pairing.ts+45 −24manifest.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": {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",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",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 });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",