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
10 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 −0
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.26",5+  "version": "4.0.27",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.26",4+  "version": "4.0.27",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.26",3+  "version": "4.0.27",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

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();

view file

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 }

view file

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() {

view file

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();

view file

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>

view file

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+});

view file

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+}

view file