relay-adoption-source-bg.test.ts

19.5 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";23const 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  localIdentity: vi.fn(),14  completePairing: vi.fn(),15  sasDecisions: [] as Array<{ id: string; ok: boolean }>,16}));1718vi.mock("../shared/pairing-dial", () => ({19  openRelayPairingSocket: mocks.openSocket,20}));2122vi.mock("../shared/noise-js", () => ({23  noiseNKResponder: vi.fn(async () => ({24    handshakeHash: new Uint8Array(32).fill(7),25    encrypt: (bytes: Uint8Array) => bytes,26    decrypt: (bytes: Uint8Array) => bytes,27    close: vi.fn(),28  })),29}));3031vi.mock("../shared/rendezvous", async (importOriginal) => {32  const actual = await importOriginal<typeof import("../shared/rendezvous")>();33  return {34    ...actual,35    generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",36  };37});383940vi.mock("./device-identity", () => ({41  getLocalPairingIdentity: async () => ({42    identity: {43      deviceId: "test-device",44      deviceName: "Test",45      deviceType: "extension",46      longTermPublicKeyHex: "aa".repeat(32),47      vaultId: "bb".repeat(32),48    },49    privateKey: new Uint8Array(32).fill(7),50    publicKey: new Uint8Array(32).fill(8),51  }),52  getDeviceName: mocks.getDeviceName,53}));5455vi.mock("./sync-settings", async (importOriginal) => ({56  ...(await importOriginal<typeof import("./sync-settings")>()),57  getSyncSettings: async () => ({58    mode: "public" as const,59    relayUrl: "wss://relay.peachpasswords.com",60  }),61}));6263vi.mock("./license", () => ({64  isProUser: vi.fn(async () => false),65  getLicenseKey: vi.fn(async () => undefined),66}));6768vi.mock("./vault", () => ({69  isUnlocked: vi.fn(() => true),70  getRootKeyHex: mocks.getRootKeyHex,71}));7273vi.mock("../shared/sync-identity", () => ({74  computeVaultId: vi.fn(async () => "b".repeat(64)),75}));7677vi.mock("./paired-devices", () => ({78  upsertPairedDevice: mocks.upsert,79}));8081vi.mock("./sync-trigger", () => ({82  triggerSyncIfReachable: mocks.triggerSync,83}));8485import {86  cancelRelayAdoptionSource,87  startRelayAdoptionSource,88} from "./relay-adoption-source-bg";8990const encode = (value: unknown) =>91  new TextEncoder().encode(JSON.stringify(value));9293function socketWithFrames(frames: unknown[]) {94  const queue = frames.map(encode);95  return {96    send: vi.fn(),97    receive: vi.fn(async () => {98      const next = queue.shift();99      if (!next) return new Promise<Uint8Array>(() => undefined);100      return next;101    }),102    close: vi.fn(),103    closedBeforeFrame: vi.fn(() => false),104  };105}106107async function waitForPush(type: string): Promise<void> {108  await vi.waitFor(() => {109    expect(mocks.pushes.some((message) => message.type === type)).toBe(true);110  });111}112113beforeEach(() => {114  mocks.pushes.length = 0;115  mocks.openSocket.mockReset();116  mocks.getLocalIdentity.mockReset();117  mocks.getRootKeyHex.mockReset();118  mocks.upsert.mockReset();119  mocks.triggerSync.mockReset();120  mocks.triggerSync.mockResolvedValue(undefined);121  mocks.getLocalIdentity.mockResolvedValue({122    identity: {123      deviceId: "browser-source",124      deviceName: "June's Browser",125      deviceType: "extension",126      longTermPublicKeyHex: "a".repeat(64),127      vaultId: "b".repeat(64),128    },129    privateKey: new Uint8Array(32).fill(1),130    publicKey: new Uint8Array(32).fill(2),131  });132  mocks.getRootKeyHex.mockReturnValue("c".repeat(64));133  mocks.upsert.mockResolvedValue(undefined);134  Object.defineProperty(globalThis, "chrome", {135    configurable: true,136    value: {137    runtime: {138      sendMessage(message: { type: string; payload: unknown }) {139        mocks.pushes.push(message);140        return Promise.resolve();141      },142    },143    },144  });145});146147describe("relay adoption source background boundary", () => {148  it("uses the receiver SAS confirmation as the only root-key commit gate", async () => {149    const socket = socketWithFrames([150      {151        type: "PEACH_HELLO",152        token: "AAECAwQFBgcICQoLDA0ODw",153      },154      {155        type: "ADOPT_REQUEST",156        device_id: "request-only-label",157        device_name: "June's phone",158        device_type: "android",159      },160      { type: "ADOPT_CONFIRM", confirmed: true },161      {162        type: "ADOPT_IDENTITY",163        device_id: "android-receiver",164        device_name: "June's phone",165        device_type: "android",166        long_term_public_key_hex: "d".repeat(64),167      },168      { type: "ADOPT_APPLIED" },169    ]);170    mocks.openSocket.mockResolvedValue(socket);171172    const started = await startRelayAdoptionSource();173    expect(started.invitation).toMatch(/^peach-pair-v1:/);174    await waitForPush("ADOPTION_SOURCE_SAS");175    await waitForPush("ADOPTION_SOURCE_RESULT");176177    expect(mocks.getRootKeyHex).toHaveBeenCalled();178    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({179      deviceId: "android-receiver",180      relayUrl: "wss://relay.peachpasswords.com",181    }));182    expect(mocks.pushes).toContainEqual(expect.objectContaining({183      type: "ADOPTION_SOURCE_RESULT",184      payload: expect.objectContaining({ status: "complete" }),185    }));186    expect(mocks.triggerSync).toHaveBeenCalledWith({187      reason: "manual",188      force: true,189    });190    const sentTypes = socket.send.mock.calls191      .map(([bytes]) => {192        try {193          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;194        } catch {195          return undefined;196        }197      });198    expect(sentTypes).toContain("ADOPT_COMPLETE");199  });200201  it("fails closed when the scanning device rejects the SAS", async () => {202    const socket = socketWithFrames([203      {204        type: "PEACH_HELLO",205        token: "AAECAwQFBgcICQoLDA0ODw",206      },207      {208        type: "ADOPT_REQUEST",209        device_id: "request-only-label",210        device_name: "New phone",211        device_type: "android",212      },213      { type: "ADOPT_CONFIRM", confirmed: false },214    ]);215    mocks.openSocket.mockResolvedValue(socket);216217    await startRelayAdoptionSource();218    await waitForPush("ADOPTION_SOURCE_SAS");219    await waitForPush("ADOPTION_SOURCE_RESULT");220221    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();222    expect(mocks.upsert).not.toHaveBeenCalled();223    expect(mocks.pushes).toContainEqual({224      type: "ADOPTION_SOURCE_RESULT",225      payload: { status: "error", error: "adoption_rejected" },226    });227  });228229  it("cancelling the source screen closes the room without reading the root", async () => {230    const socket = socketWithFrames([]);231    mocks.openSocket.mockResolvedValue(socket);232233    await startRelayAdoptionSource();234    cancelRelayAdoptionSource();235236    expect(socket.close).toHaveBeenCalled();237    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();238    expect(mocks.upsert).not.toHaveBeenCalled();239  });240});241242describe("relay adoption receiver background", () => {243  beforeEach(async () => {244    // The receiver session runs in the background via `void runReceiverSession`;245    // it doesn't get awaited by the caller and can leave `inFlight` set if the246    // socket fixture didn't deliver PEACH_HELLO. Reset explicitly so each test247    // starts from a clean state.248    const { cancelRelayAdoptionSource } = await import("./relay-adoption-source-bg");249    cancelRelayAdoptionSource();250    mocks.pushes.length = 0;251    mocks.sasDecisions.length = 0;252    mocks.openSocket.mockReset();253    mocks.getDeviceName.mockReset();254    mocks.getDeviceName.mockResolvedValue("Receiver Browser");255    mocks.createVault.mockReset();256    mocks.createVault.mockResolvedValue({ ok: true, data: { vaultId: "ee".repeat(32) } });257    mocks.activateLicense.mockReset();258    mocks.activateLicense.mockResolvedValue({ ok: true });259    mocks.localIdentity.mockReset();260    mocks.localIdentity.mockResolvedValue({261      ok: true,262      data: {263        deviceId: "receiver-id",264        deviceName: "Receiver Browser",265        deviceType: "extension",266        longTermPublicKeyHex: "11".repeat(32),267        signingPublicKeyHex: "22".repeat(32),268      },269    });270    mocks.completePairing.mockReset();271    mocks.completePairing.mockResolvedValue({ ok: true });272    mocks.upsert.mockReset();273    mocks.upsert.mockResolvedValue(undefined);274    mocks.triggerSync.mockReset();275    mocks.triggerSync.mockResolvedValue(undefined);276    mocks.openSocket.mockResolvedValue(socketWithFrames([]));277    Object.defineProperty(globalThis, "chrome", {278      configurable: true,279      value: {280        runtime: {281          sendMessage(message: { type: string; payload?: unknown }) {282            // The receiver blocks on a popup SAS decision; the test resolves283            // it via the exported helper instead of simulating the listener.284            if (message.type === "RECEIVER_SAS_DECISION") {285              const p = message.payload as { id?: string; ok?: boolean };286              if (typeof p?.id === "string" && typeof p?.ok === "boolean") {287                mocks.sasDecisions.push({ id: p.id, ok: p.ok });288              }289            }290            if (message.type === "CREATE_VAULT_FROM_ROOT_KEY") {291              return Promise.resolve(mocks.createVault(message.payload));292            }293            if (message.type === "ACTIVATE_LICENSE") {294              return Promise.resolve(mocks.activateLicense(message.payload));295            }296            if (message.type === "GET_LOCAL_IDENTITY") {297              return Promise.resolve(mocks.localIdentity());298            }299            if (message.type === "COMPLETE_PAIRING") {300              return Promise.resolve(mocks.completePairing(message.payload));301            }302            if (message.type === "TRIGGER_SYNC") {303              return Promise.resolve({ ok: true });304            }305            mocks.pushes.push({ type: message.type, payload: message.payload });306            return Promise.resolve({ ok: true });307          },308          onMessage: {309            addListener() { /* no-op */ },310            removeListener() { /* no-op */ },311          },312        },313      },314    });315  });316317  it("opens a relay socket and returns an empty invitation", async () => {318    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");319    const started = await startRelayAdoptionReceiver("receiver-password");320    expect(started.invitation).toMatch(/^peach-pair-v1:/);321    // The QR carries vault:"empty" so the phone knows to act as source.322    const decoded = decodeInvitation(started.invitation);323    expect(decoded?.vault).toBe("empty");324    expect(mocks.openSocket).toHaveBeenCalled();325  });326327  it("rejects an empty password", async () => {328    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");329    await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");330  });331332  it("the receiver wires requestAdoption, not grantAdoption", async () => {333    // The receiver's protocol position is the opposite of the source side:334    // it sends ADOPT_REQUEST, the phone returns ADOPT_GRANT with the root key.335    // Assert this by watching what lands on socket.send: the receiver's336    // outbound ADOPT_* traffic is ADOPT_REQUEST + ADOPT_CONFIRM + ADOPT_IDENTITY337    // + ADOPT_APPLIED. ADOPT_GRANT only ever belongs to the source side.338    const rootKeyHex = "f0".repeat(32);339    const vaultId = "b".repeat(64);340    const socket = socketWithFrames([341      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },342      {343        type: "ADOPT_GRANT",344        root_key_hex: rootKeyHex,345        vault_id: vaultId,346        source: {347          device_id: "phone-source-id",348          device_name: "June's phone",349          device_type: "android",350          long_term_public_key_hex: "99".repeat(32),351        },352      },353      { type: "ADOPT_COMPLETE" },354    ]);355    mocks.openSocket.mockResolvedValue(socket);356357    const { startRelayAdoptionReceiver, resolveReceiverSasConfirmation } =358      await import("./relay-adoption-source-bg");359    Object.defineProperty(globalThis, "chrome", {360      configurable: true,361      value: {362        runtime: {363          sendMessage(message: { type: string; payload?: unknown }) {364            if (message.type === "RECEIVER_SAS_DECISION") return Promise.resolve({ ok: true });365            if (message.type === "CREATE_VAULT_FROM_ROOT_KEY") {366              return Promise.resolve({ ok: true, data: { vaultId } });367            }368            if (message.type === "GET_LOCAL_IDENTITY") {369              return Promise.resolve({370                ok: true,371                data: {372                  deviceId: "receiver-id",373                  deviceName: "Receiver Browser",374                  deviceType: "extension",375                  longTermPublicKeyHex: "11".repeat(32),376                },377              });378            }379            if (message.type === "COMPLETE_PAIRING") {380              return Promise.resolve({ ok: true });381            }382            if (message.type === "TRIGGER_SYNC") {383              return Promise.resolve({ ok: true });384            }385            mocks.pushes.push({ type: message.type, payload: message.payload });386            if (message.type === "ADOPTION_SOURCE_SAS") {387              const p = message.payload as { id?: string };388              if (typeof p?.id === "string") resolveReceiverSasConfirmation(p.id, true);389            }390            return Promise.resolve({ ok: true });391          },392          onMessage: { addListener: () => undefined, removeListener: () => undefined },393        },394      },395    });396    await startRelayAdoptionReceiver("receiver-password");397    await waitForPush("ADOPTION_SOURCE_RESULT");398399    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })400      .mock.calls401      .map(([bytes]) => {402        try {403          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;404        } catch {405          return undefined;406        }407      });408    // ADOPT_REQUEST is the first adoption-protocol frame out of this side;409    // ADOPT_GRANT is the source side's frame and must never appear here.410    expect(sentTypes).toContain("ADOPT_REQUEST");411    expect(sentTypes).not.toContain("ADOPT_GRANT");412  });413414  it("completes the handshake end-to-end: SAS confirm → vault created → paired", async () => {415    // Wire the receiver's noise mock and SAS gate together so the full416    // requestAdoption protocol runs against an in-memory phone that sends417    // PEACH_HELLO, then ADOPT_CONFIRM(true), ADOPT_GRANT, ADOPT_COMPLETE.418    // The mocked noise is pass-through (encrypt = decrypt = identity), so419    // frames land as ordinary JSON.420    const { resolveReceiverSasConfirmation } = await import("./relay-adoption-source-bg");421    const rootKeyHex = "f0".repeat(32);422    // computeVaultId and createVaultFromRootKey both return "b".repeat(64);423    // the grant's vault_id is set to the same so the protocol proceeds.424    const vaultId = "b".repeat(64);425    const frames = [426      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },427      // ADOPT_CONFIRM is sent BY the receiver, not received. The next two428      // frames are what the phone returns: ADOPT_GRANT then ADOPT_COMPLETE.429      {430        type: "ADOPT_GRANT",431        root_key_hex: rootKeyHex,432        vault_id: vaultId,433        license: "PPK-test",434        source: {435          device_id: "phone-source-id",436          device_name: "June's phone",437          device_type: "android",438          long_term_public_key_hex: "99".repeat(32),439          signing_public_key_hex: "aa".repeat(32),440          relay_url: "wss://relay.peachpasswords.com",441        },442      },443      { type: "ADOPT_COMPLETE" },444    ];445    mocks.createVault.mockResolvedValue({ ok: true, data: { vaultId } });446    mocks.openSocket.mockResolvedValue(socketWithFrames(frames));447448    // Replace the chrome.runtime mock for this one test so we can449    // auto-approve the SAS the moment the background pushes it.450    Object.defineProperty(globalThis, "chrome", {451      configurable: true,452      value: {453        runtime: {454          sendMessage(message: { type: string; payload?: unknown }) {455            if (message.type === "RECEIVER_SAS_DECISION") {456              const p = message.payload as { id?: string; ok?: boolean };457              if (typeof p?.id === "string" && typeof p?.ok === "boolean") {458                mocks.sasDecisions.push({ id: p.id, ok: p.ok });459              }460              return Promise.resolve({ ok: true });461            }462            if (message.type === "CREATE_VAULT_FROM_ROOT_KEY") {463              return Promise.resolve(mocks.createVault(message.payload));464            }465            if (message.type === "ACTIVATE_LICENSE") {466              return Promise.resolve(mocks.activateLicense(message.payload));467            }468            if (message.type === "GET_LOCAL_IDENTITY") {469              return Promise.resolve(mocks.localIdentity());470            }471            if (message.type === "COMPLETE_PAIRING") {472              return Promise.resolve(mocks.completePairing(message.payload));473            }474            if (message.type === "TRIGGER_SYNC") {475              return Promise.resolve({ ok: true });476            }477            mocks.pushes.push({ type: message.type, payload: message.payload });478            // When the background announces the SAS, simulate the popup479            // tapping "They match": record a positive SAS decision AND480            // unblock the receiver immediately.481            if (message.type === "ADOPTION_SOURCE_SAS") {482              const p = message.payload as { id?: string };483              if (typeof p?.id === "string") {484                mocks.sasDecisions.push({ id: p.id, ok: true });485                resolveReceiverSasConfirmation(p.id, true);486              }487            }488            return Promise.resolve({ ok: true });489          },490          onMessage: {491            addListener: () => undefined,492            removeListener: () => undefined,493          },494        },495      },496    });497498    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");499    await startRelayAdoptionReceiver("receiver-password");500501    await waitForPush("ADOPTION_SOURCE_RESULT");502503    // The SAS push happened, and the popup-approved decision was recorded.504    expect(mocks.pushes.some((p) => p.type === "ADOPTION_SOURCE_SAS")).toBe(true);505    expect(mocks.sasDecisions.some((d) => d.ok === true)).toBe(true);506507    // CREATE_VAULT_FROM_ROOT_KEY was issued with the password the popup508    // supplied and the root key the phone granted.509    expect(mocks.createVault).toHaveBeenCalledWith(expect.objectContaining({510      password: "receiver-password",511      rootKeyHex,512    }));513514    // License activation, identity derivation, and pairing completion all515    // ran through the standard background handlers.516    expect(mocks.activateLicense).toHaveBeenCalledWith({ key: "PPK-test" });517    expect(mocks.localIdentity).toHaveBeenCalled();518    expect(mocks.completePairing).toHaveBeenCalled();519    const pairedCall = mocks.completePairing.mock.calls[0]?.[0] as520      { paired?: { deviceId?: string; deviceName?: string } };521    expect(pairedCall.paired?.deviceId).toBe("phone-source-id");522523    // The terminal state is "complete" — the popup can transition to success.524    expect(mocks.pushes).toContainEqual(expect.objectContaining({525      type: "ADOPTION_SOURCE_RESULT",526      payload: expect.objectContaining({ status: "complete" }),527    }));528  });529});530531function decodeInvitation(encoded: string): { vault?: string } | null {532  // Mirrors parsePairingInvitation's payload structure without pulling the533  // module in: the receiver test only needs to assert the embedded status.534  const at = encoded.indexOf(":");535  if (at < 0) return null;536  const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");537  try {538    const json = Buffer.from(b64, "base64").toString("utf8");539    return JSON.parse(json) as { vault?: string };540  } catch {541    return null;542  }543}