relay-adoption-bg.test.ts

44.3 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";2import { padSyncFrame } from "../shared/sync-padding";34const mocks = vi.hoisted(() => ({5  openSocket: vi.fn(),6  getLocalIdentity: vi.fn(),7  getDeviceName: vi.fn(),8  getRootKeyHex: vi.fn(),9  upsert: vi.fn(),10  clearRevocation: vi.fn(),11  hasIdentityConflict: vi.fn(),12  rotateDeviceId: vi.fn(),13  setPairSecret: vi.fn(),14  pushes: [] as Array<{ type: string; payload: unknown }>,15  triggerSync: vi.fn(),16  createVault: vi.fn(),17  activateLicense: vi.fn(),18  isProUser: vi.fn(),19  getLicenseKey: vi.fn(),20  localIdentity: vi.fn(),21  rollbackVault: vi.fn(),22  createSyncAdapter: vi.fn(),23  __applyIncomingDeviceManifest: vi.fn(),24  releaseAutoLock: vi.fn(),25  deferAutoLock: vi.fn(),26  storage: new Map<string, string>(),27  storageSet: vi.fn(),28  storageGet: vi.fn(),29  storageRemove: vi.fn(),30  sasDecisions: [] as Array<{ id: string; ok: boolean }>,31  isPostReset: vi.fn(),32  markRepair: vi.fn(),33}));3435vi.mock("../shared/pairing-dial", () => ({36  openRelayPairingSocket: mocks.openSocket,37}));3839vi.mock("../shared/noise-js", () => ({40  noiseNKResponder: vi.fn(async () => ({41    handshakeHash: new Uint8Array(32).fill(7),42    encrypt: (bytes: Uint8Array) => bytes,43    decrypt: (bytes: Uint8Array) => bytes,44    close: vi.fn(),45  })),46}));4748vi.mock("../shared/rendezvous", async (importOriginal) => {49  const actual = await importOriginal<typeof import("../shared/rendezvous")>();50  return {51    ...actual,52    generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",53  };54});555657vi.mock("./device-identity", () => ({58  getLocalPairingIdentity: async () => ({59    identity: {60      deviceId: "test-device",61      deviceName: "Test",62      deviceType: "extension",63      longTermPublicKeyHex: "aa".repeat(32),64      vaultId: "bb".repeat(32),65    },66    privateKey: new Uint8Array(32).fill(7),67    publicKey: new Uint8Array(32).fill(8),68  }),69  getDeviceName: mocks.getDeviceName,70  buildLocalIdentity: mocks.localIdentity,71  preparePreVaultIdentity: vi.fn(async () => undefined),72  readPreVaultIdentity: mocks.localIdentity,73  rotatePreVaultIdentity: mocks.localIdentity,74  rotateDeviceId: mocks.rotateDeviceId,75}));7677vi.mock("./sync-settings", async (importOriginal) => ({78  ...(await importOriginal<typeof import("./sync-settings")>()),79  getSyncSettings: async () => ({80    mode: "public" as const,81    relayUrl: "wss://relay.peachpasswords.com",82  }),83}));8485vi.mock("./license", () => ({86  isProUser: mocks.isProUser,87  getLicenseKey: mocks.getLicenseKey,88  activateLicense: mocks.activateLicense,89}));9091vi.mock("./vault", () => ({92  isUnlocked: vi.fn(() => true),93  getRootKeyHex: mocks.getRootKeyHex,94  createVaultFromRootKey: mocks.createVault,95  rollbackAdoptedVault: mocks.rollbackVault,96  exportDuressSlotForAdoption: vi.fn(async () => null),97  installAdoptedDuressSlot: vi.fn(async () => undefined),98}));99100vi.mock("../shared/sync-identity", async (importOriginal) => {101  // Partial mock: the manifest test needs the REAL deriveSyncSigningKeypair /102  // pubkeyToHex to build a verifiable signed manifest (2026-08-04: this test103  // was orphaned inside the rendezvous mock factory and never ran; the mock104  // below had since lost the real exports).105  const actual = await importOriginal<typeof import("../shared/sync-identity")>();106  return {107    ...actual,108    computeVaultId: vi.fn(async () => "b".repeat(64)),109  };110});111112vi.mock("../shared/storage", () => ({113  storageSet: mocks.storageSet,114  storageGet: mocks.storageGet,115  storageRemove: mocks.storageRemove,116  storageClear: vi.fn(async () => mocks.storage.clear()),117}));118const chromeStorage = {119  local: {120    get: async (key: string) => ({ [key]: await mocks.storageGet(key) }),121    set: async (items: Record<string, string>) => {122      for (const [key, value] of Object.entries(items)) await mocks.storageSet(key, value);123    },124    remove: async (key: string) => mocks.storageRemove(key),125    clear: async () => mocks.storage.clear(),126  },127};128vi.mock("./paired-devices", () => ({129  upsertPairedDevice: mocks.upsert,130  upsertPairedDeviceAndClearRevocation: mocks.upsert,131  clearDeviceRevocation: mocks.clearRevocation,132  hasAdoptionIdentityConflict: mocks.hasIdentityConflict,133}));134135vi.mock("./pair-secrets", () => ({136  setPairSecret: mocks.setPairSecret,137  isValidPairSecret: (value: unknown): value is string =>138    typeof value === "string" && /^[0-9a-f]{64}$/.test(value),139}));140141vi.mock("./sync-trigger", () => ({142  triggerSyncIfReachable: mocks.triggerSync,143}));144145vi.mock("./sync-generation", () => ({146  browserGenerationIo: {},147  isPostReset: mocks.isPostReset,148  onDirectV2PairingEstablished: mocks.markRepair,149}));150151vi.mock("./alarms", () => ({152  deferAutoLockForOperation: mocks.deferAutoLock,153}));154155vi.mock("./sync-vault-adapter", () => ({156  createSyncVaultAdapter: mocks.createSyncAdapter,157}));158159import {160  cancelRelayAdoptionSource,161  startRelayAdoptionSource,162} from "./relay-adoption-bg";163164const encode = (value: unknown) =>165  new TextEncoder().encode(JSON.stringify(value));166167function adoptionV2Frames(options: {168  rootKeyHex: string;169  vaultId: string;170  deviceId?: string;171  deviceName?: string;172  longTermPublicKeyHex?: string;173  signingPublicKeyHex?: string;174  license?: string;175  snapshotCapable?: boolean;176}): unknown[] {177  const license = options.license ?? "PPK-test";178  return [179    { type: "ADOPT_CONFIRM_REQUIRED" },180    {181      type: "ADOPT_PREPARE",182      device_id: options.deviceId ?? "phone-source-id",183      device_name: options.deviceName ?? "June's phone",184      device_type: "android",185      long_term_public_key_hex: options.longTermPublicKeyHex ?? "99".repeat(32),186      signing_public_key_hex: options.signingPublicKeyHex ?? "aa".repeat(32),187      vault_id: options.vaultId,188      relay_url: "wss://relay.peachpasswords.com",189      license,190      snapshot_capable: options.snapshotCapable,191    },192    {193      type: "ADOPT_GRANT",194      root_key_hex: options.rootKeyHex,195      vault_id: options.vaultId,196      license,197    },198    { type: "ADOPT_COMPLETE" },199  ];200}201202function socketWithFrames(203  frames: unknown[],204  options: { closeOnEmpty?: boolean } = {},205) {206  const queue = frames.map((frame) => {207    const encoded = encode(frame);208    // PB-26-604: snapshot frames are bucket-padded before Noise.209    return (frame as { type?: string }).type === "ADOPT_SNAPSHOT"210      ? padSyncFrame(encoded)211      : encoded;212  });213  return {214    send: vi.fn(),215    receive: vi.fn(async () => {216      const next = queue.shift();217      if (!next) {218        // A real peer closes the channel once it runs out of frames; model219        // that so the receiver's post-ADOPT_APPLIED snapshot read rejects fast220        // and falls back to the sync pipeline instead of hanging on a timeout.221        if (options.closeOnEmpty) throw new Error("socket_closed");222        return new Promise<Uint8Array>(() => undefined);223      }224      return next;225    }),226    close: vi.fn(),227    closedBeforeFrame: vi.fn(() => false),228  };229}230231async function waitForPush(type: string): Promise<void> {232  await vi.waitFor(() => {233    expect(mocks.pushes.some((message) => message.type === type)).toBe(true);234  });235}236237beforeEach(() => {238  mocks.pushes.length = 0;239  mocks.sasDecisions.length = 0;240  mocks.openSocket.mockReset();241  mocks.getLocalIdentity.mockReset();242  mocks.getRootKeyHex.mockReset();243  mocks.upsert.mockReset();244  mocks.clearRevocation.mockReset();245  mocks.clearRevocation.mockResolvedValue(undefined);246  mocks.hasIdentityConflict.mockReset();247  mocks.hasIdentityConflict.mockResolvedValue(false);248  mocks.rotateDeviceId.mockReset();249  mocks.rotateDeviceId.mockResolvedValue(undefined);250  mocks.setPairSecret.mockReset();251  mocks.triggerSync.mockReset();252  mocks.triggerSync.mockResolvedValue(undefined);253  mocks.isPostReset.mockReset();254  mocks.isPostReset.mockResolvedValue(false);255  mocks.markRepair.mockReset();256  mocks.markRepair.mockResolvedValue(undefined);257  mocks.releaseAutoLock.mockReset();258  mocks.deferAutoLock.mockReset();259  mocks.deferAutoLock.mockReturnValue(mocks.releaseAutoLock);260  mocks.storage.clear();261  mocks.storageSet.mockReset();262  mocks.storageSet.mockImplementation(async (key: string, value: string) => {263    mocks.storage.set(key, value);264  });265  mocks.storageGet.mockReset();266  mocks.storageGet.mockImplementation(async (key: string) => mocks.storage.get(key) ?? null);267  mocks.storageRemove.mockReset();268  mocks.storageRemove.mockImplementation(async (key: string) => {269    mocks.storage.delete(key);270  });271  mocks.isProUser.mockReset();272  mocks.isProUser.mockResolvedValue(true);273  mocks.getLicenseKey.mockReset();274  mocks.getLicenseKey.mockResolvedValue("PPK-source");275  mocks.getLocalIdentity.mockResolvedValue({276    identity: {277      deviceId: "browser-source",278      deviceName: "June's Browser",279      deviceType: "extension",280      longTermPublicKeyHex: "a".repeat(64),281      vaultId: "b".repeat(64),282    },283    privateKey: new Uint8Array(32).fill(1),284    publicKey: new Uint8Array(32).fill(2),285  });286  mocks.getRootKeyHex.mockReturnValue("c".repeat(64));287  mocks.upsert.mockResolvedValue(undefined);288  mocks.setPairSecret.mockResolvedValue(undefined);289  const applyIncomingDeviceManifest = vi.fn(async () => undefined);290    mocks.__applyIncomingDeviceManifest = applyIncomingDeviceManifest;291    mocks.createSyncAdapter.mockReturnValue({292      buildEntryManifest: vi.fn(async () => ({})),293      getEncryptedEntries: vi.fn(async () => ({})),294      buildTombstoneManifest: vi.fn(async () => ({})),295      applyEntries: vi.fn(async () => undefined),296      applyTombstones: vi.fn(async () => undefined),297      buildOutgoingDeviceManifest: vi.fn(async () => null),298      applyIncomingDeviceManifest,299    });300  Object.defineProperty(globalThis, "chrome", {301    configurable: true,302    value: {303    runtime: {304      async sendMessage(message: { type: string; payload: unknown }) {305        mocks.pushes.push(message);306        // The source flow waits for the popup to send SOURCE_SAS_DECISION before307        // forwarding the root key. The receiver test overrides this mock and308        // resolves the decision itself; the source tests rely on this default309        // auto-approver so the happy-path test reaches the ADOPT_IDENTITY frame.310        if (message.type === "ADOPTION_SOURCE_SAS") {311          const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };312          if (typeof p?.id === "string" && typeof p.sas === "string") {313            if (p.displayOnly === false) {314              const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");315              resolveReceiverSasConfirmation(p.id, p.sas, true);316            } else {317              const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");318              mocks.sasDecisions.push({ id: p.id, ok: true });319              resolveSourceSasConfirmation(p.id, true);320            }321          }322        }323        return Promise.resolve();324      },325    },326    },327  });328});329330describe("relay adoption source background boundary", () => {331  it("shows the SAS and waits for the popup's single confirmation", async () => {332    const socket = socketWithFrames([333      {334        type: "PEACH_HELLO",335        token: "AAECAwQFBgcICQoLDA0ODw",336      },337      {338        type: "ADOPT_REQUEST",339        device_id: "request-only-label",340        device_name: "June's phone",341        device_type: "android",342      },343      { type: "ADOPT_CONFIRM", confirmed: true },344      {345        type: "ADOPT_IDENTITY",346        device_id: "android-receiver",347        device_name: "June's phone",348        device_type: "android",349        long_term_public_key_hex: "d".repeat(64),350      },351      { type: "ADOPT_APPLIED" },352    ]);353    mocks.openSocket.mockResolvedValue(socket);354355    const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");356    const sasIds: string[] = [];357    Object.defineProperty(globalThis, "chrome", {358      configurable: true,359      value: {360        storage: chromeStorage,361        runtime: {362          async sendMessage(message: { type: string; payload: unknown }) {363            mocks.pushes.push(message);364            if (message.type === "ADOPTION_SOURCE_SAS") {365              const p = message.payload as { id?: string };366              if (typeof p?.id === "string") {367                // Hold the SAS gate open until the test explicitly approves368                // it — this proves the source flow waits for the popup.369                sasIds.push(p.id);370              }371              return Promise.resolve();372            }373            return Promise.resolve();374          },375        },376      },377    });378    void resolveSourceSasConfirmation; // imported for the post-approval call below379380    const started = await startRelayAdoptionSource();381    expect(started.invitation).toMatch(/^peach-pair-v2:/);382    expect(mocks.deferAutoLock).toHaveBeenCalledWith(5 * 60 * 1000);383    await waitForPush("ADOPTION_SOURCE_SAS");384    // The adoption must NOT be complete yet — the popup's SAS decision is the385    // prerequisite for forwarding the root key.386    await vi.waitFor(() => {387      expect(mocks.sasDecisions.length).toBe(0);388    });389    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();390    expect(mocks.upsert).not.toHaveBeenCalled();391392    // Approve the SAS; the adoption should now proceed and complete.393    const id = sasIds[0];394    expect(typeof id).toBe("string");395    resolveSourceSasConfirmation(id, true);396    mocks.sasDecisions.push({ id, ok: true });397398    await waitForPush("ADOPTION_SOURCE_RESULT");399400    await vi.waitFor(() => expect(mocks.releaseAutoLock).toHaveBeenCalled());401402    expect(mocks.getRootKeyHex).toHaveBeenCalled();403    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({404      deviceId: "android-receiver",405      relayUrl: "wss://relay.peachpasswords.com",406    }));407    // PB-26-213: the registry row stays secretless; the secret bypasses it408    // into the encrypted pair-secret record.409    const sourceRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;410    expect(sourceRow.rendezvousSecretHex).toBeUndefined();411    expect(mocks.setPairSecret).toHaveBeenCalledWith(412      "android-receiver",413      expect.stringMatching(/^[0-9a-f]{64}$/),414      expect.any(Uint8Array),415    );416    expect(mocks.pushes).toContainEqual({417      type: "ADOPTION_SOURCE_RESULT",418      payload: { status: "complete" },419    });420    expect(mocks.triggerSync).toHaveBeenCalledWith({421      reason: "manual",422      force: true,423    });424    const sentTypes = socket.send.mock.calls425      .map(([bytes]) => {426        try {427          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;428        } catch {429          return undefined;430        }431      });432    expect(sentTypes).toContain("ADOPT_COMPLETE");433  });434435  it("refuses a v1 request without granting once this device has reset", async () => {436    const socket = socketWithFrames([437      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },438      {439        type: "ADOPT_REQUEST",440        device_id: "legacy-label",441        device_name: "Old phone",442        device_type: "android",443      },444    ]);445    mocks.openSocket.mockResolvedValue(socket);446    mocks.isPostReset.mockResolvedValue(true);447448    await startRelayAdoptionSource();449    await waitForPush("ADOPTION_SOURCE_RESULT");450451    expect(mocks.pushes).toContainEqual({452      type: "ADOPTION_SOURCE_RESULT",453      payload: { status: "error", error: "peer_protocol_outdated" },454    });455    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();456    expect(mocks.upsert).not.toHaveBeenCalled();457    const sentTypes = socket.send.mock.calls.map(([bytes]) => {458      try {459        return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;460      } catch {461        return undefined;462      }463    });464    expect(sentTypes).not.toContain("ADOPT_GRANT");465  });466467  it("still runs the v1 transcript before a reset", async () => {468    const socket = socketWithFrames([469      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },470      {471        type: "ADOPT_REQUEST",472        device_id: "legacy-label",473        device_name: "Old phone",474        device_type: "android",475      },476      { type: "ADOPT_CONFIRM", confirmed: true },477      {478        type: "ADOPT_IDENTITY",479        device_id: "android-receiver",480        device_name: "Old phone",481        device_type: "android",482        long_term_public_key_hex: "d".repeat(64),483      },484      { type: "ADOPT_APPLIED" },485    ]);486    mocks.openSocket.mockResolvedValue(socket);487    mocks.isPostReset.mockResolvedValue(false);488489    await startRelayAdoptionSource();490    await waitForPush("ADOPTION_SOURCE_RESULT");491492    // Non-ejectable, not withdrawn: the v1 path is unchanged until the reset.493    expect(mocks.getRootKeyHex).toHaveBeenCalled();494    expect(mocks.pushes).not.toContainEqual({495      type: "ADOPTION_SOURCE_RESULT",496      payload: { status: "error", error: "peer_protocol_outdated" },497    });498  });499500  it("fails adoption without sending snapshot frames when duress is oversized", async () => {501    const socket = socketWithFrames([502      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },503      {504        type: "ADOPT_REQUEST",505        device_id: "request-only-label",506        device_name: "June's phone",507        device_type: "android",508        snapshot_capable: true,509      },510      { type: "ADOPT_CONFIRM", confirmed: true },511      {512        type: "ADOPT_IDENTITY",513        device_id: "android-receiver",514        device_name: "June's phone",515        device_type: "android",516        long_term_public_key_hex: "d".repeat(64),517      },518      { type: "ADOPT_APPLIED" },519    ]);520    mocks.openSocket.mockResolvedValue(socket);521    const { exportDuressSlotForAdoption } = await import("./vault");522    vi.mocked(exportDuressSlotForAdoption).mockResolvedValueOnce(523      "x".repeat(768 * 1024),524    );525526    await startRelayAdoptionSource();527    await waitForPush("ADOPTION_SOURCE_RESULT");528529    expect(mocks.pushes).toContainEqual({530      type: "ADOPTION_SOURCE_RESULT",531      payload: {532        status: "error",533        error: "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE",534      },535    });536    expect(mocks.pushes).not.toContainEqual(expect.objectContaining({537      type: "ADOPTION_SOURCE_RESULT",538      payload: expect.objectContaining({ status: "complete" }),539    }));540    const sentTypes = socket.send.mock.calls.map(([bytes]) =>541      JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,542    );543    expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");544    expect(mocks.triggerSync).not.toHaveBeenCalled();545  });546547  it("sends no snapshot frames and completes via sync fallback when the vault exceeds the inline budget", async () => {548    // PB-26-206: an over-budget snapshot is never partially sent or silently549    // truncated — the source completes adoption and the sync pipeline carries550    // the entries instead.551    const socket = socketWithFrames([552      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },553      {554        type: "ADOPT_REQUEST",555        device_id: "request-only-label",556        device_name: "June's phone",557        device_type: "android",558        snapshot_capable: true,559      },560      { type: "ADOPT_CONFIRM", confirmed: true },561      {562        type: "ADOPT_IDENTITY",563        device_id: "android-receiver",564        device_name: "June's phone",565        device_type: "android",566        long_term_public_key_hex: "d".repeat(64),567      },568      { type: "ADOPT_APPLIED" },569      // The receiver's ADOPT_SNAPSHOT_APPLIED ack never arrives (nothing is570      // sent); the source must not hang on it — withTimeout resolves the wait571      // via the sync fallback path below.572      { type: "ADOPT_SNAPSHOT_APPLIED", entries_received: 0, tombstones_received: 0 },573    ]);574    mocks.openSocket.mockResolvedValue(socket);575    // 16 envelopes of ~674 KiB ≈ 10.8 MiB > 10 MiB aggregate budget.576    const envelope = "c".repeat(690_000);577    const entries: Record<string, string> = {};578    for (let i = 0; i < 16; i++) entries[`big-${i}`] = envelope;579    mocks.createSyncAdapter.mockReturnValue({580      buildEntryManifest: vi.fn(async () => Object.fromEntries(581        Object.keys(entries).map((id) => [id, 1]),582      )),583      getEncryptedEntries: vi.fn(async () => entries),584      buildTombstoneManifest: vi.fn(async () => ({})),585      applyEntries: vi.fn(async () => undefined),586      applyTombstones: vi.fn(async () => undefined),587      buildOutgoingDeviceManifest: vi.fn(async () => null),588      applyIncomingDeviceManifest: vi.fn(async () => undefined),589    });590591    await startRelayAdoptionSource();592    await waitForPush("ADOPTION_SOURCE_RESULT");593594    expect(mocks.pushes).toContainEqual({595      type: "ADOPTION_SOURCE_RESULT",596      payload: expect.objectContaining({ status: "complete" }),597    });598    const sentTypes = socket.send.mock.calls.map(([bytes]) =>599      JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,600    );601    expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");602    // Sync fallback ran so the entries still reach the receiver.603    expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });604  });605606  it("fails closed when the popup rejects the SAS", async () => {607    const socket = socketWithFrames([608      {609        type: "PEACH_HELLO",610        token: "AAECAwQFBgcICQoLDA0ODw",611      },612      {613        type: "ADOPT_REQUEST",614        device_id: "request-only-label",615        device_name: "New phone",616        device_type: "android",617      },618      { type: "ADOPT_CONFIRM", confirmed: false },619    ]);620    mocks.openSocket.mockResolvedValue(socket);621622    const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");623    let pendingId: string | undefined;624    Object.defineProperty(globalThis, "chrome", {625      configurable: true,626      value: {627        runtime: {628          sendMessage(message: { type: string; payload: unknown }) {629            mocks.pushes.push(message);630            if (message.type === "ADOPTION_SOURCE_SAS") {631              const p = message.payload as { id?: string };632              if (typeof p?.id === "string") pendingId = p.id;633            }634            return Promise.resolve();635          },636        },637      },638    });639640    await startRelayAdoptionSource();641    await waitForPush("ADOPTION_SOURCE_SAS");642    expect(typeof pendingId).toBe("string");643    resolveSourceSasConfirmation(pendingId!, false);644    mocks.sasDecisions.push({ id: pendingId!, ok: false });645646    await waitForPush("ADOPTION_SOURCE_RESULT");647648    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();649    expect(mocks.upsert).not.toHaveBeenCalled();650    expect(mocks.pushes).toContainEqual({651      type: "ADOPTION_SOURCE_RESULT",652      payload: { status: "error", error: "adoption_rejected" },653    });654  });655656  it("rejects before reading the root key when neither peer has Pro", async () => {657    const socket = socketWithFrames([658      {659        type: "PEACH_HELLO",660        token: "AAECAwQFBgcICQoLDA0ODw",661      },662      {663        type: "ADOPT_REQUEST",664        device_id: "free-receiver",665        device_name: "Free phone",666        device_type: "android",667      },668      { type: "ADOPT_CONFIRM", confirmed: true },669    ]);670    mocks.isProUser.mockResolvedValue(false);671    mocks.getLicenseKey.mockResolvedValue(undefined);672    mocks.openSocket.mockResolvedValue(socket);673674    await startRelayAdoptionSource();675    await waitForPush("ADOPTION_SOURCE_RESULT");676677    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();678    expect(mocks.upsert).not.toHaveBeenCalled();679    expect(mocks.pushes).toContainEqual({680      type: "ADOPTION_SOURCE_RESULT",681      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },682    });683    const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {684      try {685        return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];686      } catch {687        return [];688      }689    });690    expect(sentFrames).toContainEqual({691      type: "ADOPT_REJECT",692      reason: "ADOPT_PRO_REQUIRED",693    });694  });695696  it("fails closed when the user never answers the SAS", async () => {697    // vi.useFakeTimers is unsafe with the live noise/socket mocks (they698    // schedule real work), so we hold the SAS gate open by never resolving699    // the deferred and then cancel the session. clearState must drop the700    // pending SAS so it doesn't pin the relay room open for the next run.701    const socket = socketWithFrames([]);702    mocks.openSocket.mockResolvedValue(socket);703704    Object.defineProperty(globalThis, "chrome", {705      value: {706        storage: chromeStorage,707        runtime: {708          sendMessage(message: { type: string; payload: unknown }) {709            mocks.pushes.push(message);710            return Promise.resolve();711          },712        },713      },714    });715716    await startRelayAdoptionSource();717    cancelRelayAdoptionSource();718719    expect(socket.close).toHaveBeenCalled();720    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();721    expect(mocks.upsert).not.toHaveBeenCalled();722  });723724  it("cancelling the source screen closes the room without reading the root", async () => {725    const socket = socketWithFrames([]);726    mocks.openSocket.mockResolvedValue(socket);727728    await startRelayAdoptionSource();729    cancelRelayAdoptionSource();730731    expect(socket.close).toHaveBeenCalled();732    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();733    expect(mocks.upsert).not.toHaveBeenCalled();734  });735});736737describe("relay adoption receiver background", () => {738  beforeEach(async () => {739    // The receiver session runs in the background via `void runReceiverSession`;740    // it doesn't get awaited by the caller and can leave `inFlight` set if the741    // socket fixture didn't deliver PEACH_HELLO. Reset explicitly so each test742    // starts from a clean state.743    const { cancelRelayAdoptionSource } = await import("./relay-adoption-bg");744    cancelRelayAdoptionSource();745    mocks.pushes.length = 0;746    mocks.openSocket.mockReset();747    mocks.getDeviceName.mockReset();748    mocks.getDeviceName.mockResolvedValue("Receiver Browser");749    mocks.createVault.mockReset();750    mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });751    mocks.activateLicense.mockReset();752    mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });753    mocks.isProUser.mockReset();754    mocks.isProUser.mockResolvedValue(true);755    mocks.getLicenseKey.mockReset();756    mocks.getLicenseKey.mockResolvedValue(undefined);757    mocks.localIdentity.mockReset();758    mocks.localIdentity.mockResolvedValue({759      deviceId: "receiver-id",760      deviceName: "Receiver Browser",761      deviceType: "extension",762      longTermPublicKeyHex: "11".repeat(32),763      signingPublicKeyHex: "22".repeat(32),764    });765    mocks.rollbackVault.mockReset();766    mocks.rollbackVault.mockResolvedValue(undefined);767    mocks.storageSet.mockReset();768    mocks.storageSet.mockImplementation(async (key: string, value: string) => {769      mocks.storage.set(key, value);770    });771    mocks.upsert.mockReset();772    mocks.upsert.mockResolvedValue(undefined);773    mocks.setPairSecret.mockReset();774    mocks.setPairSecret.mockResolvedValue(undefined);775    mocks.triggerSync.mockReset();776    mocks.triggerSync.mockResolvedValue(undefined);777    mocks.openSocket.mockResolvedValue(socketWithFrames([]));778    Object.defineProperty(globalThis, "chrome", {779      value: {780        storage: chromeStorage,781        runtime: {782          async sendMessage(message: { type: string; payload?: unknown }) {783            mocks.pushes.push({ type: message.type, payload: message.payload });784            if (message.type === "ADOPTION_SOURCE_SAS") {785              const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };786              if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {787                const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");788                resolveReceiverSasConfirmation(p.id, p.sas, true);789              }790            }791            return { ok: true };792          },793          onMessage: {794            addListener() { /* no-op */ },795            removeListener() { /* no-op */ },796          },797        },798      },799    });800  });801802  it("opens a relay socket and returns an empty invitation", async () => {803    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");804    const started = await startRelayAdoptionReceiver("receiver-password");805    expect(started.invitation).toMatch(/^peach-pair-v2:/);806    // The QR carries vault:"empty" so the phone knows to act as source.807    const decoded = decodeInvitation(started.invitation);808    expect(decoded?.vault).toBe("empty");809    expect(mocks.openSocket).toHaveBeenCalled();810  });811812  it("rejects an empty password", async () => {813    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");814    await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");815  });816817  it("surfaces the Pro-required rejection without creating a vault", async () => {818    mocks.openSocket.mockResolvedValue(socketWithFrames([819      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },820      { type: "ADOPT_CONFIRM_REQUIRED" },821      { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },822    ]));823824    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");825    await startRelayAdoptionReceiver("receiver-password");826    await waitForPush("ADOPTION_SOURCE_RESULT");827828    expect(mocks.createVault).not.toHaveBeenCalled();829    expect(mocks.rollbackVault).not.toHaveBeenCalled();830    expect(mocks.pushes).toContainEqual({831      type: "ADOPTION_SOURCE_RESULT",832      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },833    });834  });835836  it("the receiver wires requestAdoption, not grantAdoption", async () => {837    // The receiver's protocol position is the opposite of the source side:838    // it sends ADOPT_REQUEST, the phone returns ADOPT_GRANT with the root key.839    // Assert this by watching what lands on socket.send: the receiver's840    // outbound ADOPT_* traffic is ADOPT_REQUEST + ADOPT_CONFIRM + ADOPT_IDENTITY841    // + ADOPT_APPLIED. ADOPT_GRANT only ever belongs to the source side.842    const rootKeyHex = "f0".repeat(32);843    const vaultId = "b".repeat(64);844    const socket = socketWithFrames([845      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },846      ...adoptionV2Frames({ rootKeyHex, vaultId }),847    ], { closeOnEmpty: true });848    mocks.openSocket.mockResolvedValue(socket);849    mocks.createVault.mockResolvedValue({ vaultId });850851    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");852    Object.defineProperty(globalThis, "chrome", {853      value: {854        storage: chromeStorage,855        runtime: {856          async sendMessage(message: { type: string; payload?: unknown }) {857            mocks.pushes.push({ type: message.type, payload: message.payload });858            if (message.type === "ADOPTION_SOURCE_SAS") {859              const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };860              if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {861                const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");862                resolveReceiverSasConfirmation(p.id, p.sas, true);863              }864            }865            return { ok: true };866          },867          onMessage: { addListener: () => undefined, removeListener: () => undefined },868        },869      },870    });871    await startRelayAdoptionReceiver("receiver-password");872    await waitForPush("ADOPTION_SOURCE_RESULT");873874    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })875      .mock.calls876      .map(([bytes]) => {877        try {878          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;879        } catch {880          return undefined;881        }882      });883    // ADOPT_REQUEST is the first adoption-protocol frame out of this side;884    // ADOPT_GRANT is the source side's frame and must never appear here.885    expect(sentTypes).toContain("ADOPT_REQUEST_V2");886    expect(sentTypes).not.toContain("ADOPT_GRANT");887  });888889  it("completes the handshake end-to-end: vault created → paired", async () => {890    // Wire the receiver's noise mock and SAS gate together so the full891    // requestAdoption protocol runs against an in-memory phone that sends892    // PEACH_HELLO, then ADOPT_CONFIRM(true), ADOPT_GRANT, ADOPT_COMPLETE.893    // The mocked noise is pass-through (encrypt = decrypt = identity), so894    // frames land as ordinary JSON.895    const rootKeyHex = "f0".repeat(32);896    // computeVaultId and createVaultFromRootKey both return "b".repeat(64);897    // the grant's vault_id is set to the same so the protocol proceeds.898    const vaultId = "b".repeat(64);899    const frames = [900      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },901      ...adoptionV2Frames({ rootKeyHex, vaultId }),902    ];903    mocks.createVault.mockResolvedValue({ vaultId });904    mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));905906    // Replace the chrome.runtime mock for this one test and approve the displayed SAS.907    Object.defineProperty(globalThis, "chrome", {908      value: {909        storage: chromeStorage,910        runtime: {911          async sendMessage(message: { type: string; payload?: unknown }) {912            mocks.pushes.push({ type: message.type, payload: message.payload });913            if (message.type === "ADOPTION_SOURCE_SAS") {914              const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };915              if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {916                const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");917                resolveReceiverSasConfirmation(p.id, p.sas, true);918              }919            }920            return { ok: true };921          },922          onMessage: {923            addListener: () => undefined,924            removeListener: () => undefined,925          },926        },927      },928    });929930    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");931    await startRelayAdoptionReceiver("receiver-password");932933    await waitForPush("ADOPTION_SOURCE_RESULT");934935    // The receiver displays its locally derived SAS and requires confirmation.936    expect(mocks.pushes).toContainEqual({937      type: "ADOPTION_SOURCE_SAS",938      payload: expect.objectContaining({939        sas: expect.stringMatching(/^\d{8}$/),940        displayOnly: false,941      }),942    });943944    // The vault module was called directly with the popup-supplied password945    // and the root key granted by the phone.946    expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);947948    // License activation, identity derivation, and pairing persistence all949    // call their modules directly from the service worker.950    expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");951    expect(mocks.isProUser).toHaveBeenCalled();952    expect(mocks.localIdentity).toHaveBeenCalled();953    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({954      deviceId: "phone-source-id",955    }));956    const receiverRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;957    expect(receiverRow.rendezvousSecretHex).toBeUndefined();958    expect(mocks.setPairSecret).toHaveBeenCalledWith(959      "phone-source-id",960      expect.stringMatching(/^[0-9a-f]{64}$/),961      expect.any(Uint8Array),962    );963    expect(mocks.storageSet).toHaveBeenCalledWith(964      "peach_initial_sync_pending_device_id",965      "phone-source-id",966    );967    expect(mocks.rollbackVault).not.toHaveBeenCalled();968969    // The terminal state is "complete" — the popup can transition to success.970    expect(mocks.pushes).toContainEqual({971      type: "ADOPTION_SOURCE_RESULT",972      payload: { status: "complete" },973    });974  });975976  it("rolls back and refuses completion when a carried Pro license is not activated", async () => {977    const rootKeyHex = "f0".repeat(32);978    const vaultId = "b".repeat(64);979    mocks.createVault.mockResolvedValue({ vaultId });980    mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });981    mocks.isProUser.mockResolvedValue(false);982    mocks.openSocket.mockResolvedValue(socketWithFrames([983      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },984      ...adoptionV2Frames({ rootKeyHex, vaultId, license: "PPK-invalid" }),985    ]));986987    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");988    await startRelayAdoptionReceiver("receiver-password");989    await waitForPush("ADOPTION_SOURCE_RESULT");990991    expect(mocks.rollbackVault).toHaveBeenCalledOnce();992    expect(mocks.localIdentity).toHaveBeenCalled();993    expect(mocks.upsert).not.toHaveBeenCalled();994    expect(mocks.pushes).toContainEqual(expect.objectContaining({995      type: "ADOPTION_SOURCE_RESULT",996      payload: expect.objectContaining({997        status: "error",998        error: "license_transfer_failed",999      }),1000    }));1001  });10021003  it("rolls back a newly created vault when a later adoption step fails", async () => {1004    const rootKeyHex = "f0".repeat(32);1005    const vaultId = "b".repeat(64);1006    mocks.createVault.mockResolvedValue({ vaultId });1007    mocks.upsert.mockRejectedValue(new Error("pair_persist_failed"));1008    mocks.openSocket.mockResolvedValue(socketWithFrames([1009      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1010      ...adoptionV2Frames({ rootKeyHex, vaultId }),1011    ]));10121013    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1014    await startRelayAdoptionReceiver("receiver-password");1015    await waitForPush("ADOPTION_SOURCE_RESULT");10161017    expect(mocks.rollbackVault).toHaveBeenCalledOnce();1018    expect(mocks.upsert).toHaveBeenCalledOnce();1019    expect(mocks.pushes).toContainEqual(expect.objectContaining({1020      type: "ADOPTION_SOURCE_RESULT",1021      payload: expect.objectContaining({ status: "error", error: "pair_persist_failed" }),1022    }));1023  });10241025  it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {1026    // The source's ADOPT_GRANT does not echo snapshot_capable (pre-C source).1027    // The receiver should skip the 30-second inline snapshot wait entirely1028    // and fall through to the sync pipeline — no receiveVaultSnapshot call.1029    const rootKeyHex = "f0".repeat(32);1030    const vaultId = "b".repeat(64);1031    mocks.createVault.mockResolvedValue({ vaultId });1032    mocks.openSocket.mockResolvedValue(socketWithFrames([1033      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1034      ...adoptionV2Frames({1035        rootKeyHex,1036        vaultId,1037        deviceId: "old-phone-id",1038        deviceName: "Old phone",1039      }),1040    ], { closeOnEmpty: true }));10411042    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1043    await startRelayAdoptionReceiver("receiver-password");1044    await waitForPush("ADOPTION_SOURCE_RESULT");10451046    // Sync fallback ran immediately (no 30s wait).1047    expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });1048    // Adoption completed successfully.1049    expect(mocks.pushes).toContainEqual(expect.objectContaining({1050      type: "ADOPTION_SOURCE_RESULT",1051      payload: expect.objectContaining({ status: "complete" }),1052    }));1053    // The initial sync pending marker was set (not cleared — no snapshot applied).1054    expect(mocks.storageSet).toHaveBeenCalledWith(1055      "peach_initial_sync_pending_device_id",1056      "old-phone-id",1057    );1058  });10591060  it("applies the source's device manifest after the snapshot, then acks", async () => {1061    // Mesh propagation: a 3rd browser that completes an adoption with a1062    // snapshot-capable source must also receive the source's signed device1063    // manifest and forward it to the sync adapter before the receiver1064    // resolves. The relay stays an opaque forwarder, so the whole path1065    // lives in `relay-adoption-bg.ts` and `sync-vault-adapter.ts`.1066    const rootKeyHex = "f0".repeat(32);1067    const vaultId = "b".repeat(64);1068    const longTermPubkeyHex = "99".repeat(32);1069    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");1070    const { deriveSyncSigningKeypair } = await import(1071      "../shared/legacy-identity-derivation.test-support"1072    );1073    const { pubkeyToHex } = await import("../shared/sync-identity");1074    const issuerKp = await deriveSyncSigningKeypair(1075      new Uint8Array(32).fill(0x55),1076      "phone-source-id",1077    );1078    const manifest = await buildSignedDeviceManifest(1079      "phone-source-id",1080      [1081        {1082          deviceId: "peer-b",1083          deviceName: "Pool B",1084          deviceType: "extension",1085          longTermPublicKeyHex: "11".repeat(32),1086          signingPublicKeyHex: "22".repeat(32),1087          wsPort: 49152,1088          relayUrl: "wss://relay.peachpasswords.com",1089          pairedAt: 1,1090        },1091      ],1092      issuerKp.privateKey,1093    );1094    const socket = socketWithFrames([1095      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1096      ...adoptionV2Frames({1097        rootKeyHex,1098        vaultId,1099        longTermPublicKeyHex: longTermPubkeyHex,1100        signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),1101        snapshotCapable: true,1102      }),1103      // Current protocol: the snapshot arrives as ADOPT_SNAPSHOT chunk frames1104      // after the handshake; one empty chunk ends the phase.1105      { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1106      { type: "ADOPT_DEVICE_MANIFEST", manifest },1107    ], { closeOnEmpty: true });1108    mocks.createVault.mockResolvedValue({ vaultId });1109    mocks.openSocket.mockResolvedValue(socket);1110    mocks.__applyIncomingDeviceManifest.mockClear();11111112    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1113    await startRelayAdoptionReceiver("receiver-password");1114    await waitForPush("ADOPTION_SOURCE_RESULT");11151116    expect(mocks.__applyIncomingDeviceManifest).toHaveBeenCalledTimes(1);1117    const [forwarded, pubkey] = mocks.__applyIncomingDeviceManifest.mock.calls[0];1118    expect(forwarded).toEqual(manifest);1119    expect(pubkey).toBeInstanceOf(Uint8Array);1120    expect(pubkey.length).toBe(32);1121    // The receiver must NOT have sent ADOPT_DEVICE_MANIFEST_APPLIED if the1122    // adapter path was skipped (the ack only follows a real apply).1123    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1124      .mock.calls1125      .map(([bytes]) => {1126        try {1127          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1128        } catch {1129          return undefined;1130        }1131      });1132    expect(sentTypes).toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1133    // And the source's ADOPT_DEVICE_MANIFEST frame must have been consumed.1134    expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST");1135  });11361137  it("does not call applyIncomingDeviceManifest when the source omits the manifest frame", async () => {1138    // The source is snapshot-capable but does not send an ADOPT_DEVICE_MANIFEST1139    // frame. The receiver should commit the snapshot, send ADOPT_SNAPSHOT_APPLIED,1140    // and quietly time out the optional 5s wait without acking a manifest1141    // or invoking the adapter path.1142    const rootKeyHex = "f0".repeat(32);1143    const vaultId = "b".repeat(64);1144    const longTermPubkeyHex = "99".repeat(32);1145    const socket = socketWithFrames([1146      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1147      ...adoptionV2Frames({1148        rootKeyHex,1149        vaultId,1150        longTermPublicKeyHex: longTermPubkeyHex,1151        snapshotCapable: true,1152      }),1153      // Current protocol: one empty ADOPT_SNAPSHOT chunk ends the snapshot1154      // phase; then NO ADOPT_DEVICE_MANIFEST follow-up.1155      { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1156    ], { closeOnEmpty: true });1157    mocks.createVault.mockResolvedValue({ vaultId });1158    mocks.openSocket.mockResolvedValue(socket);1159    mocks.__applyIncomingDeviceManifest.mockClear();11601161    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1162    await startRelayAdoptionReceiver("receiver-password");1163    await waitForPush("ADOPTION_SOURCE_RESULT");11641165    expect(mocks.__applyIncomingDeviceManifest).not.toHaveBeenCalled();1166    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1167      .mock.calls1168      .map(([bytes]) => {1169        try {1170          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1171        } catch {1172          return undefined;1173        }1174      });1175    expect(sentTypes).toContain("ADOPT_SNAPSHOT_APPLIED");1176    expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1177  });1178});11791180function decodeInvitation(encoded: string): { vault?: string } | null {1181  // Mirrors parsePairingInvitation's payload structure without pulling the1182  // module in: the receiver test only needs to assert the embedded status.1183  const at = encoded.indexOf(":");1184  if (at < 0) return null;1185  const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");1186  try {1187    const json = Buffer.from(b64, "base64").toString("utf8");1188    return JSON.parse(json) as { vault?: string };1189  } catch {1190    return null;1191  }1192}