924fa824995d
feat: add safe inverse vault adoption (v4.0.48)
- time
- 2026-07-26 05:37 UTC · 3 months ago
- machine
- koh2
- parent
- b6a62bdc472e
↓ peach-browser-924fa824.tar.gzatlas at this save
files changed13 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 −0manifest.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": {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",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",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";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+});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+}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>;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;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+}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 }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", () => {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 };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"