b6a62bdc472e
feat: add relay-first vault adoption (v4.0.47)
- time
- 2026-07-26 05:08 UTC · 3 months ago
- machine
- koh2
- parent
- 61a600251c9b
↓ peach-browser-b6a62bdc.tar.gzatlas at this save
files changed14 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 −1manifest.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": {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",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",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 }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+}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;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();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>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 });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;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+}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+}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 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 {