import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ openSocket: vi.fn(), getLocalIdentity: vi.fn(), getDeviceName: vi.fn(), getRootKeyHex: vi.fn(), upsert: vi.fn(), pushes: [] as Array<{ type: string; payload: unknown }>, triggerSync: vi.fn(), createVault: vi.fn(), activateLicense: vi.fn(), localIdentity: vi.fn(), completePairing: vi.fn(), sasDecisions: [] as Array<{ id: string; ok: boolean }>, })); vi.mock("../shared/pairing-dial", () => ({ openRelayPairingSocket: mocks.openSocket, })); vi.mock("../shared/noise-js", () => ({ noiseNKResponder: vi.fn(async () => ({ handshakeHash: new Uint8Array(32).fill(7), encrypt: (bytes: Uint8Array) => bytes, decrypt: (bytes: Uint8Array) => bytes, close: vi.fn(), })), })); vi.mock("../shared/rendezvous", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw", }; }); vi.mock("./device-identity", () => ({ getLocalPairingIdentity: async () => ({ identity: { deviceId: "test-device", deviceName: "Test", deviceType: "extension", longTermPublicKeyHex: "aa".repeat(32), vaultId: "bb".repeat(32), }, privateKey: new Uint8Array(32).fill(7), publicKey: new Uint8Array(32).fill(8), }), getDeviceName: mocks.getDeviceName, })); vi.mock("./sync-settings", async (importOriginal) => ({ ...(await importOriginal()), getSyncSettings: async () => ({ mode: "public" as const, relayUrl: "wss://relay.peachpasswords.com", }), })); vi.mock("./license", () => ({ isProUser: vi.fn(async () => false), getLicenseKey: vi.fn(async () => undefined), })); vi.mock("./vault", () => ({ isUnlocked: vi.fn(() => true), getRootKeyHex: mocks.getRootKeyHex, })); vi.mock("../shared/sync-identity", () => ({ computeVaultId: vi.fn(async () => "b".repeat(64)), })); vi.mock("./paired-devices", () => ({ upsertPairedDevice: mocks.upsert, })); vi.mock("./sync-trigger", () => ({ triggerSyncIfReachable: mocks.triggerSync, })); import { cancelRelayAdoptionSource, startRelayAdoptionSource, } from "./relay-adoption-source-bg"; const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value)); function socketWithFrames(frames: unknown[]) { const queue = frames.map(encode); return { send: vi.fn(), receive: vi.fn(async () => { const next = queue.shift(); if (!next) return new Promise(() => undefined); return next; }), close: vi.fn(), closedBeforeFrame: vi.fn(() => false), }; } async function waitForPush(type: string): Promise { await vi.waitFor(() => { expect(mocks.pushes.some((message) => message.type === type)).toBe(true); }); } beforeEach(() => { mocks.pushes.length = 0; mocks.openSocket.mockReset(); mocks.getLocalIdentity.mockReset(); mocks.getRootKeyHex.mockReset(); mocks.upsert.mockReset(); mocks.triggerSync.mockReset(); mocks.triggerSync.mockResolvedValue(undefined); mocks.getLocalIdentity.mockResolvedValue({ identity: { deviceId: "browser-source", deviceName: "June's Browser", deviceType: "extension", longTermPublicKeyHex: "a".repeat(64), vaultId: "b".repeat(64), }, privateKey: new Uint8Array(32).fill(1), publicKey: new Uint8Array(32).fill(2), }); mocks.getRootKeyHex.mockReturnValue("c".repeat(64)); mocks.upsert.mockResolvedValue(undefined); Object.defineProperty(globalThis, "chrome", { configurable: true, value: { runtime: { sendMessage(message: { type: string; payload: unknown }) { mocks.pushes.push(message); return Promise.resolve(); }, }, }, }); }); describe("relay adoption source background boundary", () => { it("uses the receiver SAS confirmation as the only root-key commit gate", async () => { const socket = socketWithFrames([ { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw", }, { type: "ADOPT_REQUEST", device_id: "request-only-label", device_name: "June's phone", device_type: "android", }, { type: "ADOPT_CONFIRM", confirmed: true }, { type: "ADOPT_IDENTITY", device_id: "android-receiver", device_name: "June's phone", device_type: "android", long_term_public_key_hex: "d".repeat(64), }, { type: "ADOPT_APPLIED" }, ]); mocks.openSocket.mockResolvedValue(socket); const started = await startRelayAdoptionSource(); expect(started.invitation).toMatch(/^peach-pair-v1:/); await waitForPush("ADOPTION_SOURCE_SAS"); await waitForPush("ADOPTION_SOURCE_RESULT"); expect(mocks.getRootKeyHex).toHaveBeenCalled(); expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "android-receiver", relayUrl: "wss://relay.peachpasswords.com", })); expect(mocks.pushes).toContainEqual(expect.objectContaining({ type: "ADOPTION_SOURCE_RESULT", payload: expect.objectContaining({ status: "complete" }), })); expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true, }); const sentTypes = socket.send.mock.calls .map(([bytes]) => { try { return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type; } catch { return undefined; } }); expect(sentTypes).toContain("ADOPT_COMPLETE"); }); it("fails closed when the scanning device rejects the SAS", async () => { const socket = socketWithFrames([ { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw", }, { type: "ADOPT_REQUEST", device_id: "request-only-label", device_name: "New phone", device_type: "android", }, { type: "ADOPT_CONFIRM", confirmed: false }, ]); mocks.openSocket.mockResolvedValue(socket); await startRelayAdoptionSource(); await waitForPush("ADOPTION_SOURCE_SAS"); await waitForPush("ADOPTION_SOURCE_RESULT"); expect(mocks.getRootKeyHex).not.toHaveBeenCalled(); expect(mocks.upsert).not.toHaveBeenCalled(); expect(mocks.pushes).toContainEqual({ type: "ADOPTION_SOURCE_RESULT", payload: { status: "error", error: "adoption_rejected" }, }); }); it("cancelling the source screen closes the room without reading the root", async () => { const socket = socketWithFrames([]); mocks.openSocket.mockResolvedValue(socket); await startRelayAdoptionSource(); cancelRelayAdoptionSource(); expect(socket.close).toHaveBeenCalled(); expect(mocks.getRootKeyHex).not.toHaveBeenCalled(); expect(mocks.upsert).not.toHaveBeenCalled(); }); }); describe("relay adoption receiver background", () => { beforeEach(async () => { // The receiver session runs in the background via `void runReceiverSession`; // it doesn't get awaited by the caller and can leave `inFlight` set if the // socket fixture didn't deliver PEACH_HELLO. Reset explicitly so each test // starts from a clean state. const { cancelRelayAdoptionSource } = await import("./relay-adoption-source-bg"); cancelRelayAdoptionSource(); mocks.pushes.length = 0; mocks.sasDecisions.length = 0; mocks.openSocket.mockReset(); mocks.getDeviceName.mockReset(); mocks.getDeviceName.mockResolvedValue("Receiver Browser"); mocks.createVault.mockReset(); mocks.createVault.mockResolvedValue({ ok: true, data: { vaultId: "ee".repeat(32) } }); mocks.activateLicense.mockReset(); mocks.activateLicense.mockResolvedValue({ ok: true }); mocks.localIdentity.mockReset(); mocks.localIdentity.mockResolvedValue({ ok: true, data: { deviceId: "receiver-id", deviceName: "Receiver Browser", deviceType: "extension", longTermPublicKeyHex: "11".repeat(32), signingPublicKeyHex: "22".repeat(32), }, }); mocks.completePairing.mockReset(); mocks.completePairing.mockResolvedValue({ ok: true }); mocks.upsert.mockReset(); mocks.upsert.mockResolvedValue(undefined); mocks.triggerSync.mockReset(); mocks.triggerSync.mockResolvedValue(undefined); mocks.openSocket.mockResolvedValue(socketWithFrames([])); Object.defineProperty(globalThis, "chrome", { configurable: true, value: { runtime: { sendMessage(message: { type: string; payload?: unknown }) { // The receiver blocks on a popup SAS decision; the test resolves // it via the exported helper instead of simulating the listener. if (message.type === "RECEIVER_SAS_DECISION") { const p = message.payload as { id?: string; ok?: boolean }; if (typeof p?.id === "string" && typeof p?.ok === "boolean") { mocks.sasDecisions.push({ id: p.id, ok: p.ok }); } } if (message.type === "CREATE_VAULT_FROM_ROOT_KEY") { return Promise.resolve(mocks.createVault(message.payload)); } if (message.type === "ACTIVATE_LICENSE") { return Promise.resolve(mocks.activateLicense(message.payload)); } if (message.type === "GET_LOCAL_IDENTITY") { return Promise.resolve(mocks.localIdentity()); } if (message.type === "COMPLETE_PAIRING") { return Promise.resolve(mocks.completePairing(message.payload)); } if (message.type === "TRIGGER_SYNC") { return Promise.resolve({ ok: true }); } mocks.pushes.push({ type: message.type, payload: message.payload }); return Promise.resolve({ ok: true }); }, onMessage: { addListener() { /* no-op */ }, removeListener() { /* no-op */ }, }, }, }, }); }); it("opens a relay socket and returns an empty invitation", async () => { const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg"); const started = await startRelayAdoptionReceiver("receiver-password"); expect(started.invitation).toMatch(/^peach-pair-v1:/); // The QR carries vault:"empty" so the phone knows to act as source. const decoded = decodeInvitation(started.invitation); expect(decoded?.vault).toBe("empty"); expect(mocks.openSocket).toHaveBeenCalled(); }); it("rejects an empty password", async () => { const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg"); await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD"); }); it("the receiver wires requestAdoption, not grantAdoption", async () => { // The receiver's protocol position is the opposite of the source side: // it sends ADOPT_REQUEST, the phone returns ADOPT_GRANT with the root key. // Assert this by watching what lands on socket.send: the receiver's // outbound ADOPT_* traffic is ADOPT_REQUEST + ADOPT_CONFIRM + ADOPT_IDENTITY // + ADOPT_APPLIED. ADOPT_GRANT only ever belongs to the source side. const rootKeyHex = "f0".repeat(32); const vaultId = "b".repeat(64); const socket = socketWithFrames([ { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" }, { type: "ADOPT_GRANT", root_key_hex: rootKeyHex, vault_id: vaultId, source: { device_id: "phone-source-id", device_name: "June's phone", device_type: "android", long_term_public_key_hex: "99".repeat(32), }, }, { type: "ADOPT_COMPLETE" }, ]); mocks.openSocket.mockResolvedValue(socket); const { startRelayAdoptionReceiver, resolveReceiverSasConfirmation } = await import("./relay-adoption-source-bg"); Object.defineProperty(globalThis, "chrome", { configurable: true, value: { runtime: { sendMessage(message: { type: string; payload?: unknown }) { if (message.type === "RECEIVER_SAS_DECISION") return Promise.resolve({ ok: true }); if (message.type === "CREATE_VAULT_FROM_ROOT_KEY") { return Promise.resolve({ ok: true, data: { vaultId } }); } if (message.type === "GET_LOCAL_IDENTITY") { return Promise.resolve({ ok: true, data: { deviceId: "receiver-id", deviceName: "Receiver Browser", deviceType: "extension", longTermPublicKeyHex: "11".repeat(32), }, }); } if (message.type === "COMPLETE_PAIRING") { return Promise.resolve({ ok: true }); } if (message.type === "TRIGGER_SYNC") { return Promise.resolve({ ok: true }); } mocks.pushes.push({ type: message.type, payload: message.payload }); if (message.type === "ADOPTION_SOURCE_SAS") { const p = message.payload as { id?: string }; if (typeof p?.id === "string") resolveReceiverSasConfirmation(p.id, true); } return Promise.resolve({ ok: true }); }, onMessage: { addListener: () => undefined, removeListener: () => undefined }, }, }, }); await startRelayAdoptionReceiver("receiver-password"); await waitForPush("ADOPTION_SOURCE_RESULT"); const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } }) .mock.calls .map(([bytes]) => { try { return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type; } catch { return undefined; } }); // ADOPT_REQUEST is the first adoption-protocol frame out of this side; // ADOPT_GRANT is the source side's frame and must never appear here. expect(sentTypes).toContain("ADOPT_REQUEST"); expect(sentTypes).not.toContain("ADOPT_GRANT"); }); it("completes the handshake end-to-end: SAS confirm → vault created → paired", async () => { // Wire the receiver's noise mock and SAS gate together so the full // requestAdoption protocol runs against an in-memory phone that sends // PEACH_HELLO, then ADOPT_CONFIRM(true), ADOPT_GRANT, ADOPT_COMPLETE. // The mocked noise is pass-through (encrypt = decrypt = identity), so // frames land as ordinary JSON. const { resolveReceiverSasConfirmation } = await import("./relay-adoption-source-bg"); const rootKeyHex = "f0".repeat(32); // computeVaultId and createVaultFromRootKey both return "b".repeat(64); // the grant's vault_id is set to the same so the protocol proceeds. const vaultId = "b".repeat(64); const frames = [ { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" }, // ADOPT_CONFIRM is sent BY the receiver, not received. The next two // frames are what the phone returns: ADOPT_GRANT then ADOPT_COMPLETE. { type: "ADOPT_GRANT", root_key_hex: rootKeyHex, vault_id: vaultId, license: "PPK-test", source: { device_id: "phone-source-id", device_name: "June's phone", device_type: "android", long_term_public_key_hex: "99".repeat(32), signing_public_key_hex: "aa".repeat(32), relay_url: "wss://relay.peachpasswords.com", }, }, { type: "ADOPT_COMPLETE" }, ]; mocks.createVault.mockResolvedValue({ ok: true, data: { vaultId } }); mocks.openSocket.mockResolvedValue(socketWithFrames(frames)); // Replace the chrome.runtime mock for this one test so we can // auto-approve the SAS the moment the background pushes it. Object.defineProperty(globalThis, "chrome", { configurable: true, value: { runtime: { sendMessage(message: { type: string; payload?: unknown }) { if (message.type === "RECEIVER_SAS_DECISION") { const p = message.payload as { id?: string; ok?: boolean }; if (typeof p?.id === "string" && typeof p?.ok === "boolean") { mocks.sasDecisions.push({ id: p.id, ok: p.ok }); } return Promise.resolve({ ok: true }); } if (message.type === "CREATE_VAULT_FROM_ROOT_KEY") { return Promise.resolve(mocks.createVault(message.payload)); } if (message.type === "ACTIVATE_LICENSE") { return Promise.resolve(mocks.activateLicense(message.payload)); } if (message.type === "GET_LOCAL_IDENTITY") { return Promise.resolve(mocks.localIdentity()); } if (message.type === "COMPLETE_PAIRING") { return Promise.resolve(mocks.completePairing(message.payload)); } if (message.type === "TRIGGER_SYNC") { return Promise.resolve({ ok: true }); } mocks.pushes.push({ type: message.type, payload: message.payload }); // When the background announces the SAS, simulate the popup // tapping "They match": record a positive SAS decision AND // unblock the receiver immediately. if (message.type === "ADOPTION_SOURCE_SAS") { const p = message.payload as { id?: string }; if (typeof p?.id === "string") { mocks.sasDecisions.push({ id: p.id, ok: true }); resolveReceiverSasConfirmation(p.id, true); } } return Promise.resolve({ ok: true }); }, onMessage: { addListener: () => undefined, removeListener: () => undefined, }, }, }, }); const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg"); await startRelayAdoptionReceiver("receiver-password"); await waitForPush("ADOPTION_SOURCE_RESULT"); // The SAS push happened, and the popup-approved decision was recorded. expect(mocks.pushes.some((p) => p.type === "ADOPTION_SOURCE_SAS")).toBe(true); expect(mocks.sasDecisions.some((d) => d.ok === true)).toBe(true); // CREATE_VAULT_FROM_ROOT_KEY was issued with the password the popup // supplied and the root key the phone granted. expect(mocks.createVault).toHaveBeenCalledWith(expect.objectContaining({ password: "receiver-password", rootKeyHex, })); // License activation, identity derivation, and pairing completion all // ran through the standard background handlers. expect(mocks.activateLicense).toHaveBeenCalledWith({ key: "PPK-test" }); expect(mocks.localIdentity).toHaveBeenCalled(); expect(mocks.completePairing).toHaveBeenCalled(); const pairedCall = mocks.completePairing.mock.calls[0]?.[0] as { paired?: { deviceId?: string; deviceName?: string } }; expect(pairedCall.paired?.deviceId).toBe("phone-source-id"); // The terminal state is "complete" — the popup can transition to success. expect(mocks.pushes).toContainEqual(expect.objectContaining({ type: "ADOPTION_SOURCE_RESULT", payload: expect.objectContaining({ status: "complete" }), })); }); }); function decodeInvitation(encoded: string): { vault?: string } | null { // Mirrors parsePairingInvitation's payload structure without pulling the // module in: the receiver test only needs to assert the embedded status. const at = encoded.indexOf(":"); if (at < 0) return null; const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/"); try { const json = Buffer.from(b64, "base64").toString("utf8"); return JSON.parse(json) as { vault?: string }; } catch { return null; } }