e8856c991369

chore: Rename relay-adoption-source-bg.ts to relay-adoption-bg.ts (v4.6.2)

time
2026-07-30 15:51 UTC · 2 months ago
machine
koh2
parent
517c682f9968
8 files manifest.json+1 −1package.json+1 −1src/background/messages.ts+1 −1src/background/relay-adoption-bg.test.ts+773 −0src/background/relay-adoption-bg.ts+636 −0src/background/relay-adoption-source-bg.test.ts+0 −773src/background/relay-adoption-source-bg.ts+0 −636src/popup/views/PairDevice.tsx+1 −1
manifest.json+1 −1
@@ -2,7 +2,7 @@22   "manifest_version": 3,33   "minimum_chrome_version": "116",44   "name": "Peach Password Manager",5-  "version": "4.6.1",5+  "version": "4.6.2",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "4.6.1",3+  "version": "4.6.2",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/messages.ts+1 −1
@@ -92,7 +92,7 @@9292   resolveSourceSasConfirmation,9393   startRelayAdoptionReceiver,9494   startRelayAdoptionSource,95-} from "./relay-adoption-source-bg";95+} from "./relay-adoption-bg";9696 import {9797   getSyncSettings,9898   setRelayUrl,

view file

src/background/relay-adoption-bg.test.ts+773 −0
@@ -0,0 +1,773 @@1+import { beforeEach, describe, expect, it, vi } from "vitest";2+3+const mocks = vi.hoisted(() => ({4+  openSocket: vi.fn(),5+  getLocalIdentity: vi.fn(),6+  getDeviceName: vi.fn(),7+  getRootKeyHex: vi.fn(),8+  upsert: vi.fn(),9+  pushes: [] as Array<{ type: string; payload: unknown }>,10+  triggerSync: vi.fn(),11+  createVault: vi.fn(),12+  activateLicense: vi.fn(),13+  isProUser: vi.fn(),14+  getLicenseKey: vi.fn(),15+  localIdentity: vi.fn(),16+  rollbackVault: vi.fn(),17+  storageSet: vi.fn(),18+  sasDecisions: [] as Array<{ id: string; ok: boolean }>,19+}));20+21+vi.mock("../shared/pairing-dial", () => ({22+  openRelayPairingSocket: mocks.openSocket,23+}));24+25+vi.mock("../shared/noise-js", () => ({26+  noiseNKResponder: vi.fn(async () => ({27+    handshakeHash: new Uint8Array(32).fill(7),28+    encrypt: (bytes: Uint8Array) => bytes,29+    decrypt: (bytes: Uint8Array) => bytes,30+    close: vi.fn(),31+  })),32+}));33+34+vi.mock("../shared/rendezvous", async (importOriginal) => {35+  const actual = await importOriginal<typeof import("../shared/rendezvous")>();36+  return {37+    ...actual,38+    generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",39+  };40+});41+42+43+vi.mock("./device-identity", () => ({44+  getLocalPairingIdentity: async () => ({45+    identity: {46+      deviceId: "test-device",47+      deviceName: "Test",48+      deviceType: "extension",49+      longTermPublicKeyHex: "aa".repeat(32),50+      vaultId: "bb".repeat(32),51+    },52+    privateKey: new Uint8Array(32).fill(7),53+    publicKey: new Uint8Array(32).fill(8),54+  }),55+  getDeviceName: mocks.getDeviceName,56+  buildLocalIdentity: mocks.localIdentity,57+}));58+59+vi.mock("./sync-settings", async (importOriginal) => ({60+  ...(await importOriginal<typeof import("./sync-settings")>()),61+  getSyncSettings: async () => ({62+    mode: "public" as const,63+    relayUrl: "wss://relay.peachpasswords.com",64+  }),65+}));66+67+vi.mock("./license", () => ({68+  isProUser: mocks.isProUser,69+  getLicenseKey: mocks.getLicenseKey,70+  activateLicense: mocks.activateLicense,71+}));72+73+vi.mock("./vault", () => ({74+  isUnlocked: vi.fn(() => true),75+  getRootKeyHex: mocks.getRootKeyHex,76+  createVaultFromRootKey: mocks.createVault,77+  rollbackAdoptedVault: mocks.rollbackVault,78+  exportDuressSlotForAdoption: vi.fn(async () => null),79+  installAdoptedDuressSlot: vi.fn(async () => undefined),80+}));81+82+vi.mock("../shared/sync-identity", () => ({83+  computeVaultId: vi.fn(async () => "b".repeat(64)),84+}));85+86+vi.mock("../shared/storage", () => ({87+  storageSet: mocks.storageSet,88+}));89+90+vi.mock("./paired-devices", () => ({91+  upsertPairedDevice: mocks.upsert,92+}));93+94+vi.mock("./sync-trigger", () => ({95+  triggerSyncIfReachable: mocks.triggerSync,96+}));97+98+import {99+  cancelRelayAdoptionSource,100+  startRelayAdoptionSource,101+} from "./relay-adoption-bg";102+103+const encode = (value: unknown) =>104+  new TextEncoder().encode(JSON.stringify(value));105+106+function socketWithFrames(107+  frames: unknown[],108+  options: { closeOnEmpty?: boolean } = {},109+) {110+  const queue = frames.map(encode);111+  return {112+    send: vi.fn(),113+    receive: vi.fn(async () => {114+      const next = queue.shift();115+      if (!next) {116+        // A real peer closes the channel once it runs out of frames; model117+        // that so the receiver's post-ADOPT_APPLIED snapshot read rejects fast118+        // and falls back to the sync pipeline instead of hanging on a timeout.119+        if (options.closeOnEmpty) throw new Error("socket_closed");120+        return new Promise<Uint8Array>(() => undefined);121+      }122+      return next;123+    }),124+    close: vi.fn(),125+    closedBeforeFrame: vi.fn(() => false),126+  };127+}128+129+async function waitForPush(type: string): Promise<void> {130+  await vi.waitFor(() => {131+    expect(mocks.pushes.some((message) => message.type === type)).toBe(true);132+  });133+}134+135+beforeEach(() => {136+  mocks.pushes.length = 0;137+  mocks.sasDecisions.length = 0;138+  mocks.openSocket.mockReset();139+  mocks.getLocalIdentity.mockReset();140+  mocks.getRootKeyHex.mockReset();141+  mocks.upsert.mockReset();142+  mocks.triggerSync.mockReset();143+  mocks.triggerSync.mockResolvedValue(undefined);144+  mocks.isProUser.mockReset();145+  mocks.isProUser.mockResolvedValue(true);146+  mocks.getLicenseKey.mockReset();147+  mocks.getLicenseKey.mockResolvedValue("PPK-source");148+  mocks.getLocalIdentity.mockResolvedValue({149+    identity: {150+      deviceId: "browser-source",151+      deviceName: "June's Browser",152+      deviceType: "extension",153+      longTermPublicKeyHex: "a".repeat(64),154+      vaultId: "b".repeat(64),155+    },156+    privateKey: new Uint8Array(32).fill(1),157+    publicKey: new Uint8Array(32).fill(2),158+  });159+  mocks.getRootKeyHex.mockReturnValue("c".repeat(64));160+  mocks.upsert.mockResolvedValue(undefined);161+  Object.defineProperty(globalThis, "chrome", {162+    configurable: true,163+    value: {164+    runtime: {165+      async sendMessage(message: { type: string; payload: unknown }) {166+        mocks.pushes.push(message);167+        // The source flow waits for the popup to send SOURCE_SAS_DECISION before168+        // forwarding the root key. The receiver test overrides this mock and169+        // resolves the decision itself; the source tests rely on this default170+        // auto-approver so the happy-path test reaches the ADOPT_IDENTITY frame.171+        if (message.type === "ADOPTION_SOURCE_SAS") {172+          const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");173+          const p = message.payload as { id?: string };174+          if (typeof p?.id === "string") {175+            mocks.sasDecisions.push({ id: p.id, ok: true });176+            resolveSourceSasConfirmation(p.id, true);177+          }178+        }179+        return Promise.resolve();180+      },181+    },182+    },183+  });184+});185+186+describe("relay adoption source background boundary", () => {187+  it("shows the SAS and waits for the popup's single confirmation", async () => {188+    const socket = socketWithFrames([189+      {190+        type: "PEACH_HELLO",191+        token: "AAECAwQFBgcICQoLDA0ODw",192+      },193+      {194+        type: "ADOPT_REQUEST",195+        device_id: "request-only-label",196+        device_name: "June's phone",197+        device_type: "android",198+      },199+      { type: "ADOPT_CONFIRM", confirmed: true },200+      {201+        type: "ADOPT_IDENTITY",202+        device_id: "android-receiver",203+        device_name: "June's phone",204+        device_type: "android",205+        long_term_public_key_hex: "d".repeat(64),206+      },207+      { type: "ADOPT_APPLIED" },208+    ]);209+    mocks.openSocket.mockResolvedValue(socket);210+211+    const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");212+    const sasIds: string[] = [];213+    Object.defineProperty(globalThis, "chrome", {214+      configurable: true,215+      value: {216+        runtime: {217+          async sendMessage(message: { type: string; payload: unknown }) {218+            mocks.pushes.push(message);219+            if (message.type === "ADOPTION_SOURCE_SAS") {220+              const p = message.payload as { id?: string };221+              if (typeof p?.id === "string") {222+                // Hold the SAS gate open until the test explicitly approves223+                // it — this proves the source flow waits for the popup.224+                sasIds.push(p.id);225+              }226+              return Promise.resolve();227+            }228+            return Promise.resolve();229+          },230+        },231+      },232+    });233+    void resolveSourceSasConfirmation; // imported for the post-approval call below234+235+    const started = await startRelayAdoptionSource();236+    expect(started.invitation).toMatch(/^peach-pair-v1:/);237+    await waitForPush("ADOPTION_SOURCE_SAS");238+    // The adoption must NOT be complete yet — the popup's SAS decision is the239+    // prerequisite for forwarding the root key.240+    await vi.waitFor(() => {241+      expect(mocks.sasDecisions.length).toBe(0);242+    });243+    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();244+    expect(mocks.upsert).not.toHaveBeenCalled();245+246+    // Approve the SAS; the adoption should now proceed and complete.247+    const id = sasIds[0];248+    expect(typeof id).toBe("string");249+    resolveSourceSasConfirmation(id, true);250+    mocks.sasDecisions.push({ id, ok: true });251+252+    await waitForPush("ADOPTION_SOURCE_RESULT");253+254+    expect(mocks.getRootKeyHex).toHaveBeenCalled();255+    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({256+      deviceId: "android-receiver",257+      relayUrl: "wss://relay.peachpasswords.com",258+    }));259+    expect(mocks.pushes).toContainEqual(expect.objectContaining({260+      type: "ADOPTION_SOURCE_RESULT",261+      payload: expect.objectContaining({ status: "complete" }),262+    }));263+    expect(mocks.triggerSync).toHaveBeenCalledWith({264+      reason: "manual",265+      force: true,266+    });267+    const sentTypes = socket.send.mock.calls268+      .map(([bytes]) => {269+        try {270+          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;271+        } catch {272+          return undefined;273+        }274+      });275+    expect(sentTypes).toContain("ADOPT_COMPLETE");276+  });277+278+  it("fails closed when the popup rejects the SAS", async () => {279+    const socket = socketWithFrames([280+      {281+        type: "PEACH_HELLO",282+        token: "AAECAwQFBgcICQoLDA0ODw",283+      },284+      {285+        type: "ADOPT_REQUEST",286+        device_id: "request-only-label",287+        device_name: "New phone",288+        device_type: "android",289+      },290+      { type: "ADOPT_CONFIRM", confirmed: false },291+    ]);292+    mocks.openSocket.mockResolvedValue(socket);293+294+    const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");295+    let pendingId: string | undefined;296+    Object.defineProperty(globalThis, "chrome", {297+      configurable: true,298+      value: {299+        runtime: {300+          sendMessage(message: { type: string; payload: unknown }) {301+            mocks.pushes.push(message);302+            if (message.type === "ADOPTION_SOURCE_SAS") {303+              const p = message.payload as { id?: string };304+              if (typeof p?.id === "string") pendingId = p.id;305+            }306+            return Promise.resolve();307+          },308+        },309+      },310+    });311+312+    await startRelayAdoptionSource();313+    await waitForPush("ADOPTION_SOURCE_SAS");314+    expect(typeof pendingId).toBe("string");315+    resolveSourceSasConfirmation(pendingId!, false);316+    mocks.sasDecisions.push({ id: pendingId!, ok: false });317+318+    await waitForPush("ADOPTION_SOURCE_RESULT");319+320+    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();321+    expect(mocks.upsert).not.toHaveBeenCalled();322+    expect(mocks.pushes).toContainEqual({323+      type: "ADOPTION_SOURCE_RESULT",324+      payload: { status: "error", error: "adoption_rejected" },325+    });326+  });327+328+  it("rejects before reading the root key when neither peer has Pro", async () => {329+    const socket = socketWithFrames([330+      {331+        type: "PEACH_HELLO",332+        token: "AAECAwQFBgcICQoLDA0ODw",333+      },334+      {335+        type: "ADOPT_REQUEST",336+        device_id: "free-receiver",337+        device_name: "Free phone",338+        device_type: "android",339+      },340+      { type: "ADOPT_CONFIRM", confirmed: true },341+    ]);342+    mocks.isProUser.mockResolvedValue(false);343+    mocks.getLicenseKey.mockResolvedValue(undefined);344+    mocks.openSocket.mockResolvedValue(socket);345+346+    await startRelayAdoptionSource();347+    await waitForPush("ADOPTION_SOURCE_RESULT");348+349+    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();350+    expect(mocks.upsert).not.toHaveBeenCalled();351+    expect(mocks.pushes).toContainEqual({352+      type: "ADOPTION_SOURCE_RESULT",353+      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },354+    });355+    const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {356+      try {357+        return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];358+      } catch {359+        return [];360+      }361+    });362+    expect(sentFrames).toContainEqual({363+      type: "ADOPT_REJECT",364+      reason: "ADOPT_PRO_REQUIRED",365+    });366+  });367+368+  it("fails closed when the user never answers the SAS", async () => {369+    // vi.useFakeTimers is unsafe with the live noise/socket mocks (they370+    // schedule real work), so we hold the SAS gate open by never resolving371+    // the deferred and then cancel the session. clearState must drop the372+    // pending SAS so it doesn't pin the relay room open for the next run.373+    const socket = socketWithFrames([]);374+    mocks.openSocket.mockResolvedValue(socket);375+376+    Object.defineProperty(globalThis, "chrome", {377+      configurable: true,378+      value: {379+        runtime: {380+          sendMessage(message: { type: string; payload: unknown }) {381+            mocks.pushes.push(message);382+            return Promise.resolve();383+          },384+        },385+      },386+    });387+388+    await startRelayAdoptionSource();389+    cancelRelayAdoptionSource();390+391+    expect(socket.close).toHaveBeenCalled();392+    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();393+    expect(mocks.upsert).not.toHaveBeenCalled();394+  });395+396+  it("cancelling the source screen closes the room without reading the root", async () => {397+    const socket = socketWithFrames([]);398+    mocks.openSocket.mockResolvedValue(socket);399+400+    await startRelayAdoptionSource();401+    cancelRelayAdoptionSource();402+403+    expect(socket.close).toHaveBeenCalled();404+    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();405+    expect(mocks.upsert).not.toHaveBeenCalled();406+  });407+});408+409+describe("relay adoption receiver background", () => {410+  beforeEach(async () => {411+    // The receiver session runs in the background via `void runReceiverSession`;412+    // it doesn't get awaited by the caller and can leave `inFlight` set if the413+    // socket fixture didn't deliver PEACH_HELLO. Reset explicitly so each test414+    // starts from a clean state.415+    const { cancelRelayAdoptionSource } = await import("./relay-adoption-bg");416+    cancelRelayAdoptionSource();417+    mocks.pushes.length = 0;418+    mocks.openSocket.mockReset();419+    mocks.getDeviceName.mockReset();420+    mocks.getDeviceName.mockResolvedValue("Receiver Browser");421+    mocks.createVault.mockReset();422+    mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });423+    mocks.activateLicense.mockReset();424+    mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });425+    mocks.isProUser.mockReset();426+    mocks.isProUser.mockResolvedValue(true);427+    mocks.getLicenseKey.mockReset();428+    mocks.getLicenseKey.mockResolvedValue(undefined);429+    mocks.localIdentity.mockReset();430+    mocks.localIdentity.mockResolvedValue({431+      deviceId: "receiver-id",432+      deviceName: "Receiver Browser",433+      deviceType: "extension",434+      longTermPublicKeyHex: "11".repeat(32),435+      signingPublicKeyHex: "22".repeat(32),436+    });437+    mocks.rollbackVault.mockReset();438+    mocks.rollbackVault.mockResolvedValue(undefined);439+    mocks.storageSet.mockReset();440+    mocks.storageSet.mockResolvedValue(undefined);441+    mocks.upsert.mockReset();442+    mocks.upsert.mockResolvedValue(undefined);443+    mocks.triggerSync.mockReset();444+    mocks.triggerSync.mockResolvedValue(undefined);445+    mocks.openSocket.mockResolvedValue(socketWithFrames([]));446+    Object.defineProperty(globalThis, "chrome", {447+      configurable: true,448+      value: {449+        runtime: {450+          sendMessage(message: { type: string; payload?: unknown }) {451+            mocks.pushes.push({ type: message.type, payload: message.payload });452+            return Promise.resolve({ ok: true });453+          },454+          onMessage: {455+            addListener() { /* no-op */ },456+            removeListener() { /* no-op */ },457+          },458+        },459+      },460+    });461+  });462+463+  it("opens a relay socket and returns an empty invitation", async () => {464+    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");465+    const started = await startRelayAdoptionReceiver("receiver-password");466+    expect(started.invitation).toMatch(/^peach-pair-v1:/);467+    // The QR carries vault:"empty" so the phone knows to act as source.468+    const decoded = decodeInvitation(started.invitation);469+    expect(decoded?.vault).toBe("empty");470+    expect(mocks.openSocket).toHaveBeenCalled();471+  });472+473+  it("rejects an empty password", async () => {474+    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");475+    await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");476+  });477+478+  it("surfaces the Pro-required rejection without creating a vault", async () => {479+    mocks.openSocket.mockResolvedValue(socketWithFrames([480+      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },481+      { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },482+    ]));483+484+    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");485+    await startRelayAdoptionReceiver("receiver-password");486+    await waitForPush("ADOPTION_SOURCE_RESULT");487+488+    expect(mocks.createVault).not.toHaveBeenCalled();489+    expect(mocks.rollbackVault).not.toHaveBeenCalled();490+    expect(mocks.pushes).toContainEqual({491+      type: "ADOPTION_SOURCE_RESULT",492+      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },493+    });494+  });495+496+  it("the receiver wires requestAdoption, not grantAdoption", async () => {497+    // The receiver's protocol position is the opposite of the source side:498+    // it sends ADOPT_REQUEST, the phone returns ADOPT_GRANT with the root key.499+    // Assert this by watching what lands on socket.send: the receiver's500+    // outbound ADOPT_* traffic is ADOPT_REQUEST + ADOPT_CONFIRM + ADOPT_IDENTITY501+    // + ADOPT_APPLIED. ADOPT_GRANT only ever belongs to the source side.502+    const rootKeyHex = "f0".repeat(32);503+    const vaultId = "b".repeat(64);504+    const socket = socketWithFrames([505+      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },506+      {507+        type: "ADOPT_GRANT",508+        root_key_hex: rootKeyHex,509+        vault_id: vaultId,510+        license: "PPK-test",511+        source: {512+          device_id: "phone-source-id",513+          device_name: "June's phone",514+          device_type: "android",515+          long_term_public_key_hex: "99".repeat(32),516+        },517+      },518+      { type: "ADOPT_COMPLETE" },519+    ], { closeOnEmpty: true });520+    mocks.openSocket.mockResolvedValue(socket);521+    mocks.createVault.mockResolvedValue({ vaultId });522+523+    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");524+    Object.defineProperty(globalThis, "chrome", {525+      configurable: true,526+      value: {527+        runtime: {528+          sendMessage(message: { type: string; payload?: unknown }) {529+            mocks.pushes.push({ type: message.type, payload: message.payload });530+            return Promise.resolve({ ok: true });531+          },532+          onMessage: { addListener: () => undefined, removeListener: () => undefined },533+        },534+      },535+    });536+    await startRelayAdoptionReceiver("receiver-password");537+    await waitForPush("ADOPTION_SOURCE_RESULT");538+539+    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })540+      .mock.calls541+      .map(([bytes]) => {542+        try {543+          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;544+        } catch {545+          return undefined;546+        }547+      });548+    // ADOPT_REQUEST is the first adoption-protocol frame out of this side;549+    // ADOPT_GRANT is the source side's frame and must never appear here.550+    expect(sentTypes).toContain("ADOPT_REQUEST");551+    expect(sentTypes).not.toContain("ADOPT_GRANT");552+  });553+554+  it("completes the handshake end-to-end: vault created → paired", async () => {555+    // Wire the receiver's noise mock and SAS gate together so the full556+    // requestAdoption protocol runs against an in-memory phone that sends557+    // PEACH_HELLO, then ADOPT_CONFIRM(true), ADOPT_GRANT, ADOPT_COMPLETE.558+    // The mocked noise is pass-through (encrypt = decrypt = identity), so559+    // frames land as ordinary JSON.560+    const rootKeyHex = "f0".repeat(32);561+    // computeVaultId and createVaultFromRootKey both return "b".repeat(64);562+    // the grant's vault_id is set to the same so the protocol proceeds.563+    const vaultId = "b".repeat(64);564+    const frames = [565+      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },566+      // ADOPT_CONFIRM is sent BY the receiver, not received. The next two567+      // frames are what the phone returns: ADOPT_GRANT then ADOPT_COMPLETE.568+      {569+        type: "ADOPT_GRANT",570+        root_key_hex: rootKeyHex,571+        vault_id: vaultId,572+        license: "PPK-test",573+        source: {574+          device_id: "phone-source-id",575+          device_name: "June's phone",576+          device_type: "android",577+          long_term_public_key_hex: "99".repeat(32),578+          signing_public_key_hex: "aa".repeat(32),579+          relay_url: "wss://relay.peachpasswords.com",580+        },581+      },582+      { type: "ADOPT_COMPLETE" },583+    ];584+    mocks.createVault.mockResolvedValue({ vaultId });585+    mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));586+587+    // Replace the chrome.runtime mock for this one test. The receiver588+    // auto-accepts the SAS, so no decision needs to be simulated.589+    Object.defineProperty(globalThis, "chrome", {590+      configurable: true,591+      value: {592+        runtime: {593+          sendMessage(message: { type: string; payload?: unknown }) {594+            mocks.pushes.push({ type: message.type, payload: message.payload });595+            return Promise.resolve({ ok: true });596+          },597+          onMessage: {598+            addListener: () => undefined,599+            removeListener: () => undefined,600+          },601+        },602+      },603+    });604+605+    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");606+    await startRelayAdoptionReceiver("receiver-password");607+608+    await waitForPush("ADOPTION_SOURCE_RESULT");609+610+    // The receiver displays its locally derived SAS without introducing a611+    // second confirmation gate.612+    expect(mocks.pushes).toContainEqual({613+      type: "ADOPTION_SOURCE_SAS",614+      payload: expect.objectContaining({615+        sas: expect.stringMatching(/^\d{8}$/),616+        displayOnly: true,617+      }),618+    });619+620+    // The vault module was called directly with the popup-supplied password621+    // and the root key granted by the phone.622+    expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);623+624+    // License activation, identity derivation, and pairing persistence all625+    // call their modules directly from the service worker.626+    expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");627+    expect(mocks.isProUser).toHaveBeenCalled();628+    expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));629+    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({630+      deviceId: "phone-source-id",631+    }));632+    expect(mocks.storageSet).toHaveBeenCalledWith(633+      "peach_initial_sync_pending_device_id",634+      "phone-source-id",635+    );636+    expect(mocks.rollbackVault).not.toHaveBeenCalled();637+638+    // The terminal state is "complete" — the popup can transition to success.639+    expect(mocks.pushes).toContainEqual(expect.objectContaining({640+      type: "ADOPTION_SOURCE_RESULT",641+      payload: expect.objectContaining({ status: "complete" }),642+    }));643+  });644+645+  it("rolls back and refuses completion when a carried Pro license is not activated", async () => {646+    const rootKeyHex = "f0".repeat(32);647+    const vaultId = "b".repeat(64);648+    mocks.createVault.mockResolvedValue({ vaultId });649+    mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });650+    mocks.isProUser.mockResolvedValue(false);651+    mocks.openSocket.mockResolvedValue(socketWithFrames([652+      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },653+      {654+        type: "ADOPT_GRANT",655+        root_key_hex: rootKeyHex,656+        vault_id: vaultId,657+        license: "PPK-invalid",658+        source: {659+          device_id: "phone-source-id",660+          device_name: "June's phone",661+          device_type: "android",662+          long_term_public_key_hex: "99".repeat(32),663+        },664+      },665+    ]));666+667+    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");668+    await startRelayAdoptionReceiver("receiver-password");669+    await waitForPush("ADOPTION_SOURCE_RESULT");670+671+    expect(mocks.rollbackVault).toHaveBeenCalledOnce();672+    expect(mocks.localIdentity).not.toHaveBeenCalled();673+    expect(mocks.upsert).not.toHaveBeenCalled();674+    expect(mocks.pushes).toContainEqual(expect.objectContaining({675+      type: "ADOPTION_SOURCE_RESULT",676+      payload: expect.objectContaining({677+        status: "error",678+        error: "license_transfer_failed",679+      }),680+    }));681+  });682+683+  it("rolls back a newly created vault when a later adoption step fails", async () => {684+    const rootKeyHex = "f0".repeat(32);685+    const vaultId = "b".repeat(64);686+    mocks.createVault.mockResolvedValue({ vaultId });687+    mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));688+    mocks.openSocket.mockResolvedValue(socketWithFrames([689+      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },690+      {691+        type: "ADOPT_GRANT",692+        root_key_hex: rootKeyHex,693+        vault_id: vaultId,694+        license: "PPK-test",695+        source: {696+          device_id: "phone-source-id",697+          device_name: "June's phone",698+          device_type: "android",699+          long_term_public_key_hex: "99".repeat(32),700+        },701+      },702+    ]));703+704+    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");705+    await startRelayAdoptionReceiver("receiver-password");706+    await waitForPush("ADOPTION_SOURCE_RESULT");707+708+    expect(mocks.rollbackVault).toHaveBeenCalledOnce();709+    expect(mocks.upsert).not.toHaveBeenCalled();710+    expect(mocks.pushes).toContainEqual(expect.objectContaining({711+      type: "ADOPTION_SOURCE_RESULT",712+      payload: expect.objectContaining({ status: "error", error: "identity_failed" }),713+    }));714+  });715+716+  it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {717+    // The source's ADOPT_GRANT does not echo snapshot_capable (pre-C source).718+    // The receiver should skip the 30-second inline snapshot wait entirely719+    // and fall through to the sync pipeline — no receiveVaultSnapshot call.720+    const rootKeyHex = "f0".repeat(32);721+    const vaultId = "b".repeat(64);722+    mocks.createVault.mockResolvedValue({ vaultId });723+    mocks.openSocket.mockResolvedValue(socketWithFrames([724+      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },725+      {726+        type: "ADOPT_GRANT",727+        root_key_hex: rootKeyHex,728+        vault_id: vaultId,729+        license: "PPK-test",730+        source: {731+          device_id: "old-phone-id",732+          device_name: "Old phone",733+          device_type: "android",734+          long_term_public_key_hex: "99".repeat(32),735+        },736+        // NO snapshot_capable: pre-C source does not echo the field737+      },738+      { type: "ADOPT_COMPLETE" },739+    ], { closeOnEmpty: true }));740+741+    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");742+    await startRelayAdoptionReceiver("receiver-password");743+    await waitForPush("ADOPTION_SOURCE_RESULT");744+745+    // Sync fallback ran immediately (no 30s wait).746+    expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });747+    // Adoption completed successfully.748+    expect(mocks.pushes).toContainEqual(expect.objectContaining({749+      type: "ADOPTION_SOURCE_RESULT",750+      payload: expect.objectContaining({ status: "complete" }),751+    }));752+    // The initial sync pending marker was set (not cleared — no snapshot applied).753+    expect(mocks.storageSet).toHaveBeenCalledWith(754+      "peach_initial_sync_pending_device_id",755+      "old-phone-id",756+    );757+  });758+759+});760+761+function decodeInvitation(encoded: string): { vault?: string } | null {762+  // Mirrors parsePairingInvitation's payload structure without pulling the763+  // module in: the receiver test only needs to assert the embedded status.764+  const at = encoded.indexOf(":");765+  if (at < 0) return null;766+  const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");767+  try {768+    const json = Buffer.from(b64, "base64").toString("utf8");769+    return JSON.parse(json) as { vault?: string };770+  } catch {771+    return null;772+  }773+}

view file

src/background/relay-adoption-bg.ts+636 −0
@@ -0,0 +1,636 @@1+import { generateKeyPair } from "@stablelib/x25519";2+import {3+  createPairingInvitation,4+  pairingInvitationUrl,5+  parsePairingInvitation,6+} from "../shared/pairing-invitation";7+import {8+  openRelayPairingSocket,9+  type PairingSocket,10+} from "../shared/pairing-dial";11+import {12+  noiseNKResponder,13+  type NoiseNKSession,14+} from "../shared/noise-js";15+import {16+  grantAdoption,17+  isAdoptDeviceManifestFrame,18+  isAdoptRequest,19+  isAdoptSnapshotApplied,20+  receiveFrame,21+  receiveVaultSnapshot,22+  requestAdoption,23+  sendFrame,24+  sendVaultSnapshot,25+  type AdoptRequest,26+} from "../shared/vault-adopt";27+import {28+  derivePairingSAS,29+  type PairingTransport,30+} from "../shared/pairing-handshake";31+import {32+  bytesToHex,33+  deriveRendezvousSecret,34+  generatePairingRendezvousToken,35+} from "../shared/rendezvous";36+import { buildLocalIdentity, getDeviceName, getLocalPairingIdentity } from "./device-identity";37+import { getSyncSettings, relayInForce } from "./sync-settings";38+import { activateLicense, getLicenseKey, isProUser } from "./license";39+import { createVaultFromRootKey, exportDuressSlotForAdoption, getRootKeyHex, installAdoptedDuressSlot, isUnlocked, rollbackAdoptedVault } from "./vault";40+import { computeVaultId } from "../shared/sync-identity";41+import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";42+import { storageRemove, storageSet } from "../shared/storage";43+import { markLastSyncedAt, upsertPairedDevice } from "./paired-devices";44+import { createSyncVaultAdapter } from "./sync-vault-adapter";45+import { triggerSyncIfReachable } from "./sync-trigger";46+import { schedulePostAdoptionSync } from "./sync-alarm";47+48+const SOURCE_WINDOW_MS = 5 * 60 * 1000;49+50+function push(type: string, payload: unknown): void {51+  try {52+    const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;53+    if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {54+      (maybe as Promise<unknown>).catch(() => undefined);55+    }56+  } catch {57+    // The popup may have closed. The pending approval fails closed on cleanup.58+  }59+}60+61+/** Race a promise against a wall-clock deadline; rejects with "timeout". */62+function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {63+  return new Promise<T>((resolve, reject) => {64+    const timer = setTimeout(() => reject(new Error("timeout")), ms);65+    promise.then(66+      (value) => {67+        clearTimeout(timer);68+        resolve(value);69+      },70+      (error) => {71+        clearTimeout(timer);72+        reject(error);73+      },74+    );75+  });76+}77+78+let inFlight = false;79+let activeSocket: PairingSocket | null = null;80+// Outstanding source-side SAS confirmations, keyed by id. A map (vs the81+// receiver's single slot) makes a stale decision from a previous source82+// session impossible to land on the current one even if both run on the83+// same background worker.84+const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();85+86+export function isRelayAdoptionSourceInFlight(): boolean {87+  return inFlight;88+}89+90+/**91+ * Occupy the non-buffering room before returning the invitation to the UI.92+ * All long-term key material remains in the background service worker.93+ */94+export async function startRelayAdoptionSource(): Promise<{95+  invitation: string;96+}> {97+  if (inFlight) throw new Error("adoption_source_in_progress");98+  if (!isUnlocked()) throw new Error("vault_locked");99+  inFlight = true;100+101+  let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;102+  try {103+    const settings = await getSyncSettings();104+    const relayUrl = relayInForce(settings);105+    local = await getLocalPairingIdentity();106+    const token = generatePairingRendezvousToken();107+    // Source side has the vault — caller checked isUnlocked — so the phone108+    // can read the invitation and know it should receive the vault.109+    const encoded = createPairingInvitation(relayUrl, token, "populated");110+    const invitation = parsePairingInvitation(encoded);111+    if (!invitation) throw new Error("invitation_generation_failed");112+113+    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));114+    activeSocket = socket;115+    const readyLocal = local;116+    void runSourceSession(117+      socket,118+      token,119+      relayUrl,120+      readyLocal,121+    ).finally(() => {122+      readyLocal.privateKey.fill(0);123+      readyLocal.publicKey.fill(0);124+      clearState();125+    });126+    return { invitation: encoded };127+  } catch (error) {128+    local?.privateKey.fill(0);129+    local?.publicKey.fill(0);130+    clearState();131+    throw error;132+  }133+}134+135+export function cancelRelayAdoptionSource(): void {136+  activeSocket?.close();137+  clearState();138+}139+140+/**141+ * Onboarding pairing request — the empty extension shows a QR and the populated142+ * phone scans it. The invitation carries `vaultStatus: "empty"`, which the143+ * phone reads to switch into source mode and `grantAdoption` over the144+ * established Noise channel. The receiver's `runReceiverSession` accepts the145+ * root key and creates a local vault wrapped with the popup-supplied password.146+ *147+ * `password` is the local-only password the user picked on the "Set a password148+ * for this device" screen; it wraps the adopted root key at rest and has no149+ * relationship to the source device's password.150+ */151+export async function startRelayAdoptionReceiver(152+  password: string,153+): Promise<{ invitation: string }> {154+  if (inFlight) throw new Error("adoption_receiver_in_progress");155+  if (typeof password !== "string" || password.length === 0) {156+    throw new Error("ADOPT_INVALID_PASSWORD");157+  }158+  inFlight = true;159+  try {160+    const settings = await getSyncSettings();161+    const relayUrl = relayInForce(settings);162+    const token = generatePairingRendezvousToken();163+    const encoded = createPairingInvitation(relayUrl, token, "empty");164+    const invitation = parsePairingInvitation(encoded);165+    if (!invitation) throw new Error("invitation_generation_failed");166+    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));167+    activeSocket = socket;168+    void runReceiverSession(169+      socket,170+      token,171+      relayUrl,172+      password,173+    ).finally(clearState);174+    return { invitation: encoded };175+  } catch (error) {176+    clearState();177+    throw error;178+  }179+}180+181+/**182+ * Resolves the source-side SAS confirmation by id. Called by the message183+ * handler when the popup taps Confirm/Cancel on the source's SAS screen.184+ * Returns true if the id matched an in-flight confirmation. Unknown ids185+ * (already resolved, timed out, or never existed) are a no-op so a late186+ * popup click cannot crash anything.187+ */188+export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {189+  const pending = pendingSourceSasConfirmation.get(id);190+  if (!pending) return false;191+  pendingSourceSasConfirmation.delete(id);192+  pending.resolve(ok);193+  return true;194+}195+196+async function runSourceSession(197+  socket: PairingSocket,198+  expectedToken: string,199+  relayUrl: string,200+  local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,201+): Promise<void> {202+  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);203+  let noise: NoiseNKSession | undefined;204+  try {205+    const hello = decodeJson<{ type?: unknown; token?: unknown }>(206+      await socket.receive(),207+    );208+    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||209+        !constantTimeEqual(hello.token, expectedToken)) {210+      throw new Error("invalid_invitation");211+    }212+    socket.send(encodeJson({213+      type: "PEACH_HELLO_ACK",214+      ephemeralPublicKeyHex: bytesToHex(local.publicKey),215+    }));216+217+    const session = await noiseNKResponder(218+      local.privateKey,219+      local.publicKey,220+      async (bytes) => socket.send(bytes),221+      () => socket.receive(),222+    );223+    noise = session;224+    const channel: PairingTransport = {225+      async send(plaintext) { socket.send(session.encrypt(plaintext)); },226+      async receive() { return session.decrypt(await socket.receive()); },227+    };228+    const request = await receiveFrame(channel);229+    if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");230+231+    const device = await grantAdoption(channel, request, {232+      sourceIdentity: local.identity,233+      pairState: {234+        rendezvousSecretHex: bytesToHex(235+          deriveRendezvousSecret(session.handshakeHash),236+        ),237+        relayUrl,238+      },239+      async rootKeyHex() {240+        // grantAdoption calls this only after local password approval and the241+        // receiver's encrypted ADOPT_CONFIRM(true).242+        const value = getRootKeyHex();243+        if (!value) throw new Error("vault_locked");244+        return value;245+      },246+      async vaultId() {247+        const value = getRootKeyHex();248+        if (!value) throw new Error("vault_locked");249+        const bytes = hexToBytes(value);250+        try {251+          return await computeVaultId(bytes);252+        } finally {253+          bytes.fill(0);254+        }255+      },256+      async license() {257+        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;258+      },259+      async applyLicense(license) {260+        await applyTransferredLicense(license);261+      },262+      onConsent: (adoptRequest) => requestConsent(263+        adoptRequest,264+        derivePairingSAS(session.handshakeHash),265+        crypto.randomUUID(),266+      ),267+      upsertPairedDevice,268+    });269+    if (!device) {270+      push("ADOPTION_SOURCE_RESULT", {271+        status: "error",272+        error: "adoption_rejected",273+      });274+      return;275+    }276+    // If the receiver advertised inline-snapshot support, send the vault now277+    // over the same authenticated channel. Best-effort: any failure leaves the278+    // receiver to fall back to the sync pipeline triggered below.279+    if (request.snapshot_capable === true) {280+      try {281+        const adapter = createSyncVaultAdapter();282+        const entryManifest = await adapter.buildEntryManifest();283+        const entryIds = Object.keys(entryManifest);284+        const entries =285+          entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};286+        const tombstones = await adapter.buildTombstoneManifest();287+        let duressSlot: string | null = null;288+        try {289+          duressSlot = await exportDuressSlotForAdoption();290+        } catch {291+          // Duress omission must not fail the whole snapshot.292+        }293+        await sendVaultSnapshot(channel, entries, tombstones, duressSlot);294+295+        const applied = await withTimeout(receiveFrame(channel), 15_000);296+        if (isAdoptSnapshotApplied(applied)) {297+          console.info(298+            `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,299+          );300+        }301+302+        if (adapter.buildOutgoingDeviceManifest) {303+          const manifest = await adapter.buildOutgoingDeviceManifest();304+          if (manifest) {305+            await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });306+            await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);307+          }308+        }309+      } catch (snapshotError) {310+        console.info(311+          "[Peach] inline snapshot send failed (receiver will use sync pipeline):",312+          snapshotError,313+        );314+      }315+    }316+    push("ADOPTION_SOURCE_RESULT", { status: "complete", device });317+    // The Android receiver starts its relay listener as soon as its source318+    // record is durable. Immediate best-effort trigger; alarm-based retries319+    // cover MV3 service-worker death (setTimeout does not survive it).320+    void triggerSyncIfReachable({ reason: "manual", force: true });321+    schedulePostAdoptionSync();322+  } catch (error) {323+    push("ADOPTION_SOURCE_RESULT", {324+      status: "error",325+      error: error instanceof Error ? error.message : "adoption_failed",326+    });327+  } finally {328+    clearTimeout(timeout);329+    noise?.close();330+    socket.close();331+    pendingSourceSasConfirmation.clear();332+  }333+}334+335+/**336+ * Browser receives the vault from the phone. The protocol is the mirror of337+ * the source side: phone is the Noise initiator and the source of truth for338+ * the SAS gate, browser is the responder that creates the vault locally.339+ */340+async function runReceiverSession(341+  socket: PairingSocket,342+  expectedToken: string,343+  relayUrl: string,344+  password: string,345+): Promise<void> {346+  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);347+  // Ephemeral keypair for the NK responder half. Never persisted; zeroed on exit.348+  const ephemeral = generateKeyPair();349+  const ephemeralPrivate = ephemeral.secretKey;350+  const ephemeralPublic = ephemeral.publicKey;351+  let noise: NoiseNKSession | undefined;352+  let createdVault = false;353+  let protocolError: string | undefined;354+  try {355+    const hello = decodeJson<{ type?: unknown; token?: unknown }>(356+      await socket.receive(),357+    );358+    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||359+        !constantTimeEqual(hello.token, expectedToken)) {360+      throw new Error("invalid_invitation");361+    }362+    socket.send(encodeJson({363+      type: "PEACH_HELLO_ACK",364+      ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),365+    }));366+367+    const session = await noiseNKResponder(368+      ephemeralPrivate,369+      ephemeralPublic,370+      async (bytes) => socket.send(bytes),371+      () => socket.receive(),372+    );373+    noise = session;374+    const channel: PairingTransport = {375+      async send(plaintext) { socket.send(session.encrypt(plaintext)); },376+      async receive() { return session.decrypt(await socket.receive()); },377+    };378+379+    const deviceName = await getDeviceName();380+381+    const adoptionResult = await requestAdoption(channel, {382+      deviceName,383+      generateDeviceId: () => crypto.randomUUID(),384+      // Auto-accept the SAS: both sides derive it from the same Noise385+      // handshake hash, so the codes match by construction. Requiring the user386+      // to confirm on both sides creates a race where the phone can time out387+      // waiting for ADOPT_CONFIRM before the browser's popup is reached. The388+      // phone's onConsent is the single user gate; this side just proceeds.389+      // Still show the locally derived code for visual verification.390+      async confirmSas() {391+        push("ADOPTION_SOURCE_SAS", {392+          sas: derivePairingSAS(session.handshakeHash),393+          displayOnly: true,394+        });395+        return true;396+      },397+      async createVaultFromRootKey(rootKeyHex) {398+        // Direct call, not chrome.runtime.sendMessage — a service worker399+        // calling sendMessage does not reach its own listener (returns400+        // undefined), and the receiver already lives in the same SW context401+        // as the vault module. Round-tripping through the message bus was402+        // the root cause of "create_vault_no_response".403+        const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);404+        createdVault = true;405+        return { vaultId };406+      },407+      async applyLicense(license) {408+        // Direct call, not chrome.runtime.sendMessage — same sendMessage-to-self409+        // issue as createVaultFromRootKey. SW can't reach its own listener.410+        await applyTransferredLicense(license);411+      },412+      async license() {413+        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;414+      },415+      async deriveLocalIdentity() {416+        // Direct call for the same reason as applyLicense above.417+        const masterKeyHex = getRootKeyHex();418+        if (!masterKeyHex) throw new Error("identity_unavailable");419+        const masterKey = hexToBytes(masterKeyHex);420+        try {421+          return await buildLocalIdentity(masterKey);422+        } finally {423+          masterKey.fill(0);424+        }425+      },426+      async upsertPairedDevice(paired) {427+        // Direct call for the same reason as applyLicense above.428+        await upsertPairedDevice(paired);429+      },430+      pairState: {431+        rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),432+        relayUrl,433+      },434+      onError(error) {435+        protocolError = error;436+      },437+    });438+439+    if (!adoptionResult) {440+      if (createdVault) {441+        await rollbackAdoptedVault();442+        createdVault = false;443+      }444+      push("ADOPTION_SOURCE_RESULT", {445+        status: "error",446+        error: protocolError ?? "adoption_rejected",447+      });448+      return;449+    }450+    const { device, snapshotCapable } = adoptionResult;451+    // The pairing channel is still open and authenticated — try to pull the452+    // vault snapshot inline so the popup transitions immediately instead of453+    // waiting on a second sync round. Skip the wait entirely when the source454+    // did not echo snapshot_capable: true (pre-C source, or explicit opt-out);455+    // fall through to the existing sync pipeline below.456+    let snapshotApplied = false;457+    if (snapshotCapable) try {458+      const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);459+      const adapter = createSyncVaultAdapter();460+      await adapter.applyEntries(snapshot.entries);461+      await adapter.applyTombstones(snapshot.tombstones);462+      // Duress install is isolated: a failure here must NOT demote an463+      // otherwise-successful entry snapshot to the sync fallback.464+      if (snapshot.duressSlot) {465+        try {466+          await installAdoptedDuressSlot(snapshot.duressSlot);467+        } catch (duressError) {468+          console.info(469+            "[Peach] adopted duress slot install failed (non-fatal):",470+            duressError,471+          );472+        }473+      }474+      await sendFrame(channel, {475+        type: "ADOPT_SNAPSHOT_APPLIED",476+        entries_received: Object.keys(snapshot.entries).length,477+        tombstones_received: Object.keys(snapshot.tombstones).length,478+      });479+480+      // Optional device graph. Non-fatal and isolated so a manifest timeout or481+      // unsupported source cannot demote an otherwise-successful entry snapshot.482+      try {483+        const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);484+        if (485+          isAdoptDeviceManifestFrame(manifestFrame) &&486+          adapter.applyIncomingDeviceManifest &&487+          typeof device.longTermPublicKeyHex === "string" &&488+          /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)489+        ) {490+          await adapter.applyIncomingDeviceManifest(491+            manifestFrame.manifest,492+            hexToBytes(device.longTermPublicKeyHex),493+          );494+          await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });495+        }496+      } catch {497+        // The graph arrives via incremental sync instead.498+      }499+500+      await markLastSyncedAt(device.deviceId, Date.now());501+      snapshotApplied = true;502+    } catch (snapshotError) {503+      console.info(504+        "[Peach] inline snapshot failed, falling back to sync pipeline:",505+        snapshotError,506+      );507+    }508+509+    // Adoption has committed identity and vault-key material, but entries have510+    // not necessarily crossed yet. Persist the exact source row before telling511+    // the popup pairing is complete so a popup close/reopen cannot reveal the512+    // temporarily empty receiver vault. The popup clears this only after that513+    // row records a successful sync.514+    await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(515+      () => undefined,516+    );517+    push("ADOPTION_SOURCE_RESULT", { status: "complete", device });518+    createdVault = false;519+520+    if (snapshotApplied) {521+      // The snapshot populated the vault and marked the source synced, so the522+      // pending marker's job is done — clear it instead of making the popup wait.523+      await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);524+    } else {525+      // Fallback: the existing sync pipeline fills the vault. Immediate526+      // best-effort trigger plus alarm-based retries that survive SW death.527+      void triggerSyncIfReachable({ reason: "manual", force: true });528+      schedulePostAdoptionSync();529+    }530+  } catch (error) {531+    if (createdVault) {532+      try {533+        await rollbackAdoptedVault();534+        createdVault = false;535+      } catch (rollbackError) {536+        console.error("[Peach] rollback failed:", rollbackError);537+      }538+    }539+    console.error("[Peach] receiver session failed:", error);540+    push("ADOPTION_SOURCE_RESULT", {541+      status: "error",542+      error: error instanceof Error ? error.message : "adoption_failed",543+    });544+  } finally {545+    clearTimeout(timeout);546+    noise?.close();547+    socket.close();548+    ephemeralPrivate.fill(0);549+    ephemeralPublic.fill(0);550+  }551+}552+553+/**554+ * A transferred entitlement is committed only when activation succeeds and555+ * the stored value can immediately be re-read as Pro. activateLicense returns556+ * a normal Free result for invalid input, so callers must not ignore it.557+ */558+async function applyTransferredLicense(license: string): Promise<void> {559+  const activated = await activateLicense(license);560+  if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {561+    throw new Error("license_transfer_failed");562+  }563+}564+565+/**566+ * Push the SAS to the popup, then block until the popup responds via the567+ * `SOURCE_SAS_DECISION` message or the timeout fires. Returns false on568+ * timeout or cancel so the adoption aborts before any vault state changes.569+ *570+ * The pending deferred MUST be installed before the push: a popup-side571+ * auto-approver (test mocks, future automation) can synchronously resolve572+ * the deferred the moment it sees the SAS push, and a missing slot would573+ * drop that approval on the floor.574+ */575+function waitForSourceSasConfirmation(576+  id: string,577+  request: AdoptRequest,578+  sas: string,579+): Promise<boolean> {580+  return new Promise<boolean>((resolve) => {581+    const timer = setTimeout(() => {582+      pendingSourceSasConfirmation.delete(id);583+      resolve(false);584+    }, SOURCE_WINDOW_MS);585+    pendingSourceSasConfirmation.set(id, {586+      resolve: (ok) => {587+        clearTimeout(timer);588+        resolve(ok);589+      },590+    });591+    push("ADOPTION_SOURCE_SAS", {592+      id,593+      sas,594+      receiverDeviceName: request.device_name,595+    });596+  });597+}598+599+/**600+ * Opening Add Device in an unlocked extension is the source-side authorization.601+ * Announce the SAS for comparison, then let the scanning device's explicit602+ * confirmation be the single commit gate. grantAdoption still refuses to send603+ * the root key until the receiver returns ADOPT_CONFIRM(true).604+ */605+function requestConsent(request: AdoptRequest, sas: string, id: string): Promise<boolean> {606+  return waitForSourceSasConfirmation(id, request, sas);607+}608+609+function clearState(): void {610+  activeSocket = null;611+  inFlight = false;612+  pendingSourceSasConfirmation.clear();613+}614+615+function constantTimeEqual(a: string, b: string): boolean {616+  if (a.length !== b.length) return false;617+  let diff = 0;618+  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);619+  return diff === 0;620+}621+622+function encodeJson(value: unknown): Uint8Array {623+  return new TextEncoder().encode(JSON.stringify(value));624+}625+626+function decodeJson<T>(value: Uint8Array): T {627+  return JSON.parse(new TextDecoder().decode(value)) as T;628+}629+630+function hexToBytes(hex: string): Uint8Array {631+  const out = new Uint8Array(hex.length / 2);632+  for (let i = 0; i < out.length; i++) {633+    out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);634+  }635+  return out;636+}

view file

src/background/relay-adoption-source-bg.test.ts+0 −773
@@ -1,773 +0,0 @@1-import { beforeEach, describe, expect, it, vi } from "vitest";2-3-const mocks = vi.hoisted(() => ({4-  openSocket: vi.fn(),5-  getLocalIdentity: vi.fn(),6-  getDeviceName: vi.fn(),7-  getRootKeyHex: vi.fn(),8-  upsert: vi.fn(),9-  pushes: [] as Array<{ type: string; payload: unknown }>,10-  triggerSync: vi.fn(),11-  createVault: vi.fn(),12-  activateLicense: vi.fn(),13-  isProUser: vi.fn(),14-  getLicenseKey: vi.fn(),15-  localIdentity: vi.fn(),16-  rollbackVault: vi.fn(),17-  storageSet: vi.fn(),18-  sasDecisions: [] as Array<{ id: string; ok: boolean }>,19-}));20-21-vi.mock("../shared/pairing-dial", () => ({22-  openRelayPairingSocket: mocks.openSocket,23-}));24-25-vi.mock("../shared/noise-js", () => ({26-  noiseNKResponder: vi.fn(async () => ({27-    handshakeHash: new Uint8Array(32).fill(7),28-    encrypt: (bytes: Uint8Array) => bytes,29-    decrypt: (bytes: Uint8Array) => bytes,30-    close: vi.fn(),31-  })),32-}));33-34-vi.mock("../shared/rendezvous", async (importOriginal) => {35-  const actual = await importOriginal<typeof import("../shared/rendezvous")>();36-  return {37-    ...actual,38-    generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",39-  };40-});41-42-43-vi.mock("./device-identity", () => ({44-  getLocalPairingIdentity: async () => ({45-    identity: {46-      deviceId: "test-device",47-      deviceName: "Test",48-      deviceType: "extension",49-      longTermPublicKeyHex: "aa".repeat(32),50-      vaultId: "bb".repeat(32),51-    },52-    privateKey: new Uint8Array(32).fill(7),53-    publicKey: new Uint8Array(32).fill(8),54-  }),55-  getDeviceName: mocks.getDeviceName,56-  buildLocalIdentity: mocks.localIdentity,57-}));58-59-vi.mock("./sync-settings", async (importOriginal) => ({60-  ...(await importOriginal<typeof import("./sync-settings")>()),61-  getSyncSettings: async () => ({62-    mode: "public" as const,63-    relayUrl: "wss://relay.peachpasswords.com",64-  }),65-}));66-67-vi.mock("./license", () => ({68-  isProUser: mocks.isProUser,69-  getLicenseKey: mocks.getLicenseKey,70-  activateLicense: mocks.activateLicense,71-}));72-73-vi.mock("./vault", () => ({74-  isUnlocked: vi.fn(() => true),75-  getRootKeyHex: mocks.getRootKeyHex,76-  createVaultFromRootKey: mocks.createVault,77-  rollbackAdoptedVault: mocks.rollbackVault,78-  exportDuressSlotForAdoption: vi.fn(async () => null),79-  installAdoptedDuressSlot: vi.fn(async () => undefined),80-}));81-82-vi.mock("../shared/sync-identity", () => ({83-  computeVaultId: vi.fn(async () => "b".repeat(64)),84-}));85-86-vi.mock("../shared/storage", () => ({87-  storageSet: mocks.storageSet,88-}));89-90-vi.mock("./paired-devices", () => ({91-  upsertPairedDevice: mocks.upsert,92-}));93-94-vi.mock("./sync-trigger", () => ({95-  triggerSyncIfReachable: mocks.triggerSync,96-}));97-98-import {99-  cancelRelayAdoptionSource,100-  startRelayAdoptionSource,101-} from "./relay-adoption-source-bg";102-103-const encode = (value: unknown) =>104-  new TextEncoder().encode(JSON.stringify(value));105-106-function socketWithFrames(107-  frames: unknown[],108-  options: { closeOnEmpty?: boolean } = {},109-) {110-  const queue = frames.map(encode);111-  return {112-    send: vi.fn(),113-    receive: vi.fn(async () => {114-      const next = queue.shift();115-      if (!next) {116-        // A real peer closes the channel once it runs out of frames; model117-        // that so the receiver's post-ADOPT_APPLIED snapshot read rejects fast118-        // and falls back to the sync pipeline instead of hanging on a timeout.119-        if (options.closeOnEmpty) throw new Error("socket_closed");120-        return new Promise<Uint8Array>(() => undefined);121-      }122-      return next;123-    }),124-    close: vi.fn(),125-    closedBeforeFrame: vi.fn(() => false),126-  };127-}128-129-async function waitForPush(type: string): Promise<void> {130-  await vi.waitFor(() => {131-    expect(mocks.pushes.some((message) => message.type === type)).toBe(true);132-  });133-}134-135-beforeEach(() => {136-  mocks.pushes.length = 0;137-  mocks.sasDecisions.length = 0;138-  mocks.openSocket.mockReset();139-  mocks.getLocalIdentity.mockReset();140-  mocks.getRootKeyHex.mockReset();141-  mocks.upsert.mockReset();142-  mocks.triggerSync.mockReset();143-  mocks.triggerSync.mockResolvedValue(undefined);144-  mocks.isProUser.mockReset();145-  mocks.isProUser.mockResolvedValue(true);146-  mocks.getLicenseKey.mockReset();147-  mocks.getLicenseKey.mockResolvedValue("PPK-source");148-  mocks.getLocalIdentity.mockResolvedValue({149-    identity: {150-      deviceId: "browser-source",151-      deviceName: "June's Browser",152-      deviceType: "extension",153-      longTermPublicKeyHex: "a".repeat(64),154-      vaultId: "b".repeat(64),155-    },156-    privateKey: new Uint8Array(32).fill(1),157-    publicKey: new Uint8Array(32).fill(2),158-  });159-  mocks.getRootKeyHex.mockReturnValue("c".repeat(64));160-  mocks.upsert.mockResolvedValue(undefined);161-  Object.defineProperty(globalThis, "chrome", {162-    configurable: true,163-    value: {164-    runtime: {165-      async sendMessage(message: { type: string; payload: unknown }) {166-        mocks.pushes.push(message);167-        // The source flow waits for the popup to send SOURCE_SAS_DECISION before168-        // forwarding the root key. The receiver test overrides this mock and169-        // resolves the decision itself; the source tests rely on this default170-        // auto-approver so the happy-path test reaches the ADOPT_IDENTITY frame.171-        if (message.type === "ADOPTION_SOURCE_SAS") {172-          const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");173-          const p = message.payload as { id?: string };174-          if (typeof p?.id === "string") {175-            mocks.sasDecisions.push({ id: p.id, ok: true });176-            resolveSourceSasConfirmation(p.id, true);177-          }178-        }179-        return Promise.resolve();180-      },181-    },182-    },183-  });184-});185-186-describe("relay adoption source background boundary", () => {187-  it("shows the SAS and waits for the popup's single confirmation", async () => {188-    const socket = socketWithFrames([189-      {190-        type: "PEACH_HELLO",191-        token: "AAECAwQFBgcICQoLDA0ODw",192-      },193-      {194-        type: "ADOPT_REQUEST",195-        device_id: "request-only-label",196-        device_name: "June's phone",197-        device_type: "android",198-      },199-      { type: "ADOPT_CONFIRM", confirmed: true },200-      {201-        type: "ADOPT_IDENTITY",202-        device_id: "android-receiver",203-        device_name: "June's phone",204-        device_type: "android",205-        long_term_public_key_hex: "d".repeat(64),206-      },207-      { type: "ADOPT_APPLIED" },208-    ]);209-    mocks.openSocket.mockResolvedValue(socket);210-211-    const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");212-    const sasIds: string[] = [];213-    Object.defineProperty(globalThis, "chrome", {214-      configurable: true,215-      value: {216-        runtime: {217-          async sendMessage(message: { type: string; payload: unknown }) {218-            mocks.pushes.push(message);219-            if (message.type === "ADOPTION_SOURCE_SAS") {220-              const p = message.payload as { id?: string };221-              if (typeof p?.id === "string") {222-                // Hold the SAS gate open until the test explicitly approves223-                // it — this proves the source flow waits for the popup.224-                sasIds.push(p.id);225-              }226-              return Promise.resolve();227-            }228-            return Promise.resolve();229-          },230-        },231-      },232-    });233-    void resolveSourceSasConfirmation; // imported for the post-approval call below234-235-    const started = await startRelayAdoptionSource();236-    expect(started.invitation).toMatch(/^peach-pair-v1:/);237-    await waitForPush("ADOPTION_SOURCE_SAS");238-    // The adoption must NOT be complete yet — the popup's SAS decision is the239-    // prerequisite for forwarding the root key.240-    await vi.waitFor(() => {241-      expect(mocks.sasDecisions.length).toBe(0);242-    });243-    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();244-    expect(mocks.upsert).not.toHaveBeenCalled();245-246-    // Approve the SAS; the adoption should now proceed and complete.247-    const id = sasIds[0];248-    expect(typeof id).toBe("string");249-    resolveSourceSasConfirmation(id, true);250-    mocks.sasDecisions.push({ id, ok: true });251-252-    await waitForPush("ADOPTION_SOURCE_RESULT");253-254-    expect(mocks.getRootKeyHex).toHaveBeenCalled();255-    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({256-      deviceId: "android-receiver",257-      relayUrl: "wss://relay.peachpasswords.com",258-    }));259-    expect(mocks.pushes).toContainEqual(expect.objectContaining({260-      type: "ADOPTION_SOURCE_RESULT",261-      payload: expect.objectContaining({ status: "complete" }),262-    }));263-    expect(mocks.triggerSync).toHaveBeenCalledWith({264-      reason: "manual",265-      force: true,266-    });267-    const sentTypes = socket.send.mock.calls268-      .map(([bytes]) => {269-        try {270-          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;271-        } catch {272-          return undefined;273-        }274-      });275-    expect(sentTypes).toContain("ADOPT_COMPLETE");276-  });277-278-  it("fails closed when the popup rejects the SAS", async () => {279-    const socket = socketWithFrames([280-      {281-        type: "PEACH_HELLO",282-        token: "AAECAwQFBgcICQoLDA0ODw",283-      },284-      {285-        type: "ADOPT_REQUEST",286-        device_id: "request-only-label",287-        device_name: "New phone",288-        device_type: "android",289-      },290-      { type: "ADOPT_CONFIRM", confirmed: false },291-    ]);292-    mocks.openSocket.mockResolvedValue(socket);293-294-    const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");295-    let pendingId: string | undefined;296-    Object.defineProperty(globalThis, "chrome", {297-      configurable: true,298-      value: {299-        runtime: {300-          sendMessage(message: { type: string; payload: unknown }) {301-            mocks.pushes.push(message);302-            if (message.type === "ADOPTION_SOURCE_SAS") {303-              const p = message.payload as { id?: string };304-              if (typeof p?.id === "string") pendingId = p.id;305-            }306-            return Promise.resolve();307-          },308-        },309-      },310-    });311-312-    await startRelayAdoptionSource();313-    await waitForPush("ADOPTION_SOURCE_SAS");314-    expect(typeof pendingId).toBe("string");315-    resolveSourceSasConfirmation(pendingId!, false);316-    mocks.sasDecisions.push({ id: pendingId!, ok: false });317-318-    await waitForPush("ADOPTION_SOURCE_RESULT");319-320-    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();321-    expect(mocks.upsert).not.toHaveBeenCalled();322-    expect(mocks.pushes).toContainEqual({323-      type: "ADOPTION_SOURCE_RESULT",324-      payload: { status: "error", error: "adoption_rejected" },325-    });326-  });327-328-  it("rejects before reading the root key when neither peer has Pro", async () => {329-    const socket = socketWithFrames([330-      {331-        type: "PEACH_HELLO",332-        token: "AAECAwQFBgcICQoLDA0ODw",333-      },334-      {335-        type: "ADOPT_REQUEST",336-        device_id: "free-receiver",337-        device_name: "Free phone",338-        device_type: "android",339-      },340-      { type: "ADOPT_CONFIRM", confirmed: true },341-    ]);342-    mocks.isProUser.mockResolvedValue(false);343-    mocks.getLicenseKey.mockResolvedValue(undefined);344-    mocks.openSocket.mockResolvedValue(socket);345-346-    await startRelayAdoptionSource();347-    await waitForPush("ADOPTION_SOURCE_RESULT");348-349-    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();350-    expect(mocks.upsert).not.toHaveBeenCalled();351-    expect(mocks.pushes).toContainEqual({352-      type: "ADOPTION_SOURCE_RESULT",353-      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },354-    });355-    const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {356-      try {357-        return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];358-      } catch {359-        return [];360-      }361-    });362-    expect(sentFrames).toContainEqual({363-      type: "ADOPT_REJECT",364-      reason: "ADOPT_PRO_REQUIRED",365-    });366-  });367-368-  it("fails closed when the user never answers the SAS", async () => {369-    // vi.useFakeTimers is unsafe with the live noise/socket mocks (they370-    // schedule real work), so we hold the SAS gate open by never resolving371-    // the deferred and then cancel the session. clearState must drop the372-    // pending SAS so it doesn't pin the relay room open for the next run.373-    const socket = socketWithFrames([]);374-    mocks.openSocket.mockResolvedValue(socket);375-376-    Object.defineProperty(globalThis, "chrome", {377-      configurable: true,378-      value: {379-        runtime: {380-          sendMessage(message: { type: string; payload: unknown }) {381-            mocks.pushes.push(message);382-            return Promise.resolve();383-          },384-        },385-      },386-    });387-388-    await startRelayAdoptionSource();389-    cancelRelayAdoptionSource();390-391-    expect(socket.close).toHaveBeenCalled();392-    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();393-    expect(mocks.upsert).not.toHaveBeenCalled();394-  });395-396-  it("cancelling the source screen closes the room without reading the root", async () => {397-    const socket = socketWithFrames([]);398-    mocks.openSocket.mockResolvedValue(socket);399-400-    await startRelayAdoptionSource();401-    cancelRelayAdoptionSource();402-403-    expect(socket.close).toHaveBeenCalled();404-    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();405-    expect(mocks.upsert).not.toHaveBeenCalled();406-  });407-});408-409-describe("relay adoption receiver background", () => {410-  beforeEach(async () => {411-    // The receiver session runs in the background via `void runReceiverSession`;412-    // it doesn't get awaited by the caller and can leave `inFlight` set if the413-    // socket fixture didn't deliver PEACH_HELLO. Reset explicitly so each test414-    // starts from a clean state.415-    const { cancelRelayAdoptionSource } = await import("./relay-adoption-source-bg");416-    cancelRelayAdoptionSource();417-    mocks.pushes.length = 0;418-    mocks.openSocket.mockReset();419-    mocks.getDeviceName.mockReset();420-    mocks.getDeviceName.mockResolvedValue("Receiver Browser");421-    mocks.createVault.mockReset();422-    mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });423-    mocks.activateLicense.mockReset();424-    mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });425-    mocks.isProUser.mockReset();426-    mocks.isProUser.mockResolvedValue(true);427-    mocks.getLicenseKey.mockReset();428-    mocks.getLicenseKey.mockResolvedValue(undefined);429-    mocks.localIdentity.mockReset();430-    mocks.localIdentity.mockResolvedValue({431-      deviceId: "receiver-id",432-      deviceName: "Receiver Browser",433-      deviceType: "extension",434-      longTermPublicKeyHex: "11".repeat(32),435-      signingPublicKeyHex: "22".repeat(32),436-    });437-    mocks.rollbackVault.mockReset();438-    mocks.rollbackVault.mockResolvedValue(undefined);439-    mocks.storageSet.mockReset();440-    mocks.storageSet.mockResolvedValue(undefined);441-    mocks.upsert.mockReset();442-    mocks.upsert.mockResolvedValue(undefined);443-    mocks.triggerSync.mockReset();444-    mocks.triggerSync.mockResolvedValue(undefined);445-    mocks.openSocket.mockResolvedValue(socketWithFrames([]));446-    Object.defineProperty(globalThis, "chrome", {447-      configurable: true,448-      value: {449-        runtime: {450-          sendMessage(message: { type: string; payload?: unknown }) {451-            mocks.pushes.push({ type: message.type, payload: message.payload });452-            return Promise.resolve({ ok: true });453-          },454-          onMessage: {455-            addListener() { /* no-op */ },456-            removeListener() { /* no-op */ },457-          },458-        },459-      },460-    });461-  });462-463-  it("opens a relay socket and returns an empty invitation", async () => {464-    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");465-    const started = await startRelayAdoptionReceiver("receiver-password");466-    expect(started.invitation).toMatch(/^peach-pair-v1:/);467-    // The QR carries vault:"empty" so the phone knows to act as source.468-    const decoded = decodeInvitation(started.invitation);469-    expect(decoded?.vault).toBe("empty");470-    expect(mocks.openSocket).toHaveBeenCalled();471-  });472-473-  it("rejects an empty password", async () => {474-    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");475-    await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");476-  });477-478-  it("surfaces the Pro-required rejection without creating a vault", async () => {479-    mocks.openSocket.mockResolvedValue(socketWithFrames([480-      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },481-      { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },482-    ]));483-484-    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");485-    await startRelayAdoptionReceiver("receiver-password");486-    await waitForPush("ADOPTION_SOURCE_RESULT");487-488-    expect(mocks.createVault).not.toHaveBeenCalled();489-    expect(mocks.rollbackVault).not.toHaveBeenCalled();490-    expect(mocks.pushes).toContainEqual({491-      type: "ADOPTION_SOURCE_RESULT",492-      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },493-    });494-  });495-496-  it("the receiver wires requestAdoption, not grantAdoption", async () => {497-    // The receiver's protocol position is the opposite of the source side:498-    // it sends ADOPT_REQUEST, the phone returns ADOPT_GRANT with the root key.499-    // Assert this by watching what lands on socket.send: the receiver's500-    // outbound ADOPT_* traffic is ADOPT_REQUEST + ADOPT_CONFIRM + ADOPT_IDENTITY501-    // + ADOPT_APPLIED. ADOPT_GRANT only ever belongs to the source side.502-    const rootKeyHex = "f0".repeat(32);503-    const vaultId = "b".repeat(64);504-    const socket = socketWithFrames([505-      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },506-      {507-        type: "ADOPT_GRANT",508-        root_key_hex: rootKeyHex,509-        vault_id: vaultId,510-        license: "PPK-test",511-        source: {512-          device_id: "phone-source-id",513-          device_name: "June's phone",514-          device_type: "android",515-          long_term_public_key_hex: "99".repeat(32),516-        },517-      },518-      { type: "ADOPT_COMPLETE" },519-    ], { closeOnEmpty: true });520-    mocks.openSocket.mockResolvedValue(socket);521-    mocks.createVault.mockResolvedValue({ vaultId });522-523-    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");524-    Object.defineProperty(globalThis, "chrome", {525-      configurable: true,526-      value: {527-        runtime: {528-          sendMessage(message: { type: string; payload?: unknown }) {529-            mocks.pushes.push({ type: message.type, payload: message.payload });530-            return Promise.resolve({ ok: true });531-          },532-          onMessage: { addListener: () => undefined, removeListener: () => undefined },533-        },534-      },535-    });536-    await startRelayAdoptionReceiver("receiver-password");537-    await waitForPush("ADOPTION_SOURCE_RESULT");538-539-    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })540-      .mock.calls541-      .map(([bytes]) => {542-        try {543-          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;544-        } catch {545-          return undefined;546-        }547-      });548-    // ADOPT_REQUEST is the first adoption-protocol frame out of this side;549-    // ADOPT_GRANT is the source side's frame and must never appear here.550-    expect(sentTypes).toContain("ADOPT_REQUEST");551-    expect(sentTypes).not.toContain("ADOPT_GRANT");552-  });553-554-  it("completes the handshake end-to-end: vault created → paired", async () => {555-    // Wire the receiver's noise mock and SAS gate together so the full556-    // requestAdoption protocol runs against an in-memory phone that sends557-    // PEACH_HELLO, then ADOPT_CONFIRM(true), ADOPT_GRANT, ADOPT_COMPLETE.558-    // The mocked noise is pass-through (encrypt = decrypt = identity), so559-    // frames land as ordinary JSON.560-    const rootKeyHex = "f0".repeat(32);561-    // computeVaultId and createVaultFromRootKey both return "b".repeat(64);562-    // the grant's vault_id is set to the same so the protocol proceeds.563-    const vaultId = "b".repeat(64);564-    const frames = [565-      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },566-      // ADOPT_CONFIRM is sent BY the receiver, not received. The next two567-      // frames are what the phone returns: ADOPT_GRANT then ADOPT_COMPLETE.568-      {569-        type: "ADOPT_GRANT",570-        root_key_hex: rootKeyHex,571-        vault_id: vaultId,572-        license: "PPK-test",573-        source: {574-          device_id: "phone-source-id",575-          device_name: "June's phone",576-          device_type: "android",577-          long_term_public_key_hex: "99".repeat(32),578-          signing_public_key_hex: "aa".repeat(32),579-          relay_url: "wss://relay.peachpasswords.com",580-        },581-      },582-      { type: "ADOPT_COMPLETE" },583-    ];584-    mocks.createVault.mockResolvedValue({ vaultId });585-    mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));586-587-    // Replace the chrome.runtime mock for this one test. The receiver588-    // auto-accepts the SAS, so no decision needs to be simulated.589-    Object.defineProperty(globalThis, "chrome", {590-      configurable: true,591-      value: {592-        runtime: {593-          sendMessage(message: { type: string; payload?: unknown }) {594-            mocks.pushes.push({ type: message.type, payload: message.payload });595-            return Promise.resolve({ ok: true });596-          },597-          onMessage: {598-            addListener: () => undefined,599-            removeListener: () => undefined,600-          },601-        },602-      },603-    });604-605-    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");606-    await startRelayAdoptionReceiver("receiver-password");607-608-    await waitForPush("ADOPTION_SOURCE_RESULT");609-610-    // The receiver displays its locally derived SAS without introducing a611-    // second confirmation gate.612-    expect(mocks.pushes).toContainEqual({613-      type: "ADOPTION_SOURCE_SAS",614-      payload: expect.objectContaining({615-        sas: expect.stringMatching(/^\d{8}$/),616-        displayOnly: true,617-      }),618-    });619-620-    // The vault module was called directly with the popup-supplied password621-    // and the root key granted by the phone.622-    expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);623-624-    // License activation, identity derivation, and pairing persistence all625-    // call their modules directly from the service worker.626-    expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");627-    expect(mocks.isProUser).toHaveBeenCalled();628-    expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));629-    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({630-      deviceId: "phone-source-id",631-    }));632-    expect(mocks.storageSet).toHaveBeenCalledWith(633-      "peach_initial_sync_pending_device_id",634-      "phone-source-id",635-    );636-    expect(mocks.rollbackVault).not.toHaveBeenCalled();637-638-    // The terminal state is "complete" — the popup can transition to success.639-    expect(mocks.pushes).toContainEqual(expect.objectContaining({640-      type: "ADOPTION_SOURCE_RESULT",641-      payload: expect.objectContaining({ status: "complete" }),642-    }));643-  });644-645-  it("rolls back and refuses completion when a carried Pro license is not activated", async () => {646-    const rootKeyHex = "f0".repeat(32);647-    const vaultId = "b".repeat(64);648-    mocks.createVault.mockResolvedValue({ vaultId });649-    mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });650-    mocks.isProUser.mockResolvedValue(false);651-    mocks.openSocket.mockResolvedValue(socketWithFrames([652-      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },653-      {654-        type: "ADOPT_GRANT",655-        root_key_hex: rootKeyHex,656-        vault_id: vaultId,657-        license: "PPK-invalid",658-        source: {659-          device_id: "phone-source-id",660-          device_name: "June's phone",661-          device_type: "android",662-          long_term_public_key_hex: "99".repeat(32),663-        },664-      },665-    ]));666-667-    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");668-    await startRelayAdoptionReceiver("receiver-password");669-    await waitForPush("ADOPTION_SOURCE_RESULT");670-671-    expect(mocks.rollbackVault).toHaveBeenCalledOnce();672-    expect(mocks.localIdentity).not.toHaveBeenCalled();673-    expect(mocks.upsert).not.toHaveBeenCalled();674-    expect(mocks.pushes).toContainEqual(expect.objectContaining({675-      type: "ADOPTION_SOURCE_RESULT",676-      payload: expect.objectContaining({677-        status: "error",678-        error: "license_transfer_failed",679-      }),680-    }));681-  });682-683-  it("rolls back a newly created vault when a later adoption step fails", async () => {684-    const rootKeyHex = "f0".repeat(32);685-    const vaultId = "b".repeat(64);686-    mocks.createVault.mockResolvedValue({ vaultId });687-    mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));688-    mocks.openSocket.mockResolvedValue(socketWithFrames([689-      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },690-      {691-        type: "ADOPT_GRANT",692-        root_key_hex: rootKeyHex,693-        vault_id: vaultId,694-        license: "PPK-test",695-        source: {696-          device_id: "phone-source-id",697-          device_name: "June's phone",698-          device_type: "android",699-          long_term_public_key_hex: "99".repeat(32),700-        },701-      },702-    ]));703-704-    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");705-    await startRelayAdoptionReceiver("receiver-password");706-    await waitForPush("ADOPTION_SOURCE_RESULT");707-708-    expect(mocks.rollbackVault).toHaveBeenCalledOnce();709-    expect(mocks.upsert).not.toHaveBeenCalled();710-    expect(mocks.pushes).toContainEqual(expect.objectContaining({711-      type: "ADOPTION_SOURCE_RESULT",712-      payload: expect.objectContaining({ status: "error", error: "identity_failed" }),713-    }));714-  });715-716-  it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {717-    // The source's ADOPT_GRANT does not echo snapshot_capable (pre-C source).718-    // The receiver should skip the 30-second inline snapshot wait entirely719-    // and fall through to the sync pipeline — no receiveVaultSnapshot call.720-    const rootKeyHex = "f0".repeat(32);721-    const vaultId = "b".repeat(64);722-    mocks.createVault.mockResolvedValue({ vaultId });723-    mocks.openSocket.mockResolvedValue(socketWithFrames([724-      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },725-      {726-        type: "ADOPT_GRANT",727-        root_key_hex: rootKeyHex,728-        vault_id: vaultId,729-        license: "PPK-test",730-        source: {731-          device_id: "old-phone-id",732-          device_name: "Old phone",733-          device_type: "android",734-          long_term_public_key_hex: "99".repeat(32),735-        },736-        // NO snapshot_capable: pre-C source does not echo the field737-      },738-      { type: "ADOPT_COMPLETE" },739-    ], { closeOnEmpty: true }));740-741-    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");742-    await startRelayAdoptionReceiver("receiver-password");743-    await waitForPush("ADOPTION_SOURCE_RESULT");744-745-    // Sync fallback ran immediately (no 30s wait).746-    expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });747-    // Adoption completed successfully.748-    expect(mocks.pushes).toContainEqual(expect.objectContaining({749-      type: "ADOPTION_SOURCE_RESULT",750-      payload: expect.objectContaining({ status: "complete" }),751-    }));752-    // The initial sync pending marker was set (not cleared — no snapshot applied).753-    expect(mocks.storageSet).toHaveBeenCalledWith(754-      "peach_initial_sync_pending_device_id",755-      "old-phone-id",756-    );757-  });758-759-});760-761-function decodeInvitation(encoded: string): { vault?: string } | null {762-  // Mirrors parsePairingInvitation's payload structure without pulling the763-  // module in: the receiver test only needs to assert the embedded status.764-  const at = encoded.indexOf(":");765-  if (at < 0) return null;766-  const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");767-  try {768-    const json = Buffer.from(b64, "base64").toString("utf8");769-    return JSON.parse(json) as { vault?: string };770-  } catch {771-    return null;772-  }773-}

view file at the parent

src/background/relay-adoption-source-bg.ts+0 −636
@@ -1,636 +0,0 @@1-import { generateKeyPair } from "@stablelib/x25519";2-import {3-  createPairingInvitation,4-  pairingInvitationUrl,5-  parsePairingInvitation,6-} from "../shared/pairing-invitation";7-import {8-  openRelayPairingSocket,9-  type PairingSocket,10-} from "../shared/pairing-dial";11-import {12-  noiseNKResponder,13-  type NoiseNKSession,14-} from "../shared/noise-js";15-import {16-  grantAdoption,17-  isAdoptDeviceManifestFrame,18-  isAdoptRequest,19-  isAdoptSnapshotApplied,20-  receiveFrame,21-  receiveVaultSnapshot,22-  requestAdoption,23-  sendFrame,24-  sendVaultSnapshot,25-  type AdoptRequest,26-} from "../shared/vault-adopt";27-import {28-  derivePairingSAS,29-  type PairingTransport,30-} from "../shared/pairing-handshake";31-import {32-  bytesToHex,33-  deriveRendezvousSecret,34-  generatePairingRendezvousToken,35-} from "../shared/rendezvous";36-import { buildLocalIdentity, getDeviceName, getLocalPairingIdentity } from "./device-identity";37-import { getSyncSettings, relayInForce } from "./sync-settings";38-import { activateLicense, getLicenseKey, isProUser } from "./license";39-import { createVaultFromRootKey, exportDuressSlotForAdoption, getRootKeyHex, installAdoptedDuressSlot, isUnlocked, rollbackAdoptedVault } from "./vault";40-import { computeVaultId } from "../shared/sync-identity";41-import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";42-import { storageRemove, storageSet } from "../shared/storage";43-import { markLastSyncedAt, upsertPairedDevice } from "./paired-devices";44-import { createSyncVaultAdapter } from "./sync-vault-adapter";45-import { triggerSyncIfReachable } from "./sync-trigger";46-import { schedulePostAdoptionSync } from "./sync-alarm";47-48-const SOURCE_WINDOW_MS = 5 * 60 * 1000;49-50-function push(type: string, payload: unknown): void {51-  try {52-    const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;53-    if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {54-      (maybe as Promise<unknown>).catch(() => undefined);55-    }56-  } catch {57-    // The popup may have closed. The pending approval fails closed on cleanup.58-  }59-}60-61-/** Race a promise against a wall-clock deadline; rejects with "timeout". */62-function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {63-  return new Promise<T>((resolve, reject) => {64-    const timer = setTimeout(() => reject(new Error("timeout")), ms);65-    promise.then(66-      (value) => {67-        clearTimeout(timer);68-        resolve(value);69-      },70-      (error) => {71-        clearTimeout(timer);72-        reject(error);73-      },74-    );75-  });76-}77-78-let inFlight = false;79-let activeSocket: PairingSocket | null = null;80-// Outstanding source-side SAS confirmations, keyed by id. A map (vs the81-// receiver's single slot) makes a stale decision from a previous source82-// session impossible to land on the current one even if both run on the83-// same background worker.84-const pendingSourceSasConfirmation = new Map<string, { resolve: (ok: boolean) => void }>();85-86-export function isRelayAdoptionSourceInFlight(): boolean {87-  return inFlight;88-}89-90-/**91- * Occupy the non-buffering room before returning the invitation to the UI.92- * All long-term key material remains in the background service worker.93- */94-export async function startRelayAdoptionSource(): Promise<{95-  invitation: string;96-}> {97-  if (inFlight) throw new Error("adoption_source_in_progress");98-  if (!isUnlocked()) throw new Error("vault_locked");99-  inFlight = true;100-101-  let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;102-  try {103-    const settings = await getSyncSettings();104-    const relayUrl = relayInForce(settings);105-    local = await getLocalPairingIdentity();106-    const token = generatePairingRendezvousToken();107-    // Source side has the vault — caller checked isUnlocked — so the phone108-    // can read the invitation and know it should receive the vault.109-    const encoded = createPairingInvitation(relayUrl, token, "populated");110-    const invitation = parsePairingInvitation(encoded);111-    if (!invitation) throw new Error("invitation_generation_failed");112-113-    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));114-    activeSocket = socket;115-    const readyLocal = local;116-    void runSourceSession(117-      socket,118-      token,119-      relayUrl,120-      readyLocal,121-    ).finally(() => {122-      readyLocal.privateKey.fill(0);123-      readyLocal.publicKey.fill(0);124-      clearState();125-    });126-    return { invitation: encoded };127-  } catch (error) {128-    local?.privateKey.fill(0);129-    local?.publicKey.fill(0);130-    clearState();131-    throw error;132-  }133-}134-135-export function cancelRelayAdoptionSource(): void {136-  activeSocket?.close();137-  clearState();138-}139-140-/**141- * Onboarding pairing request — the empty extension shows a QR and the populated142- * phone scans it. The invitation carries `vaultStatus: "empty"`, which the143- * phone reads to switch into source mode and `grantAdoption` over the144- * established Noise channel. The receiver's `runReceiverSession` accepts the145- * root key and creates a local vault wrapped with the popup-supplied password.146- *147- * `password` is the local-only password the user picked on the "Set a password148- * for this device" screen; it wraps the adopted root key at rest and has no149- * relationship to the source device's password.150- */151-export async function startRelayAdoptionReceiver(152-  password: string,153-): Promise<{ invitation: string }> {154-  if (inFlight) throw new Error("adoption_receiver_in_progress");155-  if (typeof password !== "string" || password.length === 0) {156-    throw new Error("ADOPT_INVALID_PASSWORD");157-  }158-  inFlight = true;159-  try {160-    const settings = await getSyncSettings();161-    const relayUrl = relayInForce(settings);162-    const token = generatePairingRendezvousToken();163-    const encoded = createPairingInvitation(relayUrl, token, "empty");164-    const invitation = parsePairingInvitation(encoded);165-    if (!invitation) throw new Error("invitation_generation_failed");166-    const socket = await openRelayPairingSocket(pairingInvitationUrl(invitation));167-    activeSocket = socket;168-    void runReceiverSession(169-      socket,170-      token,171-      relayUrl,172-      password,173-    ).finally(clearState);174-    return { invitation: encoded };175-  } catch (error) {176-    clearState();177-    throw error;178-  }179-}180-181-/**182- * Resolves the source-side SAS confirmation by id. Called by the message183- * handler when the popup taps Confirm/Cancel on the source's SAS screen.184- * Returns true if the id matched an in-flight confirmation. Unknown ids185- * (already resolved, timed out, or never existed) are a no-op so a late186- * popup click cannot crash anything.187- */188-export function resolveSourceSasConfirmation(id: string, ok: boolean): boolean {189-  const pending = pendingSourceSasConfirmation.get(id);190-  if (!pending) return false;191-  pendingSourceSasConfirmation.delete(id);192-  pending.resolve(ok);193-  return true;194-}195-196-async function runSourceSession(197-  socket: PairingSocket,198-  expectedToken: string,199-  relayUrl: string,200-  local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,201-): Promise<void> {202-  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);203-  let noise: NoiseNKSession | undefined;204-  try {205-    const hello = decodeJson<{ type?: unknown; token?: unknown }>(206-      await socket.receive(),207-    );208-    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||209-        !constantTimeEqual(hello.token, expectedToken)) {210-      throw new Error("invalid_invitation");211-    }212-    socket.send(encodeJson({213-      type: "PEACH_HELLO_ACK",214-      ephemeralPublicKeyHex: bytesToHex(local.publicKey),215-    }));216-217-    const session = await noiseNKResponder(218-      local.privateKey,219-      local.publicKey,220-      async (bytes) => socket.send(bytes),221-      () => socket.receive(),222-    );223-    noise = session;224-    const channel: PairingTransport = {225-      async send(plaintext) { socket.send(session.encrypt(plaintext)); },226-      async receive() { return session.decrypt(await socket.receive()); },227-    };228-    const request = await receiveFrame(channel);229-    if (!isAdoptRequest(request)) throw new Error("expected_ADOPT_REQUEST");230-231-    const device = await grantAdoption(channel, request, {232-      sourceIdentity: local.identity,233-      pairState: {234-        rendezvousSecretHex: bytesToHex(235-          deriveRendezvousSecret(session.handshakeHash),236-        ),237-        relayUrl,238-      },239-      async rootKeyHex() {240-        // grantAdoption calls this only after local password approval and the241-        // receiver's encrypted ADOPT_CONFIRM(true).242-        const value = getRootKeyHex();243-        if (!value) throw new Error("vault_locked");244-        return value;245-      },246-      async vaultId() {247-        const value = getRootKeyHex();248-        if (!value) throw new Error("vault_locked");249-        const bytes = hexToBytes(value);250-        try {251-          return await computeVaultId(bytes);252-        } finally {253-          bytes.fill(0);254-        }255-      },256-      async license() {257-        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;258-      },259-      async applyLicense(license) {260-        await applyTransferredLicense(license);261-      },262-      onConsent: (adoptRequest) => requestConsent(263-        adoptRequest,264-        derivePairingSAS(session.handshakeHash),265-        crypto.randomUUID(),266-      ),267-      upsertPairedDevice,268-    });269-    if (!device) {270-      push("ADOPTION_SOURCE_RESULT", {271-        status: "error",272-        error: "adoption_rejected",273-      });274-      return;275-    }276-    // If the receiver advertised inline-snapshot support, send the vault now277-    // over the same authenticated channel. Best-effort: any failure leaves the278-    // receiver to fall back to the sync pipeline triggered below.279-    if (request.snapshot_capable === true) {280-      try {281-        const adapter = createSyncVaultAdapter();282-        const entryManifest = await adapter.buildEntryManifest();283-        const entryIds = Object.keys(entryManifest);284-        const entries =285-          entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};286-        const tombstones = await adapter.buildTombstoneManifest();287-        let duressSlot: string | null = null;288-        try {289-          duressSlot = await exportDuressSlotForAdoption();290-        } catch {291-          // Duress omission must not fail the whole snapshot.292-        }293-        await sendVaultSnapshot(channel, entries, tombstones, duressSlot);294-295-        const applied = await withTimeout(receiveFrame(channel), 15_000);296-        if (isAdoptSnapshotApplied(applied)) {297-          console.info(298-            `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,299-          );300-        }301-302-        if (adapter.buildOutgoingDeviceManifest) {303-          const manifest = await adapter.buildOutgoingDeviceManifest();304-          if (manifest) {305-            await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });306-            await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);307-          }308-        }309-      } catch (snapshotError) {310-        console.info(311-          "[Peach] inline snapshot send failed (receiver will use sync pipeline):",312-          snapshotError,313-        );314-      }315-    }316-    push("ADOPTION_SOURCE_RESULT", { status: "complete", device });317-    // The Android receiver starts its relay listener as soon as its source318-    // record is durable. Immediate best-effort trigger; alarm-based retries319-    // cover MV3 service-worker death (setTimeout does not survive it).320-    void triggerSyncIfReachable({ reason: "manual", force: true });321-    schedulePostAdoptionSync();322-  } catch (error) {323-    push("ADOPTION_SOURCE_RESULT", {324-      status: "error",325-      error: error instanceof Error ? error.message : "adoption_failed",326-    });327-  } finally {328-    clearTimeout(timeout);329-    noise?.close();330-    socket.close();331-    pendingSourceSasConfirmation.clear();332-  }333-}334-335-/**336- * Browser receives the vault from the phone. The protocol is the mirror of337- * the source side: phone is the Noise initiator and the source of truth for338- * the SAS gate, browser is the responder that creates the vault locally.339- */340-async function runReceiverSession(341-  socket: PairingSocket,342-  expectedToken: string,343-  relayUrl: string,344-  password: string,345-): Promise<void> {346-  const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);347-  // Ephemeral keypair for the NK responder half. Never persisted; zeroed on exit.348-  const ephemeral = generateKeyPair();349-  const ephemeralPrivate = ephemeral.secretKey;350-  const ephemeralPublic = ephemeral.publicKey;351-  let noise: NoiseNKSession | undefined;352-  let createdVault = false;353-  let protocolError: string | undefined;354-  try {355-    const hello = decodeJson<{ type?: unknown; token?: unknown }>(356-      await socket.receive(),357-    );358-    if (hello.type !== "PEACH_HELLO" || typeof hello.token !== "string" ||359-        !constantTimeEqual(hello.token, expectedToken)) {360-      throw new Error("invalid_invitation");361-    }362-    socket.send(encodeJson({363-      type: "PEACH_HELLO_ACK",364-      ephemeralPublicKeyHex: bytesToHex(ephemeralPublic),365-    }));366-367-    const session = await noiseNKResponder(368-      ephemeralPrivate,369-      ephemeralPublic,370-      async (bytes) => socket.send(bytes),371-      () => socket.receive(),372-    );373-    noise = session;374-    const channel: PairingTransport = {375-      async send(plaintext) { socket.send(session.encrypt(plaintext)); },376-      async receive() { return session.decrypt(await socket.receive()); },377-    };378-379-    const deviceName = await getDeviceName();380-381-    const adoptionResult = await requestAdoption(channel, {382-      deviceName,383-      generateDeviceId: () => crypto.randomUUID(),384-      // Auto-accept the SAS: both sides derive it from the same Noise385-      // handshake hash, so the codes match by construction. Requiring the user386-      // to confirm on both sides creates a race where the phone can time out387-      // waiting for ADOPT_CONFIRM before the browser's popup is reached. The388-      // phone's onConsent is the single user gate; this side just proceeds.389-      // Still show the locally derived code for visual verification.390-      async confirmSas() {391-        push("ADOPTION_SOURCE_SAS", {392-          sas: derivePairingSAS(session.handshakeHash),393-          displayOnly: true,394-        });395-        return true;396-      },397-      async createVaultFromRootKey(rootKeyHex) {398-        // Direct call, not chrome.runtime.sendMessage — a service worker399-        // calling sendMessage does not reach its own listener (returns400-        // undefined), and the receiver already lives in the same SW context401-        // as the vault module. Round-tripping through the message bus was402-        // the root cause of "create_vault_no_response".403-        const { vaultId } = await createVaultFromRootKey(password, rootKeyHex);404-        createdVault = true;405-        return { vaultId };406-      },407-      async applyLicense(license) {408-        // Direct call, not chrome.runtime.sendMessage — same sendMessage-to-self409-        // issue as createVaultFromRootKey. SW can't reach its own listener.410-        await applyTransferredLicense(license);411-      },412-      async license() {413-        return (await isProUser()) ? (await getLicenseKey()) ?? undefined : undefined;414-      },415-      async deriveLocalIdentity() {416-        // Direct call for the same reason as applyLicense above.417-        const masterKeyHex = getRootKeyHex();418-        if (!masterKeyHex) throw new Error("identity_unavailable");419-        const masterKey = hexToBytes(masterKeyHex);420-        try {421-          return await buildLocalIdentity(masterKey);422-        } finally {423-          masterKey.fill(0);424-        }425-      },426-      async upsertPairedDevice(paired) {427-        // Direct call for the same reason as applyLicense above.428-        await upsertPairedDevice(paired);429-      },430-      pairState: {431-        rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),432-        relayUrl,433-      },434-      onError(error) {435-        protocolError = error;436-      },437-    });438-439-    if (!adoptionResult) {440-      if (createdVault) {441-        await rollbackAdoptedVault();442-        createdVault = false;443-      }444-      push("ADOPTION_SOURCE_RESULT", {445-        status: "error",446-        error: protocolError ?? "adoption_rejected",447-      });448-      return;449-    }450-    const { device, snapshotCapable } = adoptionResult;451-    // The pairing channel is still open and authenticated — try to pull the452-    // vault snapshot inline so the popup transitions immediately instead of453-    // waiting on a second sync round. Skip the wait entirely when the source454-    // did not echo snapshot_capable: true (pre-C source, or explicit opt-out);455-    // fall through to the existing sync pipeline below.456-    let snapshotApplied = false;457-    if (snapshotCapable) try {458-      const snapshot = await withTimeout(receiveVaultSnapshot(channel), 30_000);459-      const adapter = createSyncVaultAdapter();460-      await adapter.applyEntries(snapshot.entries);461-      await adapter.applyTombstones(snapshot.tombstones);462-      // Duress install is isolated: a failure here must NOT demote an463-      // otherwise-successful entry snapshot to the sync fallback.464-      if (snapshot.duressSlot) {465-        try {466-          await installAdoptedDuressSlot(snapshot.duressSlot);467-        } catch (duressError) {468-          console.info(469-            "[Peach] adopted duress slot install failed (non-fatal):",470-            duressError,471-          );472-        }473-      }474-      await sendFrame(channel, {475-        type: "ADOPT_SNAPSHOT_APPLIED",476-        entries_received: Object.keys(snapshot.entries).length,477-        tombstones_received: Object.keys(snapshot.tombstones).length,478-      });479-480-      // Optional device graph. Non-fatal and isolated so a manifest timeout or481-      // unsupported source cannot demote an otherwise-successful entry snapshot.482-      try {483-        const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);484-        if (485-          isAdoptDeviceManifestFrame(manifestFrame) &&486-          adapter.applyIncomingDeviceManifest &&487-          typeof device.longTermPublicKeyHex === "string" &&488-          /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)489-        ) {490-          await adapter.applyIncomingDeviceManifest(491-            manifestFrame.manifest,492-            hexToBytes(device.longTermPublicKeyHex),493-          );494-          await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });495-        }496-      } catch {497-        // The graph arrives via incremental sync instead.498-      }499-500-      await markLastSyncedAt(device.deviceId, Date.now());501-      snapshotApplied = true;502-    } catch (snapshotError) {503-      console.info(504-        "[Peach] inline snapshot failed, falling back to sync pipeline:",505-        snapshotError,506-      );507-    }508-509-    // Adoption has committed identity and vault-key material, but entries have510-    // not necessarily crossed yet. Persist the exact source row before telling511-    // the popup pairing is complete so a popup close/reopen cannot reveal the512-    // temporarily empty receiver vault. The popup clears this only after that513-    // row records a successful sync.514-    await storageSet(INITIAL_SYNC_PENDING_DEVICE_KEY, device.deviceId).catch(515-      () => undefined,516-    );517-    push("ADOPTION_SOURCE_RESULT", { status: "complete", device });518-    createdVault = false;519-520-    if (snapshotApplied) {521-      // The snapshot populated the vault and marked the source synced, so the522-      // pending marker's job is done — clear it instead of making the popup wait.523-      await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);524-    } else {525-      // Fallback: the existing sync pipeline fills the vault. Immediate526-      // best-effort trigger plus alarm-based retries that survive SW death.527-      void triggerSyncIfReachable({ reason: "manual", force: true });528-      schedulePostAdoptionSync();529-    }530-  } catch (error) {531-    if (createdVault) {532-      try {533-        await rollbackAdoptedVault();534-        createdVault = false;535-      } catch (rollbackError) {536-        console.error("[Peach] rollback failed:", rollbackError);537-      }538-    }539-    console.error("[Peach] receiver session failed:", error);540-    push("ADOPTION_SOURCE_RESULT", {541-      status: "error",542-      error: error instanceof Error ? error.message : "adoption_failed",543-    });544-  } finally {545-    clearTimeout(timeout);546-    noise?.close();547-    socket.close();548-    ephemeralPrivate.fill(0);549-    ephemeralPublic.fill(0);550-  }551-}552-553-/**554- * A transferred entitlement is committed only when activation succeeds and555- * the stored value can immediately be re-read as Pro. activateLicense returns556- * a normal Free result for invalid input, so callers must not ignore it.557- */558-async function applyTransferredLicense(license: string): Promise<void> {559-  const activated = await activateLicense(license);560-  if (!activated.valid || activated.plan !== "pro" || !(await isProUser())) {561-    throw new Error("license_transfer_failed");562-  }563-}564-565-/**566- * Push the SAS to the popup, then block until the popup responds via the567- * `SOURCE_SAS_DECISION` message or the timeout fires. Returns false on568- * timeout or cancel so the adoption aborts before any vault state changes.569- *570- * The pending deferred MUST be installed before the push: a popup-side571- * auto-approver (test mocks, future automation) can synchronously resolve572- * the deferred the moment it sees the SAS push, and a missing slot would573- * drop that approval on the floor.574- */575-function waitForSourceSasConfirmation(576-  id: string,577-  request: AdoptRequest,578-  sas: string,579-): Promise<boolean> {580-  return new Promise<boolean>((resolve) => {581-    const timer = setTimeout(() => {582-      pendingSourceSasConfirmation.delete(id);583-      resolve(false);584-    }, SOURCE_WINDOW_MS);585-    pendingSourceSasConfirmation.set(id, {586-      resolve: (ok) => {587-        clearTimeout(timer);588-        resolve(ok);589-      },590-    });591-    push("ADOPTION_SOURCE_SAS", {592-      id,593-      sas,594-      receiverDeviceName: request.device_name,595-    });596-  });597-}598-599-/**600- * Opening Add Device in an unlocked extension is the source-side authorization.601- * Announce the SAS for comparison, then let the scanning device's explicit602- * confirmation be the single commit gate. grantAdoption still refuses to send603- * the root key until the receiver returns ADOPT_CONFIRM(true).604- */605-function requestConsent(request: AdoptRequest, sas: string, id: string): Promise<boolean> {606-  return waitForSourceSasConfirmation(id, request, sas);607-}608-609-function clearState(): void {610-  activeSocket = null;611-  inFlight = false;612-  pendingSourceSasConfirmation.clear();613-}614-615-function constantTimeEqual(a: string, b: string): boolean {616-  if (a.length !== b.length) return false;617-  let diff = 0;618-  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);619-  return diff === 0;620-}621-622-function encodeJson(value: unknown): Uint8Array {623-  return new TextEncoder().encode(JSON.stringify(value));624-}625-626-function decodeJson<T>(value: Uint8Array): T {627-  return JSON.parse(new TextDecoder().decode(value)) as T;628-}629-630-function hexToBytes(hex: string): Uint8Array {631-  const out = new Uint8Array(hex.length / 2);632-  for (let i = 0; i < out.length; i++) {633-    out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);634-  }635-  return out;636-}

view file at the parent

src/popup/views/PairDevice.tsx+1 −1
@@ -10,7 +10,7 @@1010 // the raw invitation off this source screen prevents it from being mistaken for1111 // the later 8-digit safety code.1212 //13-// The pairing itself runs in the background (relay-adoption-source-bg.ts) and13+// The pairing itself runs in the background (relay-adoption-bg.ts) and1414 // reaches this view via the ADOPTION_SOURCE_* messages.1515 1616 import {

view file