924fa824995d

feat: add safe inverse vault adoption (v4.0.48)

time
2026-07-26 05:37 UTC · 3 months ago
machine
koh2
parent
b6a62bdc472e
13 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.ts+50 −0src/background/relay-adoption-source-bg.test.ts+217 −0src/background/relay-adoption-source-bg.ts+259 −0src/background/vault-createVaultFromRootKey.test.ts+21 −0src/background/vault.ts+3 −0src/popup/components/PairingQrCode.tsx+38 −0src/popup/styles/popup.css+30 −0src/popup/views/PairDevice.test.tsx+98 −3src/popup/views/PairDevice.tsx+208 −3src/shared/types.ts+5 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.0.47",4+  "version": "4.0.48",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.47",4+  "version": "4.0.48",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.47",3+  "version": "4.0.48",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/messages.ts+50 −0
@@ -1454,6 +1454,56 @@14541454         return { ok: true };14551455       }14561456 1457+      case "START_ADOPTION_SOURCE": {1458+        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1459+        const {1460+          isRelayAdoptionSourceInFlight,1461+          startRelayAdoptionSource,1462+        } = await import("./relay-adoption-source-bg");1463+        if (isRelayAdoptionSourceInFlight()) {1464+          return { ok: false, error: "adoption_source_in_progress" };1465+        }1466+        try {1467+          return { ok: true, data: await startRelayAdoptionSource() };1468+        } catch (error) {1469+          return {1470+            ok: false,1471+            error: error instanceof Error ? error.message : "adoption_source_failed",1472+          };1473+        }1474+      }1475+1476+      case "ADOPTION_SOURCE_CONFIRM": {1477+        const { password, cancel } = (msg.payload ?? {}) as {1478+          password?: unknown;1479+          cancel?: unknown;1480+        };1481+        const { confirmRelayAdoptionSource } = await import(1482+          "./relay-adoption-source-bg"1483+        );1484+        if (cancel === true) {1485+          return {1486+            ok: true,1487+            data: await confirmRelayAdoptionSource(null),1488+          };1489+        }1490+        if (typeof password !== "string" || password.length === 0) {1491+          return { ok: false, error: "invalid_payload" };1492+        }1493+        return {1494+          ok: true,1495+          data: await confirmRelayAdoptionSource(password),1496+        };1497+      }1498+1499+      case "CANCEL_ADOPTION_SOURCE": {1500+        const { cancelRelayAdoptionSource } = await import(1501+          "./relay-adoption-source-bg"1502+        );1503+        cancelRelayAdoptionSource();1504+        return { ok: true };1505+      }1506+14571507       case "TRIGGER_SYNC": {14581508         const payload = (msg.payload ?? {}) as {14591509           reason?: "vault_change" | "foreground" | "manual";

view file

src/background/relay-adoption-source-bg.test.ts+217 −0
@@ -0,0 +1,217 @@1+import { beforeEach, describe, expect, it, vi } from "vitest";2+3+const mocks = vi.hoisted(() => ({4+  openSocket: vi.fn(),5+  getLocalIdentity: vi.fn(),6+  verifyPassword: vi.fn(),7+  getRootKeyHex: vi.fn(),8+  upsert: vi.fn(),9+  pushes: [] as Array<{ type: string; payload: unknown }>,10+  triggerSync: vi.fn(),11+}));12+13+vi.mock("../shared/pairing-dial", () => ({14+  openRelayPairingSocket: mocks.openSocket,15+}));16+17+vi.mock("../shared/noise-js", () => ({18+  noiseNKResponder: vi.fn(async () => ({19+    handshakeHash: new Uint8Array(32).fill(7),20+    encrypt: (bytes: Uint8Array) => bytes,21+    decrypt: (bytes: Uint8Array) => bytes,22+  })),23+}));24+25+vi.mock("../shared/rendezvous", async (importOriginal) => {26+  const actual = await importOriginal<typeof import("../shared/rendezvous")>();27+  return {28+    ...actual,29+    generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",30+  };31+});32+33+vi.mock("./code-pairing-bg", () => ({34+  productionCodePairingDeps: () => ({35+    getLocalIdentity: mocks.getLocalIdentity,36+  }),37+}));38+39+vi.mock("./relay-settings", () => ({40+  getRelaySettings: vi.fn(async () => ({41+    relayUrl: "wss://relay.peachpasswords.com",42+    lanOnly: false,43+  })),44+}));45+46+vi.mock("./license", () => ({47+  isProUser: vi.fn(async () => false),48+  getLicenseKey: vi.fn(async () => undefined),49+}));50+51+vi.mock("./vault", () => ({52+  isUnlocked: vi.fn(() => true),53+  verifyMainPassword: mocks.verifyPassword,54+  getRootKeyHex: mocks.getRootKeyHex,55+}));56+57+vi.mock("../shared/sync-identity", () => ({58+  computeVaultId: vi.fn(async () => "b".repeat(64)),59+}));60+61+vi.mock("./paired-devices", () => ({62+  upsertPairedDevice: mocks.upsert,63+}));64+65+vi.mock("./sync-trigger", () => ({66+  triggerSyncIfReachable: mocks.triggerSync,67+}));68+69+import {70+  cancelRelayAdoptionSource,71+  confirmRelayAdoptionSource,72+  startRelayAdoptionSource,73+} from "./relay-adoption-source-bg";74+75+const encode = (value: unknown) =>76+  new TextEncoder().encode(JSON.stringify(value));77+78+function socketWithFrames(frames: unknown[]) {79+  const queue = frames.map(encode);80+  return {81+    send: vi.fn(),82+    receive: vi.fn(async () => {83+      const next = queue.shift();84+      if (!next) return new Promise<Uint8Array>(() => undefined);85+      return next;86+    }),87+    close: vi.fn(),88+    closedBeforeFrame: vi.fn(() => false),89+  };90+}91+92+async function waitForPush(type: string): Promise<void> {93+  await vi.waitFor(() => {94+    expect(mocks.pushes.some((message) => message.type === type)).toBe(true);95+  });96+}97+98+beforeEach(() => {99+  mocks.pushes.length = 0;100+  mocks.openSocket.mockReset();101+  mocks.getLocalIdentity.mockReset();102+  mocks.verifyPassword.mockReset();103+  mocks.getRootKeyHex.mockReset();104+  mocks.upsert.mockReset();105+  mocks.triggerSync.mockReset();106+  mocks.triggerSync.mockResolvedValue(undefined);107+  mocks.getLocalIdentity.mockResolvedValue({108+    identity: {109+      deviceId: "browser-source",110+      deviceName: "June's Browser",111+      deviceType: "extension",112+      longTermPublicKeyHex: "a".repeat(64),113+      vaultId: "b".repeat(64),114+    },115+    privateKey: new Uint8Array(32).fill(1),116+    publicKey: new Uint8Array(32).fill(2),117+  });118+  mocks.getRootKeyHex.mockReturnValue("c".repeat(64));119+  mocks.upsert.mockResolvedValue(undefined);120+  Object.defineProperty(globalThis, "chrome", {121+    configurable: true,122+    value: {123+    runtime: {124+      sendMessage(message: { type: string; payload: unknown }) {125+        mocks.pushes.push(message);126+        return Promise.resolve();127+      },128+    },129+    },130+  });131+});132+133+describe("relay adoption source background boundary", () => {134+  it("does not access the root for a wrong password and only grants after both confirmations", async () => {135+    const socket = socketWithFrames([136+      {137+        type: "PEACH_HELLO",138+        token: "AAECAwQFBgcICQoLDA0ODw",139+      },140+      {141+        type: "ADOPT_REQUEST",142+        device_id: "request-only-label",143+        device_name: "June's phone",144+        device_type: "android",145+      },146+      { type: "ADOPT_CONFIRM", confirmed: true },147+      {148+        type: "ADOPT_IDENTITY",149+        device_id: "android-receiver",150+        device_name: "June's phone",151+        device_type: "android",152+        long_term_public_key_hex: "d".repeat(64),153+      },154+    ]);155+    mocks.openSocket.mockResolvedValue(socket);156+    mocks.verifyPassword157+      .mockResolvedValueOnce(false)158+      .mockResolvedValueOnce(true);159+160+    const started = await startRelayAdoptionSource();161+    expect(started.invitation).toMatch(/^peach-pair-v1:/);162+    await waitForPush("ADOPTION_SOURCE_SAS");163+    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();164+165+    await expect(confirmRelayAdoptionSource("wrong")).resolves.toEqual({166+      valid: false,167+    });168+    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();169+170+    await expect(confirmRelayAdoptionSource("correct")).resolves.toEqual({171+      valid: true,172+    });173+    await waitForPush("ADOPTION_SOURCE_RESULT");174+175+    expect(mocks.getRootKeyHex).toHaveBeenCalled();176+    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({177+      deviceId: "android-receiver",178+      relayUrl: "wss://relay.peachpasswords.com",179+    }));180+    expect(mocks.pushes).toContainEqual(expect.objectContaining({181+      type: "ADOPTION_SOURCE_RESULT",182+      payload: expect.objectContaining({ status: "complete" }),183+    }));184+    expect(mocks.triggerSync).toHaveBeenCalledWith({185+      reason: "manual",186+      force: true,187+    });188+  });189+190+  it("fails closed on cancellation without reading or sending the root", async () => {191+    const socket = socketWithFrames([192+      {193+        type: "PEACH_HELLO",194+        token: "AAECAwQFBgcICQoLDA0ODw",195+      },196+      {197+        type: "ADOPT_REQUEST",198+        device_id: "request-only-label",199+        device_name: "New phone",200+        device_type: "android",201+      },202+    ]);203+    mocks.openSocket.mockResolvedValue(socket);204+205+    await startRelayAdoptionSource();206+    await waitForPush("ADOPTION_SOURCE_SAS");207+    cancelRelayAdoptionSource();208+    await waitForPush("ADOPTION_SOURCE_RESULT");209+210+    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();211+    expect(mocks.upsert).not.toHaveBeenCalled();212+    expect(mocks.pushes).toContainEqual({213+      type: "ADOPTION_SOURCE_RESULT",214+      payload: { status: "error", error: "adoption_rejected" },215+    });216+  });217+});

view file

src/background/relay-adoption-source-bg.ts+259 −0
@@ -0,0 +1,259 @@1+import {2+  createPairingInvitation,3+  pairingInvitationUrl,4+  parsePairingInvitation,5+} from "../shared/pairing-invitation";6+import {7+  openRelayPairingSocket,8+  type PairingSocket,9+} from "../shared/pairing-dial";10+import { noiseNKResponder } from "../shared/noise-js";11+import {12+  grantAdoption,13+  isAdoptRequest,14+  receiveFrame,15+  type AdoptRequest,16+} from "../shared/vault-adopt";17+import {18+  derivePairingSAS,19+  type PairingTransport,20+} from "../shared/pairing-handshake";21+import {22+  bytesToHex,23+  deriveRendezvousSecret,24+  generatePairingRendezvousToken,25+} from "../shared/rendezvous";26+import { productionCodePairingDeps } from "./code-pairing-bg";27+import { getRelaySettings } from "./relay-settings";28+import { getLicenseKey, isProUser } from "./license";29+import { getRootKeyHex, isUnlocked, verifyMainPassword } from "./vault";30+import { computeVaultId } from "../shared/sync-identity";31+import { upsertPairedDevice } from "./paired-devices";32+import { triggerSyncIfReachable } from "./sync-trigger";33+34+const SOURCE_WINDOW_MS = 5 * 60 * 1000;35+36+function push(type: string, payload: unknown): void {37+  try {38+    const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;39+    if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {40+      (maybe as Promise<unknown>).catch(() => undefined);41+    }42+  } catch {43+    // The popup may have closed. The pending approval fails closed on cleanup.44+  }45+}46+47+let inFlight = false;48+let activeSocket: PairingSocket | null = null;49+let pendingConsent: ((approved: boolean) => void) | null = null;50+51+export function isRelayAdoptionSourceInFlight(): boolean {52+  return inFlight;53+}54+55+/**56+ * Occupy the non-buffering room before returning the invitation to the UI.57+ * All long-term key material remains in the background service worker.58+ */59+export async function startRelayAdoptionSource(): Promise<{60+  invitation: string;61+}> {62+  if (inFlight) throw new Error("adoption_source_in_progress");63+  if (!isUnlocked()) throw new Error("vault_locked");64+  inFlight = true;65+66+  let local: Awaited<67+    ReturnType<ReturnType<typeof productionCodePairingDeps>["getLocalIdentity"]>68+  > | null = null;69+  try {70+    local = await productionCodePairingDeps().getLocalIdentity();71+    const relayUrl = (await getRelaySettings()).relayUrl;72+    const token = generatePairingRendezvousToken();73+    const encoded = createPairingInvitation(relayUrl, token);74+    const invitation = parsePairingInvitation(encoded);75+    if (!invitation) throw new Error("invitation_generation_failed");76+77+    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));78+    activeSocket = socket;79+    const readyLocal = local;80+    void runSourceSession(81+      socket,82+      token,83+      relayUrl,84+      readyLocal,85+    ).finally(() => {86+      readyLocal.privateKey.fill(0);87+      readyLocal.publicKey.fill(0);88+      clearState();89+    });90+    return { invitation: encoded };91+  } catch (error) {92+    local?.privateKey.fill(0);93+    local?.publicKey.fill(0);94+    clearState();95+    throw error;96+  }97+}98+99+/**100+ * Wrong passwords do not consume the pending decision; cancel does.101+ */102+export async function confirmRelayAdoptionSource(103+  password: string | null,104+): Promise<{ valid: boolean }> {105+  const pending = pendingConsent;106+  if (!pending) return { valid: false };107+  if (password === null) {108+    pendingConsent = null;109+    pending(false);110+    return { valid: true };111+  }112+  const valid = await verifyMainPassword(password);113+  if (valid) {114+    pendingConsent = null;115+    pending(true);116+  }117+  return { valid };118+}119+120+export function cancelRelayAdoptionSource(): void {121+  pendingConsent?.(false);122+  pendingConsent = null;123+  activeSocket?.close();124+  clearState();125+}126+127+async function runSourceSession(128+  socket: PairingSocket,129+  expectedToken: string,130+  relayUrl: string,131+  local: Awaited<ReturnType<ReturnType<typeof productionCodePairingDeps>["getLocalIdentity"]>>,132+): Promise<void> {133+  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);134+  try {135+    const hello = decodeJson<{ type?: unknown; token?: unknown }>(136+      await socket.receive(),137+    );138+    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||139+        !constantTimeEqual(hello.token, expectedToken)) {140+      throw new Error("invalid_invitation");141+    }142+    socket.send(encodeJson({143+      type: "PEACH_HELLO_ACK",144+      ephemeralPublicKeyHex: bytesToHex(local.publicKey),145+    }));146+147+    const noise = await noiseNKResponder(148+      local.privateKey,149+      local.publicKey,150+      async (bytes) => socket.send(bytes),151+      () => socket.receive(),152+    );153+    const channel: PairingTransport = {154+      async send(plaintext) { socket.send(noise.encrypt(plaintext)); },155+      async receive() { return noise.decrypt(await socket.receive()); },156+    };157+    const request = await receiveFrame(channel);158+    if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");159+160+    const device = await grantAdoption(channel, request, {161+      sourceIdentity: local.identity,162+      pairState: {163+        rendezvousSecretHex: bytesToHex(164+          deriveRendezvousSecret(noise.handshakeHash),165+        ),166+        relayUrl,167+      },168+      async rootKeyHex() {169+        // grantAdoption calls this only after local password approval and the170+        // receiver's encrypted ADOPT_CONFIRM(true).171+        const value = getRootKeyHex();172+        if (!value) throw new Error("vault_locked");173+        return value;174+      },175+      async vaultId() {176+        const value = getRootKeyHex();177+        if (!value) throw new Error("vault_locked");178+        const bytes = hexToBytes(value);179+        try {180+          return await computeVaultId(bytes);181+        } finally {182+          bytes.fill(0);183+        }184+      },185+      async license() {186+        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;187+      },188+      onConsent: (adoptRequest) => requestConsent(189+        adoptRequest,190+        derivePairingSAS(noise.handshakeHash),191+      ),192+      upsertPairedDevice,193+    });194+    if (!device) {195+      push("ADOPTION_SOURCE_RESULT", {196+        status: "error",197+        error: "adoption_rejected",198+      });199+      return;200+    }201+    push("ADOPTION_SOURCE_RESULT", { status: "complete", device });202+    // The Android receiver starts its relay listener as soon as its source203+    // record is durable. Try once now and once after service startup settles so204+    // the newly adopted empty vault receives its first sync without another205+    // user action. Both calls are best-effort and contain no user data.206+    void triggerSyncIfReachable({ reason: "manual", force: true });207+    setTimeout(() => {208+      void triggerSyncIfReachable({ reason: "manual", force: true });209+    }, 1_000);210+  } catch {211+    push("ADOPTION_SOURCE_RESULT", {212+      status: "error",213+      error: "adoption_failed",214+    });215+  } finally {216+    clearTimeout(timeout);217+    socket.close();218+  }219+}220+221+function requestConsent(request: AdoptRequest, sas: string): Promise<boolean> {222+  push("ADOPTION_SOURCE_SAS", {223+    sas,224+    receiverDeviceName: request.device_name,225+  });226+  return new Promise<boolean>((resolve) => {227+    pendingConsent = resolve;228+  });229+}230+231+function clearState(): void {232+  pendingConsent?.(false);233+  pendingConsent = null;234+  activeSocket = null;235+  inFlight = false;236+}237+238+function constantTimeEqual(a: string, b: string): boolean {239+  if (a.length !== b.length) return false;240+  let diff = 0;241+  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);242+  return diff === 0;243+}244+245+function encodeJson(value: unknown): Uint8Array {246+  return new TextEncoder().encode(JSON.stringify(value));247+}248+249+function decodeJson<T>(value: Uint8Array): T {250+  return JSON.parse(new TextDecoder().decode(value)) as T;251+}252+253+function hexToBytes(hex: string): Uint8Array {254+  const out = new Uint8Array(hex.length / 2);255+  for (let i = 0; i < out.length; i++) {256+    out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);257+  }258+  return out;259+}

view file

src/background/vault-createVaultFromRootKey.test.ts+21 −0
@@ -123,6 +123,27 @@123123     expect(vaultId).toBe(await computeVaultId(new Uint8Array(Buffer.from(rootKeyHex, "hex"))));124124   });125125 126+  it("T-A06: refuses to overwrite an existing local vault", async () => {127+    const vault = await import("./vault");128+    await vault.createVault(RECEIVER_PASSWORD);129+    const originalRoot = vault.getRootKeyHex();130+    await vault.addEntry({131+      type: "login",132+      title: "Must survive",133+      username: "june",134+      password: "local-only",135+    });136+137+    await expect(138+      vault.createVaultFromRootKey(RECEIVER_PASSWORD, "44".repeat(32)),139+    ).rejects.toThrow("ADOPT_VAULT_ALREADY_EXISTS");140+141+    expect(vault.getRootKeyHex()).toBe(originalRoot);142+    expect(vault.getEntries()).toContainEqual(143+      expect.objectContaining({ title: "Must survive" }),144+    );145+  });146+126147   it("T-A30: storage failure leaves the receiver vault-less with no partial slot", async () => {127148     const mock = makeChromeStorageMock();128149     const storageSet = mock.chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;

view file

src/background/vault.ts+3 −0
@@ -1088,6 +1088,9 @@10881088   let sessionRootKey: Uint8Array | null = null;10891089   try {10901090     await ensureMigrated();1091+    if ((await loadSlot(SLOT_A)) !== null) {1092+      throw new Error("ADOPT_VAULT_ALREADY_EXISTS");1093+    }10911094 10921095     const nextEntries: VaultEntry[] = [];10931096     const nextDuressConfigured = false;

view file

src/popup/components/PairingQrCode.tsx+38 −0
@@ -0,0 +1,38 @@1+import { BarcodeFormat, QRCodeWriter } from "@zxing/library";2+3+export interface PairingQrCodeProps {4+  value: string;5+  size?: number;6+}7+8+/** Render a QR as native SVG so no invitation bytes touch a canvas export. */9+export function PairingQrCode(props: PairingQrCodeProps) {10+  const matrix = new QRCodeWriter().encode(11+    props.value,12+    BarcodeFormat.QR_CODE,13+    0,14+    0,15+    new Map(),16+  );17+  const path: string[] = [];18+  for (let y = 0; y < matrix.getHeight(); y++) {19+    for (let x = 0; x < matrix.getWidth(); x++) {20+      if (matrix.get(x, y)) path.push(`M${x} ${y}h1v1h-1z`);21+    }22+  }23+  const size = props.size ?? 236;24+  return (25+    <svg26+      class="peach-pair-qr"27+      viewBox={`0 0 ${matrix.getWidth()} ${matrix.getHeight()}`}28+      width={size}29+      height={size}30+      role="img"31+      aria-label="Peach pairing QR code"32+      shape-rendering="crispEdges"33+    >34+      <rect width="100%" height="100%" fill="#fff" />35+      <path d={path.join("")} fill="#000" />36+    </svg>37+  );38+}

view file

src/popup/styles/popup.css+30 −0
@@ -7136,6 +7136,36 @@71367136   object-fit: cover;71377137 }71387138 7139+.peach-adoption-source-invitation {7140+  display: flex;7141+  flex-direction: column;7142+  align-items: center;7143+  gap: 10px;7144+  padding: 4px 18px 16px;7145+  text-align: center;7146+}7147+7148+.peach-pair-qr {7149+  display: block;7150+  max-width: min(224px, 100%);7151+  height: auto;7152+  padding: 8px;7153+  border-radius: 10px;7154+  background: #fff;7155+}7156+7157+.peach-adoption-source-code {7158+  width: 100%;7159+  padding: 8px;7160+  resize: none;7161+  overflow-wrap: anywhere;7162+  border: 1px solid var(--peach-border);7163+  border-radius: 8px;7164+  background: color-mix(in srgb, var(--peach-t0) 6%, transparent);7165+  color: var(--peach-t1);7166+  font: 0.58rem/1.35 "IBM Plex Mono", monospace;7167+}7168+71397169 .peach-adopt-card > .peach-btn + .peach-btn {71407170   margin-top: 8px;71417171 }

view file

src/popup/views/PairDevice.test.tsx+98 −3
@@ -20,6 +20,9 @@2020 2121 // Mutated per-test to script the PAIR_WITH_CODE start response.2222 let startResponse: { ok: boolean; error?: string } = { ok: true };23+let lanOnly = true;24+const SOURCE_INVITATION =25+  "peach-pair-v1:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyJ9";2326 2427 import { PairDeviceView } from "./PairDevice";2528 import type { PairedDevice } from "../../shared/sync-types";@@ -36,10 +39,33 @@3639 beforeEach(() => {3740   listeners = [];3841   startResponse = { ok: true };42+  lanOnly = true;3943   mockSendMessage.mockReset();40-  mockSendMessage.mockImplementation(async (msg: { type?: string }) => {41-    if (msg?.type === "PAIR_WITH_CODE") return startResponse;42-    return { ok: true };44+  mockSendMessage.mockImplementation((msg: { type?: string }) => {45+    if (msg?.type === "GET_RELAY_SETTINGS") {46+      // Resolve synchronously so the existing code-pairing state-machine tests47+      // enter the explicitly configured LAN-only path before their first DOM48+      // assertion.49+      return {50+        then(resolve: (value: unknown) => unknown) {51+          return Promise.resolve(resolve({52+            ok: true,53+            data: { lanOnly },54+          }));55+        },56+      };57+    }58+    if (msg?.type === "START_ADOPTION_SOURCE") {59+      return Promise.resolve({60+        ok: true,61+        data: { invitation: SOURCE_INVITATION },62+      });63+    }64+    if (msg?.type === "ADOPTION_SOURCE_CONFIRM") {65+      return Promise.resolve({ ok: true, data: { valid: true } });66+    }67+    if (msg?.type === "PAIR_WITH_CODE") return Promise.resolve(startResponse);68+    return Promise.resolve({ ok: true });4369   });4470   (globalThis as unknown as { chrome: unknown }).chrome = {4571     runtime: {@@ -49,6 +75,75 @@4975       },5076     },5177   };78+});79+80+describe("PairDevice — relay adoption source", () => {81+  const cleanups: Array<() => void> = [];82+  afterEach(() => {83+    while (cleanups.length) cleanups.pop()!();84+  });85+86+  function mount() {87+    lanOnly = false;88+    const rendered = renderView(89+      <PairDeviceView90+        isPro={true}91+        initialPair="code"92+        onBack={() => {}}93+        onLearnPro={() => {}}94+      />,95+    );96+    cleanups.push(rendered.dispose);97+    return rendered;98+  }99+100+  it("opens the relay room before rendering its QR and manual fallback", async () => {101+    const { container } = mount();102+    await vi.waitFor(() => {103+      expect(container.textContent).toContain("Scan this in Peach");104+    });105+106+    expect(mockSendMessage).toHaveBeenCalledWith({107+      type: "START_ADOPTION_SOURCE",108+    });109+    expect(container.querySelector("svg.peach-pair-qr")).toBeTruthy();110+    expect(111+      (container.querySelector("#pairing-invitation-manual") as HTMLTextAreaElement).value,112+    ).toBe(SOURCE_INVITATION);113+    expect(container.textContent).toContain("single-use random token");114+  });115+116+  it("requires the matching SAS and master password before sharing", async () => {117+    const { container } = mount();118+    await vi.waitFor(() => {119+      expect(container.textContent).toContain("Scan this in Peach");120+    });121+122+    pushFromBackground("ADOPTION_SOURCE_SAS", {123+      sas: "482193",124+      receiverDeviceName: "June's phone",125+    });126+    expect(container.textContent).toContain("June's phone");127+    expect(container.textContent).toContain("4 8 2 1 9 3");128+129+    inputText(130+      container.querySelector("#adoption-consent-password") as HTMLInputElement,131+      "correct horse battery staple",132+    );133+    const approve = Array.from(container.querySelectorAll("button")).find(134+      (button) => button.textContent?.includes("Share vault"),135+    ) as HTMLButtonElement;136+    approve.click();137+    await vi.waitFor(() => {138+      expect(mockSendMessage).toHaveBeenCalledWith({139+        type: "ADOPTION_SOURCE_CONFIRM",140+        payload: { password: "correct horse battery staple" },141+      });142+    });143+    await vi.waitFor(() => {144+      expect(container.textContent).toContain("Waiting for the new device");145+    });146+  });52147 });53148 54149 describe("PairDevice — code pairing", () => {

view file

src/popup/views/PairDevice.tsx+208 −3
@@ -24,6 +24,8 @@2424 import type { PairedDevice } from "../../shared/sync-types";2525 import { DesktopBird, MobileBird } from "../components/PairingBirds";2626 import { formatPairingCode, parsePairingCode } from "../../shared/pairing-code";27+import { AdoptionConsentModal } from "../components/AdoptionConsentModal";28+import { PairingQrCode } from "../components/PairingQrCode";2729 2830 export interface PairDeviceViewProps {2931   isPro: boolean;@@ -41,6 +43,7 @@4143 4244 // Code-pairing phases. Mirrors the background CodePairingError union.4345 type PairPhase = "enter" | "connecting" | "sas" | "paired" | "error";46+type SharePhase = "opening" | "invitation" | "sas" | "connecting" | "paired" | "error";4447 type CodePairingError =4548   | "invalid_code"4649   | "no_host_found"@@ -94,10 +97,17 @@9497   const [errorKind, setErrorKind] = createSignal<CodePairingError | "unknown">("unknown");9598   const [pairedDevice, setPairedDevice] = createSignal<PairedDevice | undefined>(undefined);9699   const [busy, setBusy] = createSignal(false);100+  const [mode, setMode] = createSignal<"share" | "connect">("share");101+  const [sharePhase, setSharePhase] = createSignal<SharePhase>("opening");102+  const [invitation, setInvitation] = createSignal("");103+  const [sourceSas, setSourceSas] = createSignal("");104+  const [receiverName, setReceiverName] = createSignal("New device");105+  const [shareError, setShareError] = createSignal("");97106 98107   const codeReady = () => parsePairingCode(code()) !== null;99108 100109   function startFlow(): void {110+    setMode("connect");101111     setCode("");102112     setSas(null);103113     setErrorKind("unknown");@@ -106,6 +116,48 @@106116     setPhase("enter");107117   }108118 119+  async function startShareFlow(): Promise<void> {120+    setMode("share");121+    setSharePhase("opening");122+    setShareError("");123+    setInvitation("");124+    setSourceSas("");125+    try {126+      const response = (await sendMessage({127+        type: "START_ADOPTION_SOURCE",128+      })) as {129+        ok: boolean;130+        data?: { invitation?: string };131+        error?: string;132+      };133+      if (!response.ok || typeof response.data?.invitation !== "string") {134+        throw new Error(response.error ?? "adoption_source_failed");135+      }136+      setInvitation(response.data.invitation);137+      setSharePhase("invitation");138+    } catch (error) {139+      setShareError(error instanceof Error ? error.message : "adoption_source_failed");140+      setSharePhase("error");141+    }142+  }143+144+  async function stopShareFlow(): Promise<void> {145+    await sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);146+  }147+148+  async function useCodeInstead(): Promise<void> {149+    await stopShareFlow();150+    startFlow();151+  }152+153+  async function verifySourcePassword(password: string): Promise<boolean> {154+    const response = (await sendMessage({155+      type: "ADOPTION_SOURCE_CONFIRM",156+      payload: { password },157+    })) as { ok: boolean; data?: { valid?: boolean } };158+    return response.ok && response.data?.valid === true;159+  }160+109161   function onCodeInput(value: string): void {110162     const cleaned = value.toUpperCase().replace(/[^0-9A-Z]/g, "").slice(0, 32);111163     setCode(formatPairingCode(cleaned));@@ -159,8 +211,20 @@159211   onMount(() => {160212     // Opened straight into a pairing path from the Settings sync card. With no161213     // method there's nothing to do here — return to Settings (the hub).162-    if (props.initialPair === "code") startFlow();163-    else props.onBack();214+    if (props.initialPair === "code") {215+      void sendMessage({ type: "GET_RELAY_SETTINGS" })216+        .then((response) => {217+          const settings = response as {218+            ok: boolean;219+            data?: { lanOnly?: boolean };220+          };221+          if (settings.ok && settings.data?.lanOnly !== true) {222+            return startShareFlow();223+          }224+          startFlow();225+        })226+        .catch(() => startFlow());227+    } else props.onBack();164228 165229     // Pairing pushes from the background (bg → popup). The whole NK166230     // pairing runs in the service worker; the SAS to confirm and the terminal@@ -177,6 +241,30 @@177241             | undefined;178242           if (p?.status === "complete") { setPairedDevice(p.device); setPhase("paired"); }179243           else if (p?.status === "error") { setErrorKind(p.error); setPhase("error"); }244+        } else if (msg?.type === "ADOPTION_SOURCE_SAS") {245+          const p = msg.payload as {246+            sas?: string;247+            receiverDeviceName?: string;248+          } | undefined;249+          if (typeof p?.sas === "string") {250+            setSourceSas(p.sas);251+            setReceiverName(252+              typeof p.receiverDeviceName === "string" && p.receiverDeviceName253+                ? p.receiverDeviceName254+                : "New device",255+            );256+            setSharePhase("sas");257+          }258+        } else if (msg?.type === "ADOPTION_SOURCE_RESULT") {259+          const p = msg.payload as {260+            status?: "complete" | "error";261+            error?: string;262+          } | undefined;263+          if (p?.status === "complete") setSharePhase("paired");264+          else if (p?.status === "error") {265+            setShareError(p.error ?? "adoption_failed");266+            setSharePhase("error");267+          }180268         }181269       };182270       chrome.runtime.onMessage.addListener(handler);@@ -189,6 +277,9 @@189277       if (phase() === "sas") {190278         void sendMessage({ type: "CODE_PAIR_CONFIRM", payload: { confirmed: false } });191279       }280+      if (mode() === "share" && sharePhase() !== "paired") {281+        void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" });282+      }192283     });193284   });194285 @@ -327,7 +418,121 @@327418     </div>328419   );329420 421+  const shareScreen = () => (422+    <div423+      class="peach-pair-pair peach-desktop-pair peach-adoption-source"424+      data-phase={sharePhase()}425+      role="dialog"426+      aria-label="Share this vault with a new device"427+    >428+      <header class="peach-pair-head">429+        <button430+          class="peach-pair-back"431+          aria-label="Back"432+          onClick={() => { void stopShareFlow(); props.onBack(); }}433+        >434+          {backArrow}435+        </button>436+        <div class="peach-pair-title">Add a new device</div>437+        <div class="peach-pair-head-spacer" />438+      </header>439+440+      {birdStage(sharePhase() === "sas" || sharePhase() === "connecting")}441+442+      <Show when={sharePhase() === "opening"}>443+        <div class="peach-desktop-searching">444+          <div class="peach-pair-headline">Opening a secure pairing room…</div>445+          <div class="peach-pair-sub">The QR appears only after this browser is waiting in the relay.</div>446+        </div>447+      </Show>448+449+      <Show when={sharePhase() === "invitation"}>450+        <div class="peach-adoption-source-invitation">451+          <div class="peach-pair-headline">Scan this in Peach</div>452+          <div class="peach-pair-sub">453+            Connecting via secure relay — works anywhere. The QR contains only454+            the relay address and a single-use random token.455+          </div>456+          <PairingQrCode value={invitation()} size={224} />457+          <label class="peach-label" for="pairing-invitation-manual">or copy the invitation</label>458+          <textarea459+            id="pairing-invitation-manual"460+            class="peach-adoption-source-code"461+            readonly462+            rows={3}463+            value={invitation()}464+          />465+          <div class="peach-pair-action">466+            <button467+              class="peach-btn peach-btn-ghost"468+              onClick={() => void useCodeInstead()}469+            >470+              Enter a code instead471+            </button>472+            <button473+              class="peach-btn peach-btn-primary"474+              onClick={() => void navigator.clipboard.writeText(invitation())}475+            >476+              Copy477+            </button>478+          </div>479+        </div>480+      </Show>481+482+      <Show when={sharePhase() === "connecting"}>483+        <div class="peach-desktop-searching">484+          <div class="peach-pair-headline">Waiting for the new device…</div>485+          <div class="peach-pair-sub">The vault key stays put until that device confirms the same code too.</div>486+        </div>487+      </Show>488+489+      <Show when={sharePhase() === "paired"}>490+        <div class="peach-desktop-paired">491+          <div class="peach-pair-headline">Device added</div>492+          <div class="peach-pair-sub">The new device can now sync this vault through the secure relay.</div>493+          <div class="peach-pair-action">494+            <button class="peach-btn peach-btn-primary" onClick={props.onBack}>Done</button>495+          </div>496+        </div>497+      </Show>498+499+      <Show when={sharePhase() === "error"}>500+        <div class="peach-desktop-error">501+          <div class="peach-pair-headline">Could not add the device</div>502+          <div class="peach-pair-sub">503+            {shareError() === "vault_locked"504+              ? "Unlock Peach first, then try again."505+              : "The secure pairing session ended. Start again with a fresh QR."}506+          </div>507+          <div class="peach-pair-action">508+            <button class="peach-btn peach-btn-ghost" onClick={props.onBack}>Cancel</button>509+            <button class="peach-btn peach-btn-primary" onClick={() => void startShareFlow()}>Try again</button>510+          </div>511+        </div>512+      </Show>513+514+      <Show when={sharePhase() === "sas"}>515+        <AdoptionConsentModal516+          receiverDeviceName={receiverName()}517+          sas={sourceSas()}518+          verifyMasterPassword={verifySourcePassword}519+          onCancel={() => {520+            void sendMessage({521+              type: "ADOPTION_SOURCE_CONFIRM",522+              payload: { cancel: true },523+            });524+            setShareError("adoption_rejected");525+            setSharePhase("error");526+          }}527+          onApprove={async () => {528+            setSharePhase("connecting");529+          }}530+        />531+      </Show>532+    </div>533+  );534+330535   // Sync is no longer Pro-gated: anyone may attempt to pair so a free device can331536   // receive Pro from an already-Pro peer. The upsell lives on the Settings card.332-  return pairingScreen();537+  return mode() === "share" ? shareScreen() : pairingScreen();333538 };

view file

src/shared/types.ts+5 −0
@@ -309,6 +309,11 @@309309   | "CODE_PAIR_CONFIRM"310310   | "CODE_PAIR_SAS"311311   | "CODE_PAIR_RESULT"312+  | "START_ADOPTION_SOURCE"313+  | "ADOPTION_SOURCE_CONFIRM"314+  | "CANCEL_ADOPTION_SOURCE"315+  | "ADOPTION_SOURCE_SAS"316+  | "ADOPTION_SOURCE_RESULT"312317   | "TRIGGER_SYNC"313318   | "GET_SYNC_STATE"314319   | "GET_RELAY_SETTINGS"

view file