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
↓ peach-browser-e8856c99.tar.gzatlas at this save
files changed8 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 −1manifest.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",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",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,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+}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+}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-}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-}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 {