ad1ba5b1a3e7
rebuild vault adoption onto the pairing code: extract the shared dialler, make the receiver dial out instead of listen, add a code field to the pair phase (v4.0.27)
- time
- 2026-07-24 22:12 UTC · 3 months ago
- machine
- koh2
- agent
- claude-opus-5
- session
- pairing code transport
- parent
- 59b013ae70b4
↓ peach-browser-ad1ba5b1.tar.gzatlas at this save
files changed10 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/desktop-pairing-bg.ts+9 −57src/background/desktop-pairing.ts+48 −149src/popup/App.tsx+61 −44src/popup/views/AdoptVaultView.test.tsx+43 −0src/popup/views/AdoptVaultView.tsx+65 −5src/shared/pairing-dial.test.ts+149 −0src/shared/pairing-dial.ts+227 −0manifest.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.26",5+ "version": "4.0.27",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.26",4+ "version": "4.0.27",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.26",3+ "version": "4.0.27",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/desktop-pairing-bg.ts+9 −57
@@ -14,9 +14,9 @@1414 import {1515 pairWithDesktopHelper,1616 type DesktopPairingDeps,17- type DesktopPairingSocket,1817 type PairWithDesktopCallbacks,1918 } from "./desktop-pairing";19+import { openPairingSocket } from "../shared/pairing-dial";2020 import { upsertPairedDevice, setLastKnownIp } from "./paired-devices";2121 import { buildLocalIdentity } from "./chirp-identity";2222 import { computeVaultId } from "../shared/sync-identity";@@ -44,62 +44,14 @@4444 }4545 }4646 47-/** Adapt a real `WebSocket` to the `DesktopPairingSocket` seam (binary frames). */48-export function openBinaryWebSocket(ip: string, port: number): Promise<DesktopPairingSocket> {49- return new Promise((resolve, reject) => {50- let ws: WebSocket;51- try {52- ws = new WebSocket(`ws://${ip}:${port}/`);53- } catch (err) {54- reject(err instanceof Error ? err : new Error("websocket open failed"));55- return;56- }57- ws.binaryType = "arraybuffer";47+/**48+ * The WebSocket adapter moved to `shared/pairing-dial.ts` alongside the seam it49+ * implements, so vault adoption (which runs in the popup, before a vault50+ * exists) dials through the same code. Re-exported under its original name51+ * because that is what existing callers import.52+ */53+export { openPairingSocket as openBinaryWebSocket };5854 59- const inbox: Uint8Array[] = [];60- const readers: Array<{ resolve: (b: Uint8Array) => void; reject: (e: Error) => void }> = [];61- let opened = false;62- let closed = false;63- // True iff the socket closed with no frame delivered for the pending read —64- // the wrong/expired-code signal (peach-sync sends no error frame).65- let sawCloseBeforeFrame = false;66-67- ws.onopen = () => {68- opened = true;69- resolve(sock);70- };71- ws.onmessage = (ev: MessageEvent) => {72- const bytes = new Uint8Array(ev.data as ArrayBuffer);73- const r = readers.shift();74- if (r) { sawCloseBeforeFrame = false; r.resolve(bytes); }75- else inbox.push(bytes);76- };77- ws.onerror = () => {78- if (!opened) reject(new Error("websocket connection failed"));79- };80- ws.onclose = () => {81- closed = true;82- if (readers.length) {83- sawCloseBeforeFrame = true;84- while (readers.length) readers.shift()!.reject(new Error("socket closed"));85- }86- if (!opened) reject(new Error("socket closed before open"));87- };88-89- const sock: DesktopPairingSocket = {90- send(bytes: Uint8Array) { ws.send(bytes as Uint8Array<ArrayBuffer>); },91- receive(): Promise<Uint8Array> {92- const queued = inbox.shift();93- if (queued) { sawCloseBeforeFrame = false; return Promise.resolve(queued); }94- if (closed) { sawCloseBeforeFrame = true; return Promise.reject(new Error("socket closed")); }95- return new Promise<Uint8Array>((res, rej) => readers.push({ resolve: res, reject: rej }));96- },97- close() { try { ws.close(); } catch { /* ignore */ } },98- closedBeforeFrame() { return sawCloseBeforeFrame; },99- };100- });101-}102-10355 /** Production dependency set — real probe, WebSocket, identity, and storage. */10456 export function productionDesktopPairingDeps(): DesktopPairingDeps {10557 return {@@ -116,7 +68,7 @@11668 if (!subnet) return null;11769 return probeSubnetForPort(subnet, port);11870 },119- openSocket: (ip, port) => openBinaryWebSocket(ip, port),71+ openSocket: (ip, port) => openPairingSocket(ip, port),12072 async getLocalIdentity(): Promise<LocalIdentity> {12173 if (!isUnlocked()) throw new Error("vault_locked");12274 const masterKeyHex = getRootKeyHex();src/background/desktop-pairing.ts+48 −149
@@ -1,70 +1,45 @@1-// Code-pairing client — the browser always dials out, never listens.1+// Device pairing over a code — the PAIR_IDENTITY half.22 //3-// Two code formats reach this module, and they differ ONLY in how the4-// endpoint is resolved and what the plaintext hello carries:3+// The dialling half (decode the code, connect, plaintext hello, Noise NK) lives4+// in `shared/pairing-dial.ts` and is shared with vault adoption, which speaks5+// ADOPT_* frames over the same channel instead. This module is only what6+// happens once that channel is open:57 //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.8+// 1. exchange PAIR_IDENTITY (send ours incl. vault_id, then read theirs)9+// 2. derive the SAS, surface it for user confirmation10+// 3. on confirm -> persist the PairedDevice; on reject -> abort without11+// persisting1112 //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 sends18-// NO error frame; it silently closes and rotates the code)19-// 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-// persisting24-//25-// ROLE: the BROWSER is the NK INITIATOR here — the OPPOSITE of the audio26-// chirp flow (chirp-handshake.ts), where the extension is the responder.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.13+// Two code forms reach here. A pairing code (`A74H-48J3-KI32-OEP4`) is the14+// primary path: the phone listens and the code carries its address. A legacy15+// 6-digit helper code has no address, so the host is probed for.3116 32-import { noiseNKInitiator } from "../shared/noise-js";33-import { parsePairingCode } from "../shared/pairing-code";34-import { derivePairingSAS } from "../shared/chirp-handshake";17+import {18+ dialPairingChannel,19+ PAIRING_PORT,20+ type PairingDialDeps,21+ type PairingSocket,22+} from "../shared/pairing-dial";3523 import type { LocalIdentity } from "../shared/chirp-handshake";24+import { parsePairingCode } from "../shared/pairing-code";3625 import type { DeviceType, PairedDevice } from "../shared/sync-types";3726 38-/** The pairing-only port peach-sync listens on while `peach-sync pair` runs. */39-export const DESKTOP_PAIRING_PORT = 49142;40-/** Default sync port the helper announces; used when ws_port is absent. */27+/** The pairing-only port a legacy helper code implies. */28+export const DESKTOP_PAIRING_PORT = PAIRING_PORT;29+/** Default sync port a peer announces; used when ws_port is absent. */4130 const DEFAULT_SYNC_PORT = 8765;4231 4332 export type DesktopPairingError =4433 | "invalid_code" // socket closed before PEACH_HELLO_ACK (wrong/expired)45- | "no_host_found" // no WebSocket server answered on the pairing port34+ | "no_host_found" // nothing answered at the endpoint4635 | "handshake_failed" // Noise NK / PAIR_IDENTITY decode failed4736 | "sas_mismatch" // user rejected the SAS4837 | "vault_locked"; // no master key available4938 50-/**51- * Minimal binary WebSocket transport seam. Production wires the real52- * `WebSocket`; tests inject a fake. One binary message per frame.53- */54-export interface DesktopPairingSocket {55- send(bytes: Uint8Array): void;56- /** Resolves with the next inbound binary frame, or rejects on close. */57- receive(): Promise<Uint8Array>;58- close(): void;59- /** Resolves true if the socket closed before the next frame arrived. */60- closedBeforeFrame(): boolean;61-}39+/** Re-exported so existing callers and specs keep one name for the seam. */40+export type DesktopPairingSocket = PairingSocket;6241 63-export interface DesktopPairingDeps {64- /** Change 5 — find a host answering WebSocket on the pairing port. */65- findHost(port: number): Promise<string | null>;66- /** Open a binary WebSocket to ws://<ip>:<port>/ . */67- openSocket(ip: string, port: number): Promise<DesktopPairingSocket>;42+export interface DesktopPairingDeps extends PairingDialDeps {6843 /** Public identity for our outbound PAIR_IDENTITY (incl. vaultId). */6944 getLocalIdentity(): Promise<LocalIdentity>;7045 /** Persist on user confirm. */@@ -74,7 +49,7 @@7449 }7550 7651 export interface PairWithDesktopCallbacks {77- /** Show the 6-digit SAS; the popup must compare against the helper's. */52+ /** Show the 6-digit SAS; the popup must compare it against the peer's. */7853 onSasReady(sas: string): void;7954 /** Pairing succeeded and the device was persisted. */8055 onComplete(device: PairedDevice): void;@@ -97,19 +72,8 @@9772 return new TextEncoder().encode(JSON.stringify(obj));9873 }9974 100-function decodeJson<T>(bytes: Uint8Array): T {101- return JSON.parse(new TextDecoder().decode(bytes)) as T;102-}103-104-function hexToBytes(hex: string): Uint8Array {105- if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error("expected 64-char lowercase hex");106- const out = new Uint8Array(32);107- for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);108- return out;109-}110-11175 function decodePairIdentity(bytes: Uint8Array): PairingFrame {112- const p = decodeJson<Record<string, unknown>>(bytes);76+ const p = JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>;11377 if (p?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY");11478 if (typeof p.device_id !== "string" || !p.device_id) throw new Error("device_id missing");11579 if (typeof p.device_name !== "string") throw new Error("device_name missing");@@ -122,12 +86,13 @@12286 }12387 12488 /**125- * Drive a complete desktop-helper code pairing.89+ * Drive a complete code pairing.12690 *127- * `confirm` is a function the caller resolves AFTER the user has compared128- * the SAS shown here against the SAS the helper displays. Resolving false129- * (or rejecting) aborts before any device is persisted — this is the130- * out-of-band MITM defence (see SECURITY note in chirp-handshake.ts).91+ * `confirm` is a function the caller resolves AFTER the user has compared the92+ * SAS shown here against the SAS the peer displays. Resolving false (or93+ * rejecting) aborts before any device is persisted — this is the out-of-band94+ * MITM defence (see the SECURITY note in chirp-handshake.ts), and it, not the95+ * pairing token, is what actually authenticates the peer.13196 */13297 export async function pairWithDesktopHelper(13398 code: string,@@ -135,8 +100,6 @@135100 deps: DesktopPairingDeps,136101 confirm: (sas: string) => Promise<boolean>,137102 ): Promise<void> {138- const pairing = parsePairingCode(code);139-140103 let identity: LocalIdentity;141104 try {142105 identity = await deps.getLocalIdentity();@@ -145,74 +108,15 @@145108 return;146109 }147110 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));152- if (!host) {153- cb.onError("no_host_found");111+ const dial = await dialPairingChannel(code, deps);112+ if (!dial.ok) {113+ cb.onError(dial.error);154114 return;155115 }116+ const { channel, sas, host, socket } = dial.dialled;156117 157- // 2) Open the WebSocket.158- let sock: DesktopPairingSocket;159118 try {160- sock = await deps.openSocket(host, port);161- } catch {162- cb.onError("no_host_found");163- return;164- }165-166- try {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- ));176-177- // 4) Await PEACH_HELLO_ACK OR socket close.178- // peach-sync sends NO error frame; a wrong/expired code closes the179- // socket. So "receive() rejected / closed before a frame" == invalid_code.180- let ackBytes: Uint8Array;181- try {182- ackBytes = await sock.receive();183- } catch {184- cb.onError("invalid_code");185- return;186- }187- if (sock.closedBeforeFrame() || ackBytes.length === 0) {188- cb.onError("invalid_code");189- return;190- }191-192- let ephemeralPublicKeyHex: string;193- try {194- const ack = decodeJson<{ type?: string; ephemeralPublicKeyHex?: string }>(ackBytes);195- if (ack.type !== "PEACH_HELLO_ACK" || typeof ack.ephemeralPublicKeyHex !== "string") {196- // A non-ACK frame on a matching code should not happen; treat the197- // missing ACK exactly like a wrong code.198- cb.onError("invalid_code");199- return;200- }201- ephemeralPublicKeyHex = ack.ephemeralPublicKeyHex;202- } catch {203- cb.onError("invalid_code");204- return;205- }206-207- // 5) Noise NK as INITIATOR against the helper's ephemeral static key.208- const responderStatic = hexToBytes(ephemeralPublicKeyHex);209- const session = await noiseNKInitiator(210- responderStatic,211- async (msg) => sock.send(msg),212- () => sock.receive(),213- );214-215- // 6) Exchange PAIR_IDENTITY — initiator (browser) sends FIRST, then reads.119+ // Initiator (browser) sends its identity FIRST, then reads the peer's.216120 const ourFrame: PairingFrame = {217121 type: "PAIR_IDENTITY",218122 device_id: identity.deviceId,@@ -223,29 +127,26 @@223127 ws_port: identity.wsPort,224128 vault_id: identity.vaultId, // Change 1225129 };226- sock.send(session.encrypt(encodeJson(ourFrame)));130+ await channel.send(encodeJson(ourFrame));227131 228- const theirCipher = await sock.receive();229- const theirFrame = decodePairIdentity(session.decrypt(theirCipher));132+ const theirFrame = decodePairIdentity(await channel.receive());230133 231- // 7) Derive the SAS (REUSED — same value the helper computes from the232- // shared handshake hash) and surface it for user confirmation.233- const sas = derivePairingSAS(session.handshakeHash);234134 cb.onSasReady(sas);235-236135 const confirmed = await confirm(sas);237136 if (!confirmed) {238137 cb.onError("sas_mismatch");239138 return;240139 }241140 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".141+ // ws_port comes from the peer frame, defaulting to 8765 (both peers142+ // announce it, but guard it). The device-type fallback follows the code143+ // form so a phone never lands in the registry labelled "desktop".245144 const device: PairedDevice = {246145 deviceId: theirFrame.device_id,247146 deviceName: theirFrame.device_name,248- deviceType: (theirFrame.device_type as DeviceType) || (pairing ? "android" : "desktop"),147+ deviceType:148+ (theirFrame.device_type as DeviceType) ||149+ (parsePairingCode(code) ? "android" : "desktop"),249150 longTermPublicKeyHex: theirFrame.long_term_public_key_hex,250151 signingPublicKeyHex: theirFrame.signing_public_key_hex,251152 trustSource: "direct",@@ -258,11 +159,9 @@258159 }259160 cb.onComplete(device);260161 } catch (err) {261- // The mapped error is intentionally coarse for the UI; log the real cause262- // so a failed pair is diagnosable from the service-worker console.263162 if (typeof console !== "undefined") console.error("[desktop-pairing] handshake failed:", err);264163 cb.onError("handshake_failed");265164 } finally {266- sock.close();165+ socket.close();267166 }268167 }src/popup/App.tsx+61 −44
@@ -125,14 +125,6 @@125125 return "Peach";126126 }127127 128-function adoptionDeviceType(): "extension" | "android" | "ios" {129- const cap = (globalThis as unknown as { Capacitor?: { getPlatform?: () => string } }).Capacitor;130- const platform = cap?.getPlatform?.();131- if (platform === "ios") return "ios";132- if (platform === "android") return "android";133- return "extension";134-}135-136128 const ENTRY_TRANSITION_SETTLE_MS = 135;137129 138130 export default function App() {@@ -1186,49 +1178,74 @@11861178 void sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "foreground" } });11871179 }11881180 1181+ /**1182+ * Join an existing vault from a brand-new install.1183+ *1184+ * This used to require the receiver to LISTEN for the other device (a1185+ * Capacitor pairing server), which a browser extension structurally cannot1186+ * do — so on every real browser build it threw before doing anything. Over a1187+ * pairing code the receiver is the DIALLER instead, which is the only shape1188+ * that works here: dial the code, confirm the SAS, then ask for the vault.1189+ *1190+ * The vault does not exist yet at dial time, so nothing in this path may1191+ * touch the vault before `createVaultFromRootKey` runs.1192+ */11891193 async function startVaultAdoption(args: {11901194 password: string;1195+ code: string;11911196 confirmSAS(sas: string): Promise<boolean>;11921197 createVault(rootKeyHex: string): Promise<{ vaultId: string }>;11931198 }) {1194- if (!isNativePlatform()) {1195- throw new Error("adoption_pairing_requires_native_server");1196- }1197-1198- const [{ runChirpAdoptionReceiver }, { openCapacitorPairingServer }] = await Promise.all([1199- import("./chirp-pair"),1200- import("./chirp-pair-capacitor"),1199+ const [{ dialPairingChannel, openPairingSocket }, { requestAdoption }] = await Promise.all([1200+ import("../shared/pairing-dial"),1201+ import("../shared/chirp-adopt"),12011202 ]);12021203 1203- await runChirpAdoptionReceiver({1204- deviceName: defaultAdoptionDeviceName(),1205- deviceType: adoptionDeviceType(),1206- openPairingServer: openCapacitorPairingServer,1207- confirmSAS: args.confirmSAS,1208- createVaultFromRootKey: args.createVault,1209- deriveLocalIdentity: async () => {1210- const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });1211- const r = res as { ok: boolean; data?: unknown; error?: string };1212- if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");1213- return r.data as Awaited<ReturnType<Parameters<typeof runChirpAdoptionReceiver>[0]["deriveLocalIdentity"]>>;1214- },1215- completePairing: async (paired) => {1216- const res = await sendMessage({ type: "CHIRP_COMPLETE_PAIRING", payload: { paired } });1217- const r = res as { ok: boolean; error?: string };1218- if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");1219- },1220- applyLicense: async (license) => {1221- const res = (await sendMessage({1222- type: "ACTIVATE_LICENSE",1223- payload: { key: license },1224- })) as { ok: boolean; data?: { valid?: boolean; plan?: string } };1225- if (res.ok && res.data?.valid && res.data?.plan === "pro") await refreshProStatus();1226- },1227- onProgress() {1228- // AdoptVaultView owns the visible phase copy; its SAS callback moves1229- // from chirping to verification, and success/error are local there.1230- },1231- });1204+ // No `findHost`: adoption only accepts a pairing code, which carries its1205+ // own address. There is nothing to probe the LAN for.1206+ const dial = await dialPairingChannel(args.code, { openSocket: openPairingSocket });1207+ if (!dial.ok) throw new Error(dial.error);1208+ const { channel, sas, socket } = dial.dialled;1209+1210+ try {1211+ // Same out-of-band gate as device pairing, and for the same reason: the1212+ // token proves nothing, the user comparing this code does. Ask BEFORE1213+ // requesting the vault's root key.1214+ if (!(await args.confirmSAS(sas))) throw new Error("sas_rejected");1215+1216+ const source = await requestAdoption(channel, {1217+ deviceName: defaultAdoptionDeviceName(),1218+ generateDeviceId: () => crypto.randomUUID(),1219+ createVaultFromRootKey: args.createVault,1220+ deriveLocalIdentity: async () => {1221+ const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });1222+ const r = res as { ok: boolean; data?: unknown; error?: string };1223+ if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");1224+ return r.data as Awaited<ReturnType<Parameters<typeof requestAdoption>[1]["deriveLocalIdentity"]>>;1225+ },1226+ upsertPairedDevice: async (paired) => {1227+ const res = await sendMessage({ type: "CHIRP_COMPLETE_PAIRING", payload: { paired } });1228+ const r = res as { ok: boolean; error?: string };1229+ if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");1230+ },1231+ applyLicense: async (license) => {1232+ const res = (await sendMessage({1233+ type: "ACTIVATE_LICENSE",1234+ payload: { key: license },1235+ })) as { ok: boolean; data?: { valid?: boolean; plan?: string } };1236+ if (res.ok && res.data?.valid && res.data?.plan === "pro") await refreshProStatus();1237+ },1238+ onError(error) {1239+ throw new Error(error);1240+ },1241+ });1242+ if (!source) throw new Error("adoption_rejected");1243+ // No last-known-IP cache write here: the first sync round falls back to1244+ // the subnet probe and persists the address itself once it lands, so a1245+ // popup→background message just to save that one scan isn't worth it.1246+ } finally {1247+ socket.close();1248+ }12321249 }12331250 12341251 async function handleApprovePendingPasskey() {src/popup/views/AdoptVaultView.test.tsx+43 −0
@@ -5,7 +5,10 @@55 import { Onboarding } from "./Onboarding";66 import { AdoptVaultView } from "./AdoptVaultView";77 import { flushView, inputText, renderView } from "./view-test-utils";8+import { encodePairingCode } from "../../shared/pairing-code";89 10+const PAIRING_CODE = encodePairingCode("192.168.4.77", 51555, Uint8Array.of(1, 2, 3, 4, 5, 6, 7, 8));11+912 vi.mock("@zxcvbn-ts/core", () => ({1013 zxcvbn: (value: string) => ({ score: value.length >= 16 ? 4 : 1 }),1114 zxcvbnOptions: { setOptions: vi.fn() },@@ -27,6 +30,12 @@2730 return found as HTMLButtonElement;2831 }2932 33+/** The pair phase now needs a real code before it will dial anything. */34+async function enterPairingCode(container: HTMLElement) {35+ inputText(container.querySelector("#adopt-pair-code") as HTMLInputElement, PAIRING_CODE);36+ await flushView();37+}38+3039 async function enterStrongPassword(container: HTMLElement) {3140 inputText(container.querySelector("#adopt-password") as HTMLInputElement, "9xQ!vZ2pLm74Tr#Ke8Wd5Bn");3241 inputText(container.querySelector("#adopt-confirm-password") as HTMLInputElement, "9xQ!vZ2pLm74Tr#Ke8Wd5Bn");@@ -76,6 +85,37 @@7685 expect(continueButton.disabled).toBe(true);7786 });7887 88+ it("T-A26: pairing cannot start until a valid pairing code is entered", async () => {89+ const startAdoption = vi.fn(async () => {});90+ const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} startAdoption={startAdoption} />);91+ await enterStrongPassword(view.container);92+ button(view.container, "Continue").click();93+ await flushView();94+95+ expect(button(view.container, "Pair a device").disabled).toBe(true);96+ // Long enough to be grouped, but too short to carry a token.97+ inputText(view.container.querySelector("#adopt-pair-code") as HTMLInputElement, "A74H48J3");98+ await flushView();99+ expect(button(view.container, "Pair a device").disabled).toBe(true);100+101+ await enterPairingCode(view.container);102+ expect(button(view.container, "Pair a device").disabled).toBe(false);103+ expect(startAdoption).not.toHaveBeenCalled();104+ });105+106+ it("T-A27: the typed code is handed to the adoption transport", async () => {107+ const startAdoption = vi.fn(async (_args: { code: string }) => {});108+ const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} startAdoption={startAdoption} />);109+ await enterStrongPassword(view.container);110+ button(view.container, "Continue").click();111+ await enterPairingCode(view.container);112+ button(view.container, "Pair a device").click();113+ await flushView();114+115+ expect(startAdoption).toHaveBeenCalledTimes(1);116+ expect(startAdoption.mock.calls[0][0]).toMatchObject({ code: PAIRING_CODE });117+ });118+79119 it("T-A23: adoption flow reaches the SAS screen", async () => {80120 const startAdoption = vi.fn(async ({ confirmSAS }: { confirmSAS(sas: string): Promise<boolean> }) => {81121 await confirmSAS("895245");@@ -83,6 +123,7 @@83123 const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} startAdoption={startAdoption} />);84124 await enterStrongPassword(view.container);85125 button(view.container, "Continue").click();126+ await enterPairingCode(view.container);86127 button(view.container, "Pair a device").click();87128 await flushView();88129 @@ -103,6 +144,7 @@103144 );104145 await enterStrongPassword(view.container);105146 button(view.container, "Continue").click();147+ await enterPairingCode(view.container);106148 button(view.container, "Pair a device").click();107149 await flushView();108150 @@ -128,6 +170,7 @@128170 );129171 await enterStrongPassword(view.container);130172 button(view.container, "Continue").click();173+ await enterPairingCode(view.container);131174 button(view.container, "Pair a device").click();132175 await flushView();133176 button(view.container, "Cancel").click();src/popup/views/AdoptVaultView.tsx+65 −5
@@ -1,6 +1,7 @@11 import { Match, Show, Switch, createMemo, createSignal } from "solid-js";22 import { sendMessage } from "../App";33 import { RuneBackground } from "../components/RuneBackground";4+import { formatPairingCode, parsePairingCode } from "../../shared/pairing-code";45 56 type AdoptPhase = "password" | "pair" | "sas" | "success" | "error";67 @@ -9,6 +10,8 @@910 onDone(): void | Promise<void>;1011 startAdoption(args: {1112 password: string;13+ /** The pairing code shown by the device that already holds the vault. */14+ code: string;1215 confirmSAS(sas: string): Promise<boolean>;1316 createVault(rootKeyHex: string): Promise<{ vaultId: string }>;1417 }): Promise<void>;@@ -17,6 +20,23 @@1720 const MIN_LENGTH = 12;1821 const MIN_ZXCVBN_SCORE = 2;1922 23+/** Turn a dial/adoption failure into something a person can act on. */24+function adoptionErrorCopy(err: unknown): string {25+ const message = err instanceof Error ? err.message : String(err);26+ switch (message) {27+ case "invalid_code":28+ return "That code wasn't accepted — it may have expired. Get a fresh one from your other device.";29+ case "no_host_found":30+ return "Couldn't reach your other device. Check both are on the same Wi-Fi and its pairing screen is still open.";31+ case "handshake_failed":32+ return "The secure connection failed. Try again with a fresh code.";33+ case "adoption_rejected":34+ return "Your other device declined the request.";35+ default:36+ return message;37+ }38+}39+2040 let zxcvbnLoader: ((password: string) => { score: number }) | null = null;2141 async function getZxcvbn() {2242 if (zxcvbnLoader) return zxcvbnLoader;@@ -42,7 +62,11 @@4262 const [sas, setSas] = createSignal("");4363 const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);4464 const [error, setError] = createSignal("");65+ const [code, setCode] = createSignal("");66+ const [connecting, setConnecting] = createSignal(false);4567 68+ const codeReady = createMemo(() => parsePairingCode(code()) !== null);69+4670 createMemo(() => {4771 const value = password();4872 if (!value) {@@ -75,13 +99,16 @@7599 });76100 77101 async function beginPairing() {102+ if (!codeReady() || connecting()) return;78103 setPhase("pair");79104 setError("");105+ setConnecting(true);80106 try {81107 await props.startAdoption({82108 password: password(),83- confirmSAS: (code) => new Promise<boolean>((resolve) => {84- setSas(code);109+ code: code(),110+ confirmSAS: (sasCode) => new Promise<boolean>((resolve) => {111+ setSas(sasCode);85112 setSasResolve(() => resolve);86113 setPhase("sas");87114 }),@@ -97,8 +124,10 @@97124 setPhase("success");98125 } catch (err) {99126 if (err instanceof Error && err.message === "sas_rejected") return;100- setError(err instanceof Error ? err.message : String(err));127+ setError(adoptionErrorCopy(err));101128 setPhase("error");129+ } finally {130+ setConnecting(false);102131 }103132 }104133 @@ -111,6 +140,9 @@111140 setPassword("");112141 setConfirmPassword("");113142 setStrengthScore(null);143+ // The code is single-use on the other device's side too — a rejected SAS144+ // means starting over with a fresh one, not retrying this one.145+ setCode("");114146 }115147 }116148 @@ -157,9 +189,37 @@157189 <span />158190 </div>159191 <h1>Pair with your other device.</h1>160- <p>Open Peach on the device that has your vault. Bring both devices close together.</p>192+ <p>193+ Open Peach on the device that has your vault and tap “Pair a device”.194+ Type the code it shows. Both devices need to be on the same Wi-Fi.195+ </p>196+ <label class="peach-label" for="adopt-pair-code">pairing code</label>197+ <input198+ id="adopt-pair-code"199+ class="peach-underline-input peach-adopt-input peach-code-input"200+ type="text"201+ autocapitalize="characters"202+ autocomplete="one-time-code"203+ spellcheck={false}204+ maxlength={32}205+ placeholder="A74H-48J3-KI32-OEP4"206+ aria-label="Pairing code"207+ disabled={connecting()}208+ value={code()}209+ onInput={(event) =>210+ setCode(formatPairingCode((event.target as HTMLInputElement).value).slice(0, 32))211+ }212+ onKeyDown={(event) => { if (event.key === "Enter") void beginPairing(); }}213+ />214+ <Show when={connecting()}>215+ <p class="peach-hint">connecting to your other device...</p>216+ </Show>161217 <div class="peach-adopt-actions">162- <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void beginPairing()}>218+ <button219+ class="peach-btn peach-btn--accent peach-btn--md"220+ disabled={!codeReady() || connecting()}221+ onClick={() => void beginPairing()}222+ >163223 Pair a device164224 </button>165225 <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>src/shared/pairing-dial.test.ts+149 −0
@@ -0,0 +1,149 @@1+import { beforeEach, describe, expect, it, vi } from "vitest";2+3+import { dialPairingChannel, PAIRING_PORT, type PairingSocket } from "./pairing-dial";4+import { encodePairingCode } from "./pairing-code";5+6+const enc = (o: unknown) => new TextEncoder().encode(JSON.stringify(o));7+const dec = (b: Uint8Array) => JSON.parse(new TextDecoder().decode(b));8+9+const LISTENER_EPHEMERAL_HEX = "a".repeat(64);10+const CODE = encodePairingCode("192.168.4.77", 51555, Uint8Array.of(0xde, 0xad, 0xbe, 0xef));11+12+const FAKE_HANDSHAKE_HASH = Uint8Array.from([0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0)]);13+14+// Skip the real handshake messages; encrypt/decrypt are identity transforms so15+// the channel's framing can be asserted directly.16+vi.mock("./noise-js", async (orig) => {17+ const actual = await orig<typeof import("./noise-js")>();18+ return {19+ ...actual,20+ noiseNKInitiator: vi.fn(async () => ({21+ encrypt: (pt: Uint8Array) => pt,22+ decrypt: (ct: Uint8Array) => ct,23+ handshakeHash: FAKE_HANDSHAKE_HASH,24+ })),25+ };26+});27+28+/** `null` in the queue means "the socket closes here with no data". */29+function fakeSocket(inbound: Array<Uint8Array | null>) {30+ const sent: Uint8Array[] = [];31+ let closed = false;32+ let lastWasClose = false;33+ const queue = [...inbound];34+ const sock: PairingSocket = {35+ send(bytes) { sent.push(bytes); },36+ async receive() {37+ const next = queue.shift();38+ if (next === undefined || next === null) {39+ closed = true;40+ lastWasClose = true;41+ throw new Error("socket closed");42+ }43+ lastWasClose = false;44+ return next;45+ },46+ close() { closed = true; },47+ closedBeforeFrame() { return lastWasClose; },48+ };49+ return { sock, sent, isClosed: () => closed };50+}51+52+function ackThen(...rest: Array<Uint8Array | null>) {53+ return fakeSocket([54+ enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: LISTENER_EPHEMERAL_HEX }),55+ ...rest,56+ ]);57+}58+59+beforeEach(() => { vi.clearAllMocks(); });60+61+describe("dialPairingChannel", () => {62+ it("dials the coded endpoint and never probes for a host", async () => {63+ const openSocket = vi.fn(async () => ackThen().sock);64+ const findHost = vi.fn(async () => "10.0.0.9");65+66+ const result = await dialPairingChannel(CODE, { findHost, openSocket });67+68+ expect(result.ok).toBe(true);69+ expect(findHost).not.toHaveBeenCalled();70+ expect(openSocket).toHaveBeenCalledWith("192.168.4.77", 51555);71+ });72+73+ it("presents the token as hex and returns the shared SAS", async () => {74+ const s = ackThen();75+ const result = await dialPairingChannel(CODE, { openSocket: async () => s.sock });76+77+ expect(dec(s.sent[0])).toEqual({ type: "PEACH_HELLO", token: "deadbeef" });78+ expect(result.ok && result.dialled.sas).toBe("895245");79+ expect(result.ok && result.dialled.host).toBe("192.168.4.77");80+ });81+82+ it("probes for a host and sends a padded code for a legacy 6-digit code", async () => {83+ const s = ackThen();84+ const findHost = vi.fn(async () => "10.0.0.9");85+ const openSocket = vi.fn(async () => s.sock);86+87+ await dialPairingChannel("123", { findHost, openSocket });88+89+ expect(findHost).toHaveBeenCalledWith(PAIRING_PORT);90+ expect(openSocket).toHaveBeenCalledWith("10.0.0.9", PAIRING_PORT);91+ expect(dec(s.sent[0])).toEqual({ type: "PEACH_HELLO", code: "000123" });92+ });93+94+ it("has nowhere to go for an addressless code when no probe is supplied", async () => {95+ const openSocket = vi.fn(async () => ackThen().sock);96+97+ const result = await dialPairingChannel("123456", { openSocket });98+99+ expect(result).toEqual({ ok: false, error: "no_host_found" });100+ expect(openSocket).not.toHaveBeenCalled();101+ });102+103+ it("reads a silent close as invalid_code, not a transport failure", async () => {104+ // A wrong or expired token gets no error frame — the listener just closes.105+ const s = fakeSocket([null]);106+ const result = await dialPairingChannel(CODE, { openSocket: async () => s.sock });107+108+ expect(result).toEqual({ ok: false, error: "invalid_code" });109+ expect(s.isClosed()).toBe(true);110+ });111+112+ it("treats a non-ACK first frame exactly like a wrong token", async () => {113+ const s = fakeSocket([enc({ type: "SOMETHING_ELSE" })]);114+ const result = await dialPairingChannel(CODE, { openSocket: async () => s.sock });115+116+ expect(result).toEqual({ ok: false, error: "invalid_code" });117+ });118+119+ it("rejects an ACK whose ephemeral key is not 32-byte hex", async () => {120+ const s = fakeSocket([enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: "nope" })]);121+ const result = await dialPairingChannel(CODE, { openSocket: async () => s.sock });122+123+ expect(result).toEqual({ ok: false, error: "handshake_failed" });124+ expect(s.isClosed()).toBe(true);125+ });126+127+ it("reports no_host_found when the endpoint refuses the connection", async () => {128+ const result = await dialPairingChannel(CODE, {129+ openSocket: async () => { throw new Error("ECONNREFUSED"); },130+ });131+132+ expect(result).toEqual({ ok: false, error: "no_host_found" });133+ });134+135+ it("leaves the socket open on success so the caller owns its lifetime", async () => {136+ const s = ackThen(enc({ hello: "world" }));137+ const result = await dialPairingChannel(CODE, { openSocket: async () => s.sock });138+ if (!result.ok) throw new Error("expected a dialled channel");139+140+ expect(s.isClosed()).toBe(false);141+ await result.dialled.channel.send(enc({ type: "ANYTHING" }));142+ // sent[0] is the plaintext hello; sent[1] went through the Noise channel.143+ expect(dec(s.sent[1])).toEqual({ type: "ANYTHING" });144+ expect(dec(await result.dialled.channel.receive())).toEqual({ hello: "world" });145+146+ result.dialled.socket.close();147+ expect(s.isClosed()).toBe(true);148+ });149+});src/shared/pairing-dial.ts+227 −0
@@ -0,0 +1,227 @@1+// The dialling half of code pairing — shared by device pairing and vault2+// adoption.3+//4+// Both flows are identical up to the point where they diverge: decode a5+// pairing code, dial the address inside it, present the token in a plaintext6+// hello, and run Noise NK as the INITIATOR against the ephemeral key the7+// listener returns. What they then say over the encrypted channel differs8+// (PAIR_IDENTITY vs ADOPT_REQUEST); how they get there does not.9+//10+// The browser is always the dialler. It cannot listen, so it is always the NK11+// initiator and the listener's `ephemeralPublicKeyHex` is the responder static.12+//13+// Nothing here touches chrome, the vault, or a real WebSocket — the socket is14+// injected — because vault adoption runs before a vault exists at all.15+16+import { noiseNKInitiator } from "./noise-js";17+import { derivePairingSAS, type PairingTransport } from "./chirp-handshake";18+import { parsePairingCode } from "./pairing-code";19+20+/** The pairing-only port a legacy helper code implies. */21+export const PAIRING_PORT = 49142;22+23+export type PairingDialError =24+ | "invalid_code" // socket closed before PEACH_HELLO_ACK (wrong/expired)25+ | "no_host_found" // nothing answered at the endpoint26+ | "handshake_failed"; // Noise NK failed or the ACK was malformed27+28+/**29+ * Minimal binary WebSocket seam. Production wires the real `WebSocket`; tests30+ * inject a fake. One binary message per frame.31+ */32+export interface PairingSocket {33+ send(bytes: Uint8Array): void;34+ /** Resolves with the next inbound binary frame, or rejects on close. */35+ receive(): Promise<Uint8Array>;36+ close(): void;37+ /** True if the socket closed before the next frame arrived. */38+ closedBeforeFrame(): boolean;39+}40+41+export interface PairingDialDeps {42+ /**43+ * Legacy 6-digit codes carry no address, so a host has to be found. Omit it44+ * where only pairing codes are accepted — a code without an address then45+ * simply has nowhere to go.46+ */47+ findHost?(port: number): Promise<string | null>;48+ openSocket(ip: string, port: number): Promise<PairingSocket>;49+}50+51+export interface DialledPairingChannel {52+ /** Encrypted channel — everything written to it is Noise-protected. */53+ channel: PairingTransport;54+ /** The 6-digit code the user must confirm matches the listener's screen. */55+ sas: string;56+ /** The address actually connected to, for the reconnect cache. */57+ host: string;58+ socket: PairingSocket;59+}60+61+export type DialResult =62+ | { ok: true; dialled: DialledPairingChannel }63+ | { ok: false; error: PairingDialError };64+65+/**66+ * Dial a pairing code and bring up the encrypted channel behind it.67+ *68+ * On success the socket is left OPEN and owned by the caller, which must close69+ * it. On failure it is closed here.70+ */71+export async function dialPairingChannel(72+ code: string,73+ deps: PairingDialDeps,74+): Promise<DialResult> {75+ const pairing = parsePairingCode(code);76+77+ // A pairing code already carries an endpoint; a legacy helper code does not,78+ // so the /24 has to be probed for it.79+ const port = pairing?.port ?? PAIRING_PORT;80+ const host = pairing?.ip ?? (await deps.findHost?.(PAIRING_PORT)) ?? null;81+ if (!host) return { ok: false, error: "no_host_found" };82+83+ let sock: PairingSocket;84+ try {85+ sock = await deps.openSocket(host, port);86+ } catch {87+ return { ok: false, error: "no_host_found" };88+ }89+90+ try {91+ // The legacy helper compares the code byte-for-byte against a [6]u8, so92+ // that form is normalised to exactly 6 zero-padded ASCII digits (ground93+ // truth §2); a pairing token is sent as the hex it decoded to.94+ sock.send(encodeJson(95+ pairing96+ ? { type: "PEACH_HELLO", token: pairing.tokenHex }97+ : { type: "PEACH_HELLO", code: String(code).replace(/\D/g, "").padStart(6, "0").slice(-6) },98+ ));99+100+ // A wrong or expired code gets NO error frame — the listener silently101+ // closes and rotates. That silence IS the wrong-code signal.102+ let ackBytes: Uint8Array;103+ try {104+ ackBytes = await sock.receive();105+ } catch {106+ sock.close();107+ return { ok: false, error: "invalid_code" };108+ }109+ if (sock.closedBeforeFrame() || ackBytes.length === 0) {110+ sock.close();111+ return { ok: false, error: "invalid_code" };112+ }113+114+ let ephemeralPublicKeyHex: string;115+ try {116+ const ack = decodeJson<{ type?: string; ephemeralPublicKeyHex?: string }>(ackBytes);117+ if (ack.type !== "PEACH_HELLO_ACK" || typeof ack.ephemeralPublicKeyHex !== "string") {118+ // A non-ACK frame on a matching code should not happen; treat a missing119+ // ACK exactly like a wrong code.120+ sock.close();121+ return { ok: false, error: "invalid_code" };122+ }123+ ephemeralPublicKeyHex = ack.ephemeralPublicKeyHex;124+ } catch {125+ sock.close();126+ return { ok: false, error: "invalid_code" };127+ }128+129+ const session = await noiseNKInitiator(130+ hexToBytes32(ephemeralPublicKeyHex),131+ async (msg) => sock.send(msg),132+ () => sock.receive(),133+ );134+135+ const channel: PairingTransport = {136+ async send(plaintext) { sock.send(session.encrypt(plaintext)); },137+ async receive() { return session.decrypt(await sock.receive()); },138+ };139+140+ return {141+ ok: true,142+ dialled: {143+ channel,144+ sas: derivePairingSAS(session.handshakeHash),145+ host,146+ socket: sock,147+ },148+ };149+ } catch (err) {150+ // Coarse for the UI; log the real cause so a failed dial is diagnosable151+ // from the service-worker console.152+ if (typeof console !== "undefined") console.error("[pairing-dial] handshake failed:", err);153+ sock.close();154+ return { ok: false, error: "handshake_failed" };155+ }156+}157+158+/** Adapt a real `WebSocket` to the `PairingSocket` seam (binary frames). */159+export function openPairingSocket(ip: string, port: number): Promise<PairingSocket> {160+ return new Promise((resolve, reject) => {161+ let ws: WebSocket;162+ try {163+ ws = new WebSocket(`ws://${ip}:${port}/`);164+ } catch (err) {165+ reject(err instanceof Error ? err : new Error("websocket open failed"));166+ return;167+ }168+ ws.binaryType = "arraybuffer";169+170+ const inbox: Uint8Array[] = [];171+ const readers: Array<{ resolve: (b: Uint8Array) => void; reject: (e: Error) => void }> = [];172+ let opened = false;173+ let closed = false;174+ // True iff the socket closed with no frame delivered for the pending read —175+ // the wrong/expired-code signal (the listener sends no error frame).176+ let sawCloseBeforeFrame = false;177+178+ ws.onopen = () => {179+ opened = true;180+ resolve(sock);181+ };182+ ws.onmessage = (ev: MessageEvent) => {183+ const bytes = new Uint8Array(ev.data as ArrayBuffer);184+ const r = readers.shift();185+ if (r) { sawCloseBeforeFrame = false; r.resolve(bytes); }186+ else inbox.push(bytes);187+ };188+ ws.onerror = () => {189+ if (!opened) reject(new Error("websocket connection failed"));190+ };191+ ws.onclose = () => {192+ closed = true;193+ if (readers.length) {194+ sawCloseBeforeFrame = true;195+ while (readers.length) readers.shift()!.reject(new Error("socket closed"));196+ }197+ if (!opened) reject(new Error("socket closed before open"));198+ };199+200+ const sock: PairingSocket = {201+ send(bytes: Uint8Array) { ws.send(bytes as Uint8Array<ArrayBuffer>); },202+ receive(): Promise<Uint8Array> {203+ const queued = inbox.shift();204+ if (queued) { sawCloseBeforeFrame = false; return Promise.resolve(queued); }205+ if (closed) { sawCloseBeforeFrame = true; return Promise.reject(new Error("socket closed")); }206+ return new Promise<Uint8Array>((res, rej) => readers.push({ resolve: res, reject: rej }));207+ },208+ close() { try { ws.close(); } catch { /* ignore */ } },209+ closedBeforeFrame() { return sawCloseBeforeFrame; },210+ };211+ });212+}213+214+function encodeJson(obj: unknown): Uint8Array {215+ return new TextEncoder().encode(JSON.stringify(obj));216+}217+218+function decodeJson<T>(bytes: Uint8Array): T {219+ return JSON.parse(new TextDecoder().decode(bytes)) as T;220+}221+222+function hexToBytes32(hex: string): Uint8Array {223+ if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error("expected 64-char lowercase hex");224+ const out = new Uint8Array(32);225+ for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);226+ return out;227+}