b6a62bdc472e

feat: add relay-first vault adoption (v4.0.47)

time
2026-07-26 05:08 UTC · 3 months ago
machine
koh2
parent
61a600251c9b
14 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/App.tsx+33 −7src/popup/pairing-qr-scanner.ts+21 −0src/popup/styles/popup.css+23 −0src/popup/views/AdoptVaultView.test.tsx+50 −25src/popup/views/AdoptVaultView.tsx+173 −28src/shared/pairing-dial.test.ts+55 −1src/shared/pairing-dial.ts+65 −2src/shared/pairing-invitation.test.ts+47 −0src/shared/pairing-invitation.ts+71 −0src/shared/vault-adopt.test.ts+14 −2src/shared/vault-adopt.ts+29 −1
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.0.46",4+  "version": "4.0.47",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "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.46",4+  "version": "4.0.47",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.46",3+  "version": "4.0.47",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/popup/App.tsx+33 −7
@@ -1188,22 +1188,40 @@11881188    */11891189   async function startVaultAdoption(args: {11901190     password: string;1191-    code: string;1191+    connection:1192+      | { mode: "relay"; invitation: string }1193+      | { mode: "lan"; code: string };11921194     confirmSAS(sas: string): Promise<boolean>;11931195     createVault(rootKeyHex: string): Promise<{ vaultId: string }>;11941196   }) {1195-    const [{ dialPairingChannel, openPairingSocket }, { requestAdoption }, { setLastKnownIp }] =1197+    const [{1198+      dialPairingChannel,1199+      dialRelayPairingChannel,1200+      openPairingSocket,1201+      openRelayPairingSocket,1202+    }, { requestAdoption }, { setLastKnownIp }] =11961203       await Promise.all([11971204         import("../shared/pairing-dial"),11981205         import("../shared/vault-adopt"),11991206         import("../background/paired-devices"),12001207       ]);12011208 1202-    // Adoption accepts the same pairing code as device pairing; it carries the1203-    // listener address and one-time token.1204-    const dial = await dialPairingChannel(args.code, { openSocket: openPairingSocket });1209+    const dial = args.connection.mode === "relay"1210+      ? await dialRelayPairingChannel(args.connection.invitation, {1211+        openSocket: openRelayPairingSocket,1212+      })1213+      : await dialPairingChannel(args.connection.code, {1214+        openSocket: openPairingSocket,1215+      }, { flow: "adoption" });12051216     if (!dial.ok) throw new Error(dial.error);1206-    const { channel, sas, socket, host } = dial.dialled;1217+    const {1218+      channel,1219+      sas,1220+      socket,1221+      host,1222+      relayUrl,1223+      rendezvousSecretHex,1224+    } = dial.dialled;12071225 12081226     try {12091227       const source = await requestAdoption(channel, {@@ -1212,6 +1230,7 @@12121230         // requestAdoption writes ADOPT_CONFIRM and refuses to read a root-key12131231         // grant until this local SAS decision is true.12141232         confirmSas: () => args.confirmSAS(sas),1233+        pairState: { rendezvousSecretHex, relayUrl },12151234         createVaultFromRootKey: args.createVault,12161235         deriveLocalIdentity: async () => {12171236           const res = await sendMessage({ type: "GET_LOCAL_IDENTITY" });@@ -1230,7 +1249,7 @@12301249           // API does not exist in a service worker — so getSubnet() always12311250           // returns null and connect() throws subnet_unknown forever. The cache12321251           // is the only route that ever reaches the peer.1233-          if (paired.longTermPublicKeyHex) {1252+          if (!relayUrl && paired.longTermPublicKeyHex) {12341253             await setLastKnownIp(paired.longTermPublicKeyHex, host).catch(() => undefined);12351254           }12361255         },@@ -1248,6 +1267,13 @@12481267         },12491268       });12501269       if (!source) throw new Error("adoption_rejected");1270+      if (relayUrl) {1271+        const setting = (await sendMessage({1272+          type: "SET_RELAY_SETTINGS",1273+          payload: { relayUrl, lanOnly: false },1274+        })) as { ok: boolean; error?: string };1275+        if (!setting.ok) throw new Error(setting.error ?? "relay_settings_failed");1276+      }12511277     } finally {12521278       socket.close();12531279     }

view file

src/popup/pairing-qr-scanner.ts+21 −0
@@ -0,0 +1,21 @@1+import { BrowserQRCodeReader, NotFoundException } from "@zxing/library";2+3+export interface PairingQrScannerHandle {4+  stop(): void;5+}6+7+export async function startPairingQrScanner(8+  video: HTMLVideoElement,9+  onScan: (value: string) => void,10+  onError: (error: Error) => void,11+): Promise<PairingQrScannerHandle> {12+  const reader = new BrowserQRCodeReader();13+  await reader.decodeFromVideoDevice(null, video, (result, error) => {14+    if (result) {15+      onScan(result.getText());16+      return;17+    }18+    if (error && !(error instanceof NotFoundException)) onError(error);19+  });20+  return { stop: () => reader.reset() };21+}

view file

src/popup/styles/popup.css+23 −0
@@ -7117,6 +7117,29 @@71177117   margin-bottom: 12px;71187118 }71197119 7120+.peach-adopt-card textarea.peach-adopt-input {7121+  width: 100%;7122+  resize: vertical;7123+  overflow-wrap: anywhere;7124+  font-family: "IBM Plex Mono", monospace;7125+  font-size: 0.64rem;7126+}7127+7128+.peach-adopt-qr-video {7129+  display: block;7130+  width: 100%;7131+  max-height: 220px;7132+  margin: 0 0 12px;7133+  border: 1px solid color-mix(in srgb, var(--peach-accent) 40%, transparent);7134+  border-radius: 8px;7135+  background: #000;7136+  object-fit: cover;7137+}7138+7139+.peach-adopt-card > .peach-btn + .peach-btn {7140+  margin-top: 8px;7141+}7142+71207143 .peach-adopt-actions {71217144   display: flex;71227145   gap: 10px;

view file

src/popup/views/AdoptVaultView.test.tsx+50 −25
@@ -3,11 +3,17 @@33 import type { JSX } from "solid-js";44 import { afterEach, describe, expect, it, vi } from "vitest";55 import { Onboarding } from "./Onboarding";6-import { AdoptVaultView } from "./AdoptVaultView";6+import {7+  AdoptVaultView,8+  type AdoptVaultViewProps,9+} from "./AdoptVaultView";710 import { flushView, inputText, renderView } from "./view-test-utils";8-import { encodePairingCode } from "../../shared/pairing-code";11+import { createPairingInvitation } from "../../shared/pairing-invitation";912 10-const PAIRING_CODE = encodePairingCode("192.168.4.77", 51555, Uint8Array.of(1, 2, 3, 4));13+const INVITATION = createPairingInvitation(14+  "wss://relay.peachpasswords.com",15+  "AAECAwQFBgcICQoLDA0ODw",16+);1117 1218 vi.mock("@zxcvbn-ts/core", () => ({1319   zxcvbn: (value: string) => ({ score: value.length >= 16 ? 4 : 1 }),@@ -30,10 +36,14 @@3036   return found as HTMLButtonElement;3137 }3238 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);39+async function enterInvitation(container: HTMLElement) {40+  button(container, "Enter invitation manually").click();3641   await flushView();42+  inputText(43+    container.querySelector("#adopt-relay-invitation") as HTMLTextAreaElement,44+    INVITATION,45+  );46+  await flushView();3747 }3848 3949 async function enterStrongPassword(container: HTMLElement) {@@ -85,35 +95,50 @@8595     expect(continueButton.disabled).toBe(true);8696   });8797 88-  it("T-A26: pairing cannot start until a valid pairing code is entered", async () => {89-    const startAdoption = vi.fn(async () => {});98+  it("T-A26: relay adoption cannot start until a valid invitation is entered", async () => {99+    const startAdoption = vi.fn(100+      async (_args: Parameters<AdoptVaultViewProps["startAdoption"]>[0]) => {},101+    );90102     const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} startAdoption={startAdoption} />);91103     await enterStrongPassword(view.container);92104     button(view.container, "Continue").click();93105     await flushView();94106 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");107+    expect(view.container.textContent).toContain("Connecting via secure relay");108+    button(view.container, "Enter invitation manually").click();98109     await flushView();99-    expect(button(view.container, "Pair a device").disabled).toBe(true);110+    expect(button(view.container, "Connect securely").disabled).toBe(true);111+    inputText(112+      view.container.querySelector("#adopt-relay-invitation") as HTMLTextAreaElement,113+      "peach-pair-v1:not-valid",114+    );115+    await flushView();116+    expect(button(view.container, "Connect securely").disabled).toBe(true);100117 101-    await enterPairingCode(view.container);102-    expect(button(view.container, "Pair a device").disabled).toBe(false);118+    inputText(119+      view.container.querySelector("#adopt-relay-invitation") as HTMLTextAreaElement,120+      INVITATION,121+    );122+    await flushView();123+    expect(button(view.container, "Connect securely").disabled).toBe(false);103124     expect(startAdoption).not.toHaveBeenCalled();104125   });105126 106-  it("T-A27: the typed code is handed to the adoption transport", async () => {107-    const startAdoption = vi.fn(async (_args: { code: string }) => {});127+  it("T-A27: the relay invitation is handed to the adoption transport", async () => {128+    const startAdoption = vi.fn(129+      async (_args: Parameters<AdoptVaultViewProps["startAdoption"]>[0]) => {},130+    );108131     const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} startAdoption={startAdoption} />);109132     await enterStrongPassword(view.container);110133     button(view.container, "Continue").click();111-    await enterPairingCode(view.container);112-    button(view.container, "Pair a device").click();134+    await enterInvitation(view.container);135+    button(view.container, "Connect securely").click();113136     await flushView();114137 115138     expect(startAdoption).toHaveBeenCalledTimes(1);116-    expect(startAdoption.mock.calls[0][0]).toMatchObject({ code: PAIRING_CODE });139+    expect(startAdoption.mock.calls[0][0]).toMatchObject({140+      connection: { mode: "relay", invitation: INVITATION },141+    });117142   });118143 119144   it("T-A23: adoption flow reaches the SAS screen", async () => {@@ -123,8 +148,8 @@123148     const view = mounted(() => <AdoptVaultView onCancel={() => {}} onDone={() => {}} startAdoption={startAdoption} />);124149     await enterStrongPassword(view.container);125150     button(view.container, "Continue").click();126-    await enterPairingCode(view.container);127-    button(view.container, "Pair a device").click();151+    await enterInvitation(view.container);152+    button(view.container, "Connect securely").click();128153     await flushView();129154 130155     expect(view.container.textContent).toContain("Confirm this code matches");@@ -144,8 +169,8 @@144169     );145170     await enterStrongPassword(view.container);146171     button(view.container, "Continue").click();147-    await enterPairingCode(view.container);148-    button(view.container, "Pair a device").click();172+    await enterInvitation(view.container);173+    button(view.container, "Connect securely").click();149174     await flushView();150175 151176     expect(view.container.textContent).toContain("Joined your vault.");@@ -170,8 +195,8 @@170195     );171196     await enterStrongPassword(view.container);172197     button(view.container, "Continue").click();173-    await enterPairingCode(view.container);174-    button(view.container, "Pair a device").click();198+    await enterInvitation(view.container);199+    button(view.container, "Connect securely").click();175200     await flushView();176201     button(view.container, "Cancel").click();177202     await flushView();

view file

src/popup/views/AdoptVaultView.tsx+173 −28
@@ -1,7 +1,17 @@1-import { Match, Show, Switch, createMemo, createSignal } from "solid-js";1+import {2+  Match,3+  Show,4+  Switch,5+  createMemo,6+  createSignal,7+  onCleanup,8+  onMount,9+} from "solid-js";210 import { sendMessage } from "../App";311 import { RuneBackground } from "../components/RuneBackground";412 import { formatPairingCode, parsePairingCode } from "../../shared/pairing-code";13+import { parsePairingInvitation } from "../../shared/pairing-invitation";14+import type { PairingQrScannerHandle } from "../pairing-qr-scanner";515 616 type AdoptPhase = "password" | "pair" | "sas" | "success" | "error";717 @@ -10,8 +20,9 @@1020   onDone(): void | Promise<void>;1121   startAdoption(args: {1222     password: string;13-    /** The pairing code shown by the device that already holds the vault. */14-    code: string;23+    connection:24+      | { mode: "relay"; invitation: string }25+      | { mode: "lan"; code: string };1526     confirmSAS(sas: string): Promise<boolean>;1627     createVault(rootKeyHex: string): Promise<{ vaultId: string }>;1728   }): Promise<void>;@@ -63,10 +74,36 @@6374   const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);6475   const [error, setError] = createSignal("");6576   const [code, setCode] = createSignal("");77+  const [invitation, setInvitation] = createSignal("");78+  const [lanOnly, setLanOnly] = createSignal(false);79+  const [manualEntry, setManualEntry] = createSignal(false);80+  const [scanning, setScanning] = createSignal(false);81+  const [scanError, setScanError] = createSignal("");6682   const [connecting, setConnecting] = createSignal(false);83+  let videoEl: HTMLVideoElement | undefined;84+  let qrScanner: PairingQrScannerHandle | null = null;6785 6886   const codeReady = createMemo(() => parsePairingCode(code()) !== null);87+  const invitationReady = createMemo(() =>88+    parsePairingInvitation(invitation()) !== null89+  );6990 91+  onCleanup(() => qrScanner?.stop());92+  onMount(() => {93+    void sendMessage({ type: "GET_RELAY_SETTINGS" })94+      .then((response) => {95+        const result = response as {96+          ok: boolean;97+          data?: { lanOnly?: boolean };98+        };99+        if (result.ok) setLanOnly(result.data?.lanOnly === true);100+      })101+      .catch(() => {102+        // Fresh installs default to relay mode if the background is still103+        // starting; the save path will reconcile before pairing completes.104+      });105+  });106+70107   createMemo(() => {71108     const value = password();72109     if (!value) {@@ -99,14 +136,20 @@99136   });100137 101138   async function beginPairing() {102-    if (!codeReady() || connecting()) return;139+    const connection = lanOnly()140+      ? (codeReady() ? { mode: "lan" as const, code: code() } : null)141+      : (invitationReady()142+        ? { mode: "relay" as const, invitation: invitation() }143+        : null);144+    if (!connection || connecting()) return;145+    stopScanning();103146     setPhase("pair");104147     setError("");105148     setConnecting(true);106149     try {107150       await props.startAdoption({108151         password: password(),109-        code: code(),152+        connection,110153         confirmSAS: (sasCode) => new Promise<boolean>((resolve) => {111154           setSas(sasCode);112155           setSasResolve(() => resolve);@@ -143,9 +186,56 @@143186       // The code is single-use on the other device's side too — a rejected SAS144187       // means starting over with a fresh one, not retrying this one.145188       setCode("");189+      setInvitation("");146190     }147191   }148192 193+  async function startScanning() {194+    if (scanning()) return;195+    setScanError("");196+    setScanning(true);197+    await Promise.resolve();198+    if (!videoEl) {199+      setScanning(false);200+      setScanError("Camera preview could not start. Enter the invitation instead.");201+      return;202+    }203+    try {204+      const { startPairingQrScanner } = await import("../pairing-qr-scanner");205+      qrScanner = await startPairingQrScanner(206+        videoEl,207+        (value) => {208+          if (!parsePairingInvitation(value)) return;209+          setInvitation(value);210+          stopScanning();211+          void beginPairing();212+        },213+        () => setScanError("Couldn't read that QR code. Hold it steady or enter the invitation."),214+      );215+    } catch {216+      stopScanning();217+      setScanError("Camera access is unavailable. Enter the invitation instead.");218+      setManualEntry(true);219+    }220+  }221+222+  function stopScanning() {223+    qrScanner?.stop();224+    qrScanner = null;225+    setScanning(false);226+  }227+228+  async function chooseLanOnly(next: boolean) {229+    stopScanning();230+    setLanOnly(next);231+    setManualEntry(false);232+    const response = (await sendMessage({233+      type: "SET_RELAY_SETTINGS",234+      payload: { lanOnly: next },235+    })) as { ok: boolean; error?: string };236+    if (!response.ok) setScanError(response.error ?? "Could not update sync mode.");237+  }238+149239   return (150240     <section class="peach-screen peach-theme peach-adopt-vault">151241       <RuneBackground opacity={0.07} />@@ -189,38 +279,93 @@189279               <span />190280             </div>191281             <h1>Pair with your other device.</h1>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))282+            <Show283+              when={!lanOnly()}284+              fallback={285+                <>286+                  <p>287+                    Local network only. Both devices must be on the same Wi-Fi;288+                    this will not work over VPN or from different locations.289+                  </p>290+                  <label class="peach-label" for="adopt-pair-code">local pairing code</label>291+                  <input292+                    id="adopt-pair-code"293+                    class="peach-underline-input peach-adopt-input peach-code-input"294+                    type="text"295+                    autocapitalize="characters"296+                    autocomplete="one-time-code"297+                    spellcheck={false}298+                    maxlength={32}299+                    placeholder="A74H-48J3-KI32-OEP4"300+                    aria-label="Pairing code"301+                    disabled={connecting()}302+                    value={code()}303+                    onInput={(event) =>304+                      setCode(formatPairingCode((event.target as HTMLInputElement).value).slice(0, 32))305+                    }306+                    onKeyDown={(event) => { if (event.key === "Enter") void beginPairing(); }}307+                  />308+                  <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void chooseLanOnly(false)}>309+                    Use secure relay instead310+                  </button>311+                </>211312               }212-              onKeyDown={(event) => { if (event.key === "Enter") void beginPairing(); }}213-            />313+            >314+              <p>Connecting via secure relay — works anywhere.</p>315+              <Show when={scanning()}>316+                <video317+                  ref={videoEl}318+                  class="peach-adopt-qr-video"319+                  autoplay320+                  muted321+                  playsinline322+                  aria-label="Pairing QR camera preview"323+                />324+              </Show>325+              <Show when={!scanning()}>326+                <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void startScanning()}>327+                  Scan pairing QR328+                </button>329+              </Show>330+              <button331+                class="peach-btn peach-btn--ghost peach-btn--sm"332+                onClick={() => { stopScanning(); setManualEntry(!manualEntry()); }}333+              >334+                {manualEntry() ? "Hide manual entry" : "Enter invitation manually"}335+              </button>336+              <Show when={manualEntry()}>337+                <label class="peach-label" for="adopt-relay-invitation">pairing invitation</label>338+                <textarea339+                  id="adopt-relay-invitation"340+                  class="peach-underline-input peach-adopt-input"341+                  rows={3}342+                  autocomplete="off"343+                  spellcheck={false}344+                  aria-label="Pairing invitation"345+                  disabled={connecting()}346+                  value={invitation()}347+                  onInput={(event) =>348+                    setInvitation((event.target as HTMLTextAreaElement).value.trim())349+                  }350+                />351+              </Show>352+              <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void chooseLanOnly(true)}>353+                Switch to local network only354+              </button>355+            </Show>356+            <Show when={scanError()}>357+              <p class="peach-hint peach-hint-error">{scanError()}</p>358+            </Show>214359             <Show when={connecting()}>215360               <p class="peach-hint">connecting to your other device...</p>216361             </Show>217362             <div class="peach-adopt-actions">218363               <button219364                 class="peach-btn peach-btn--accent peach-btn--md"220-                disabled={!codeReady() || connecting()}365+                disabled={(lanOnly() ? !codeReady() : !invitationReady()) || connecting()}221366                 onClick={() => void beginPairing()}222367               >223-                Pair a device368+                Connect securely224369               </button>225370               <button class="peach-btn peach-btn--ghost peach-btn--md" onClick={props.onCancel}>Cancel</button>226371             </div>

view file

src/shared/pairing-dial.test.ts+55 −1
@@ -1,13 +1,23 @@11 import { beforeEach, describe, expect, it, vi } from "vitest";22 3-import { dialPairingChannel, type PairingSocket } from "./pairing-dial";3+import {4+  dialPairingChannel,5+  dialRelayPairingChannel,6+  type PairingSocket,7+} from "./pairing-dial";48 import { encodePairingCode } from "./pairing-code";9+import { createPairingInvitation } from "./pairing-invitation";510 611 const enc = (o: unknown) => new TextEncoder().encode(JSON.stringify(o));712 const dec = (b: Uint8Array) => JSON.parse(new TextDecoder().decode(b));813 914 const LISTENER_EPHEMERAL_HEX = "a".repeat(64);1015 const CODE = encodePairingCode("192.168.4.77", 51555, Uint8Array.of(0xde, 0xad, 0xbe, 0xef));16+const RELAY_TOKEN = "AAECAwQFBgcICQoLDA0ODw";17+const RELAY_INVITATION = createPairingInvitation(18+  "wss://relay.example.test/base",19+  RELAY_TOKEN,20+);1121 1222 const FAKE_HANDSHAKE_HASH = Uint8Array.from([0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0)]);1323 @@ -80,6 +90,22 @@8090     );8191   });8292 93+  it("marks LAN adoption before Noise so the source can skip device-pair auth", async () => {94+    const s = ackThen();95+    const result = await dialPairingChannel(96+      CODE,97+      { openSocket: async () => s.sock },98+      { flow: "adoption" },99+    );100+101+    expect(result.ok).toBe(true);102+    expect(dec(s.sent[0])).toEqual({103+      type: "PEACH_HELLO",104+      token: "deadbeef",105+      flow: "adoption",106+    });107+  });108+83109   it("rejects an addressless legacy code before opening a socket", async () => {84110     const openSocket = vi.fn(async () => ackThen().sock);85111 @@ -134,5 +160,33 @@134160 135161     result.dialled.socket.close();136162     expect(s.isClosed()).toBe(true);163+  });164+});165+166+describe("dialRelayPairingChannel", () => {167+  it("occupies the invited relay room and runs the identical hello/NK path", async () => {168+    const s = ackThen();169+    const openSocket = vi.fn(async () => s.sock);170+171+    const result = await dialRelayPairingChannel(RELAY_INVITATION, { openSocket });172+173+    expect(result.ok).toBe(true);174+    expect(openSocket).toHaveBeenCalledWith(175+      `wss://relay.example.test/base/r/${RELAY_TOKEN}`,176+    );177+    expect(dec(s.sent[0])).toEqual({ type: "PEACH_HELLO", token: RELAY_TOKEN });178+    expect(result.ok && result.dialled.relayUrl).toBe(179+      "wss://relay.example.test/base",180+    );181+    expect(result.ok && result.dialled.host).toBe("relay.example.test");182+  });183+184+  it("rejects a malformed invitation before opening a socket", async () => {185+    const openSocket = vi.fn(async () => ackThen().sock);186+    const result = await dialRelayPairingChannel("peach-pair-v1:not-valid", {187+      openSocket,188+    });189+    expect(result).toEqual({ ok: false, error: "invalid_code" });190+    expect(openSocket).not.toHaveBeenCalled();137191   });138192 });

view file

src/shared/pairing-dial.ts+65 −2
@@ -16,6 +16,10 @@1616 import { noiseNKInitiator, performInitiatorAuth } from "./noise-js";1717 import { derivePairingSAS, type PairingTransport } from "./pairing-handshake";1818 import { parsePairingCode } from "./pairing-code";19+import {20+  pairingInvitationUrl,21+  parsePairingInvitation,22+} from "./pairing-invitation";1923 import { bytesToHex, deriveRendezvousSecret } from "./rendezvous";2024 2125 /** Default pairing port used by native listeners. */@@ -43,6 +47,14 @@4347   openSocket(ip: string, port: number): Promise<PairingSocket>;4448 }4549 50+export interface PairingDialOptions {51+  flow?: "adoption";52+}53+54+export interface RelayPairingDialDeps {55+  openSocket(url: string): Promise<PairingSocket>;56+}57+4658 export interface DialledPairingChannel {4759   /** Encrypted channel — everything written to it is Noise-protected. */4860   channel: PairingTransport;@@ -50,6 +62,8 @@5062   sas: string;5163   /** The address actually connected to, for the reconnect cache. */5264   host: string;65+  /** Present only for relay-first pairing/adoption. */66+  relayUrl?: string;5367   /** Pair-only reconnect capability derived independently from the SAS. */5468   rendezvousSecretHex: string;5569   /** Static key authenticated by Noise NK. PAIR_IDENTITY must repeat this key. */@@ -79,6 +93,7 @@7993 export async function dialPairingChannel(8094   code: string,8195   deps: PairingDialDeps,96+  options: PairingDialOptions = {},8297 ): Promise<DialResult> {8398   const pairing = parsePairingCode(code);8499   if (!pairing) return { ok: false, error: "invalid_code" };@@ -91,9 +106,48 @@91106     return { ok: false, error: "no_host_found" };92107   }93108 109+  return finishPairingHandshake(sock, pairing.tokenHex, host, options.flow);110+}111+112+/**113+ * Dial a relay-first invitation. The source must already occupy the room114+ * because the relay deliberately buffers nothing.115+ */116+export async function dialRelayPairingChannel(117+  encodedInvitation: string,118+  deps: RelayPairingDialDeps,119+): Promise<DialResult> {120+  const invitation = parsePairingInvitation(encodedInvitation);121+  if (!invitation) return { ok: false, error: "invalid_code" };122+123+  let sock: PairingSocket;94124   try {95-    sock.send(encodeJson({ type: "PEACH_HELLO", token: pairing.tokenHex }));125+    sock = await deps.openSocket(pairingInvitationUrl(invitation));126+  } catch {127+    return { ok: false, error: "no_host_found" };128+  }129+  const result = await finishPairingHandshake(130+    sock,131+    invitation.token,132+    new URL(invitation.relayUrl).hostname,133+  );134+  if (result.ok) result.dialled.relayUrl = invitation.relayUrl;135+  return result;136+}96137 138+async function finishPairingHandshake(139+  sock: PairingSocket,140+  token: string,141+  host: string,142+  flow?: "adoption",143+): Promise<DialResult> {144+  try {145+    sock.send(encodeJson({146+      type: "PEACH_HELLO",147+      token,148+      ...(flow ? { flow } : {}),149+    }));150+97151     // A wrong or expired code gets NO error frame — the listener silently98152     // closes and rotates. That silence IS the wrong-code signal.99153     let ackBytes: Uint8Array;@@ -166,10 +220,19 @@166220 167221 /** Adapt a real `WebSocket` to the `PairingSocket` seam (binary frames). */168222 export function openPairingSocket(ip: string, port: number): Promise<PairingSocket> {223+  return openPairingSocketUrl(`ws://${ip}:${port}/`);224+}225+226+/** Relay-first pairing uses the same binary socket seam over `wss://`. */227+export function openRelayPairingSocket(url: string): Promise<PairingSocket> {228+  return openPairingSocketUrl(url);229+}230+231+function openPairingSocketUrl(url: string): Promise<PairingSocket> {169232   return new Promise((resolve, reject) => {170233     let ws: WebSocket;171234     try {172-      ws = new WebSocket(`ws://${ip}:${port}/`);235+      ws = new WebSocket(url);173236     } catch (err) {174237       reject(err instanceof Error ? err : new Error("websocket open failed"));175238       return;

view file

src/shared/pairing-invitation.test.ts+47 −0
@@ -0,0 +1,47 @@1+import { describe, expect, it } from "vitest";2+3+import {4+  PAIRING_INVITATION_PREFIX,5+  createPairingInvitation,6+  pairingInvitationUrl,7+  parsePairingInvitation,8+} from "./pairing-invitation";9+10+const TOKEN = "AAECAwQFBgcICQoLDA0ODw";11+const RELAY = "wss://relay.peachpasswords.com";12+const KAT =13+  "peach-pair-v1:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyJ9";14+15+describe("relay pairing invitation", () => {16+  it("pins the cross-client compact JSON/base64url vector", () => {17+    expect(createPairingInvitation(`${RELAY}/`, TOKEN)).toBe(KAT);18+    expect(parsePairingInvitation(KAT)).toEqual({ relayUrl: RELAY, token: TOKEN });19+  });20+21+  it("builds a room URL without identity or vault state", () => {22+    const parsed = parsePairingInvitation(KAT);23+    expect(parsed).not.toBeNull();24+    expect(pairingInvitationUrl(parsed!)).toBe(`${RELAY}/r/${TOKEN}`);25+    expect(KAT).not.toContain("device");26+    expect(KAT).not.toContain("vault");27+  });28+29+  it("fails closed on malformed, insecure, or short-token payloads", () => {30+    expect(parsePairingInvitation("not-an-invitation")).toBeNull();31+    expect(parsePairingInvitation(`${PAIRING_INVITATION_PREFIX}%%%`)).toBeNull();32+    const insecure = createRaw({ relay: "ws://relay.example.test", token: TOKEN });33+    expect(parsePairingInvitation(insecure)).toBeNull();34+    const shortToken = createRaw({ relay: RELAY, token: "too-short" });35+    expect(parsePairingInvitation(shortToken)).toBeNull();36+  });37+});38+39+function createRaw(value: unknown): string {40+  const bytes = new TextEncoder().encode(JSON.stringify(value));41+  let binary = "";42+  for (const byte of bytes) binary += String.fromCharCode(byte);43+  return `${PAIRING_INVITATION_PREFIX}${btoa(binary)44+    .replace(/\+/g, "-")45+    .replace(/\//g, "_")46+    .replace(/=+$/g, "")}`;47+}

view file

src/shared/pairing-invitation.ts+71 −0
@@ -0,0 +1,71 @@1+import {2+  buildRendezvousUrl,3+  generatePairingRendezvousToken,4+  normalizeRelayUrl,5+} from "./rendezvous";6+7+export const PAIRING_INVITATION_PREFIX = "peach-pair-v1:";8+9+export interface PairingInvitation {10+  relayUrl: string;11+  token: string;12+}13+14+/**15+ * QR/manual first-pair payload. It contains reachability only: a credential-free16+ * relay URL and a fresh 128-bit bearer token. No identity or vault material is17+ * present before Noise and the two-person SAS gate.18+ */19+export function createPairingInvitation(20+  relayUrl: string,21+  token = generatePairingRendezvousToken(),22+): string {23+  const normalized = normalizeRelayUrl(relayUrl);24+  assertToken(token);25+  const json = JSON.stringify({ relay: normalized, token });26+  return `${PAIRING_INVITATION_PREFIX}${base64urlEncode(new TextEncoder().encode(json))}`;27+}28+29+export function parsePairingInvitation(input: string): PairingInvitation | null {30+  const value = input.trim();31+  if (!value.startsWith(PAIRING_INVITATION_PREFIX)) return null;32+  try {33+    const json = new TextDecoder("utf-8", { fatal: true }).decode(34+      base64urlDecode(value.slice(PAIRING_INVITATION_PREFIX.length)),35+    );36+    const parsed = JSON.parse(json) as { relay?: unknown; token?: unknown };37+    if (typeof parsed.relay !== "string" || typeof parsed.token !== "string") return null;38+    assertToken(parsed.token);39+    return {40+      relayUrl: normalizeRelayUrl(parsed.relay),41+      token: parsed.token,42+    };43+  } catch {44+    return null;45+  }46+}47+48+export function pairingInvitationUrl(invitation: PairingInvitation): string {49+  return buildRendezvousUrl(invitation.relayUrl, invitation.token);50+}51+52+function assertToken(token: string): void {53+  // A first-pair token is exactly 16 bytes encoded without padding.54+  if (!/^[A-Za-z0-9_-]{22}$/.test(token)) {55+    throw new Error("pairing invitation token must be 128-bit base64url");56+  }57+}58+59+function base64urlEncode(bytes: Uint8Array): string {60+  let binary = "";61+  for (const byte of bytes) binary += String.fromCharCode(byte);62+  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");63+}64+65+function base64urlDecode(value: string): Uint8Array {66+  if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error("invalid base64url");67+  const padded = value.replace(/-/g, "+").replace(/_/g, "/")68+    .padEnd(Math.ceil(value.length / 4) * 4, "=");69+  const binary = atob(padded);70+  return Uint8Array.from(binary, (char) => char.charCodeAt(0));71+}

view file

src/shared/vault-adopt.test.ts+14 −2
@@ -54,6 +54,10 @@5454     const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));5555     const sourceUpsert = vi.fn();5656     const receiverUpsert = vi.fn();57+    const pairState = {58+      rendezvousSecretHex: "42".repeat(32),59+      relayUrl: "wss://relay.example.test",60+    };5761     const receiverDeps: AdoptionReceiverDeps = {5862       deviceName: "Receiver",5963       generateDeviceId: () => "receiver-ephemeral",@@ -62,6 +66,7 @@6266       deriveLocalIdentity: vi.fn(async () => identity("receiver")),6367       upsertPairedDevice: receiverUpsert,6468       applyLicense: vi.fn(),69+      pairState,6570       onError: vi.fn(),6671     };6772     const sourceDeps: AdoptionSourceDeps = {@@ -71,6 +76,7 @@7176       license: vi.fn(async () => "PPK-test"),7277       onConsent: vi.fn(async () => true),7378       upsertPairedDevice: sourceUpsert,79+      pairState,7480     };7581 7682     await Promise.all([@@ -82,8 +88,14 @@8288     ]);8389 8490     expect(receiverDeps.createVaultFromRootKey).toHaveBeenCalledWith(rootKeyHex);85-    expect(receiverUpsert).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "source-id" }));86-    expect(sourceUpsert).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "receiver-id" }));91+    expect(receiverUpsert).toHaveBeenCalledWith(expect.objectContaining({92+      deviceId: "source-id",93+      ...pairState,94+    }));95+    expect(sourceUpsert).toHaveBeenCalledWith(expect.objectContaining({96+      deviceId: "receiver-id",97+      ...pairState,98+    }));8799     expect(receiverDeps.applyLicense).toHaveBeenCalledWith("PPK-test");88100   });89101 

view file

src/shared/vault-adopt.ts+29 −1
@@ -21,6 +21,7 @@2121     long_term_public_key_hex: string;2222     signing_public_key_hex?: string;2323     ws_port?: number;24+    relay_url?: string;2425   };2526 }2627 @@ -44,6 +45,11 @@4445   | AdoptGrant4546   | AdoptIdentity;4647 48+export interface AdoptionPairState {49+  rendezvousSecretHex: string;50+  relayUrl?: string;51+}52+4753 export interface AdoptionReceiverDeps {4854   deviceName: string;4955   generateDeviceId(): string;@@ -53,6 +59,8 @@5359   deriveLocalIdentity(): Promise<LocalIdentity>;5460   upsertPairedDevice(device: PairedDevice): Promise<void>;5561   applyLicense?(license: string): Promise<void>;62+  /** Bound to the same completed Noise session, never taken from frame claims. */63+  pairState?: AdoptionPairState;5664   onError(error: string): void;5765 }5866 @@ -63,6 +71,8 @@6371   license?(): Promise<string | undefined>;6472   onConsent(request: AdoptRequest): Promise<boolean>;6573   upsertPairedDevice(device: PairedDevice): Promise<void>;74+  /** Bound to the same completed Noise session, never taken from frame claims. */75+  pairState?: AdoptionPairState;6676 }6777 6878 export function isAdoptRequest(frame: unknown): frame is AdoptRequest {@@ -86,7 +96,10 @@8696     typeof value.source.device_name === "string" &&8797     isDeviceType(value.source.device_type) &&8898     typeof value.source.long_term_public_key_hex === "string" &&89-    /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex);99+    /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex) &&100+    (value.source.relay_url === undefined ||101+      (typeof value.source.relay_url === "string" &&102+        isSecureRelayUrl(value.source.relay_url)));90103 }91104 92105 export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {@@ -174,6 +187,8 @@174187     signingPublicKeyHex: frame.source.signing_public_key_hex,175188     trustSource: "direct",176189     wsPort: frame.source.ws_port,190+    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,191+    relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url,177192     pairedAt: Date.now(),178193   };179194   await deps.upsertPairedDevice(sourceDevice);@@ -206,6 +221,7 @@206221       long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,207222       signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,208223       ws_port: deps.sourceIdentity.wsPort,224+      relay_url: deps.pairState?.relayUrl,209225     },210226   });211227 @@ -219,6 +235,8 @@219235     longTermPublicKeyHex: identity.long_term_public_key_hex,220236     signingPublicKeyHex: identity.signing_public_key_hex,221237     trustSource: "direct",238+    rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,239+    relayUrl: deps.pairState?.relayUrl,222240     pairedAt: Date.now(),223241   };224242   await deps.upsertPairedDevice(device);@@ -235,6 +253,16 @@235253 236254 function isDeviceType(value: unknown): value is DeviceType {237255   return value === "extension" || value === "android" || value === "ios";256+}257+258+function isSecureRelayUrl(value: string): boolean {259+  try {260+    const url = new URL(value);261+    return url.protocol === "wss:" && !url.username && !url.password &&262+      !url.search && !url.hash;263+  } catch {264+    return false;265+  }238266 }239267 240268 function hexToBytes(hex: string): Uint8Array {

view file