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 102 103 104 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 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 219 220 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 307 308 309 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 368 369 sasIds.push(p.id);370 }371 return Promise.resolve();372 }373 return Promise.resolve();374 },375 },376 },377 });378 void resolveSourceSasConfirmation; 379380 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 385 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 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 408 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 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 549 550 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 570 571 572 { type: "ADOPT_SNAPSHOT_APPLIED", entries_received: 0, tombstones_received: 0 },573 ]);574 mocks.openSocket.mockResolvedValue(socket);575 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 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 698 699 700 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 740 741 742 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() { },795 removeListener() { },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 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 838 839 840 841 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 884 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 891 892 893 894 895 const rootKeyHex = "f0".repeat(32);896 897 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 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 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 945 946 expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);947948 949 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 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 1027 1028 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 1047 expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });1048 1049 expect(mocks.pushes).toContainEqual(expect.objectContaining({1050 type: "ADOPTION_SOURCE_RESULT",1051 payload: expect.objectContaining({ status: "complete" }),1052 }));1053 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 1062 1063 1064 1065 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 1104 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 1122 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 1134 expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST");1135 });11361137 it("does not call applyIncomingDeviceManifest when the source omits the manifest frame", async () => {1138 1139 1140 1141 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 1154 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 1182 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}