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}));3233vi.mock("../shared/pairing-dial", () => ({34 openRelayPairingSocket: mocks.openSocket,35}));3637vi.mock("../shared/noise-js", () => ({38 noiseNKResponder: vi.fn(async () => ({39 handshakeHash: new Uint8Array(32).fill(7),40 encrypt: (bytes: Uint8Array) => bytes,41 decrypt: (bytes: Uint8Array) => bytes,42 close: vi.fn(),43 })),44}));4546vi.mock("../shared/rendezvous", async (importOriginal) => {47 const actual = await importOriginal<typeof import("../shared/rendezvous")>();48 return {49 ...actual,50 generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",51 };52});535455vi.mock("./device-identity", () => ({56 getLocalPairingIdentity: async () => ({57 identity: {58 deviceId: "test-device",59 deviceName: "Test",60 deviceType: "extension",61 longTermPublicKeyHex: "aa".repeat(32),62 vaultId: "bb".repeat(32),63 },64 privateKey: new Uint8Array(32).fill(7),65 publicKey: new Uint8Array(32).fill(8),66 }),67 getDeviceName: mocks.getDeviceName,68 buildLocalIdentity: mocks.localIdentity,69 preparePreVaultIdentity: vi.fn(async () => undefined),70 readPreVaultIdentity: mocks.localIdentity,71 rotatePreVaultIdentity: mocks.localIdentity,72 rotateDeviceId: mocks.rotateDeviceId,73}));7475vi.mock("./sync-settings", async (importOriginal) => ({76 ...(await importOriginal<typeof import("./sync-settings")>()),77 getSyncSettings: async () => ({78 mode: "public" as const,79 relayUrl: "wss://relay.peachpasswords.com",80 }),81}));8283vi.mock("./license", () => ({84 isProUser: mocks.isProUser,85 getLicenseKey: mocks.getLicenseKey,86 activateLicense: mocks.activateLicense,87}));8889vi.mock("./vault", () => ({90 isUnlocked: vi.fn(() => true),91 getRootKeyHex: mocks.getRootKeyHex,92 createVaultFromRootKey: mocks.createVault,93 rollbackAdoptedVault: mocks.rollbackVault,94 exportDuressSlotForAdoption: vi.fn(async () => null),95 installAdoptedDuressSlot: vi.fn(async () => undefined),96}));9798vi.mock("../shared/sync-identity", async (importOriginal) => {99 100 101 102 103 const actual = await importOriginal<typeof import("../shared/sync-identity")>();104 return {105 ...actual,106 computeVaultId: vi.fn(async () => "b".repeat(64)),107 };108});109110vi.mock("../shared/storage", () => ({111 storageSet: mocks.storageSet,112 storageGet: mocks.storageGet,113 storageRemove: mocks.storageRemove,114 storageClear: vi.fn(async () => mocks.storage.clear()),115}));116const chromeStorage = {117 local: {118 get: async (key: string) => ({ [key]: await mocks.storageGet(key) }),119 set: async (items: Record<string, string>) => {120 for (const [key, value] of Object.entries(items)) await mocks.storageSet(key, value);121 },122 remove: async (key: string) => mocks.storageRemove(key),123 clear: async () => mocks.storage.clear(),124 },125};126vi.mock("./paired-devices", () => ({127 upsertPairedDevice: mocks.upsert,128 upsertPairedDeviceAndClearRevocation: mocks.upsert,129 clearDeviceRevocation: mocks.clearRevocation,130 hasAdoptionIdentityConflict: mocks.hasIdentityConflict,131}));132133vi.mock("./pair-secrets", () => ({134 setPairSecret: mocks.setPairSecret,135 isValidPairSecret: (value: unknown): value is string =>136 typeof value === "string" && /^[0-9a-f]{64}$/.test(value),137}));138139vi.mock("./sync-trigger", () => ({140 triggerSyncIfReachable: mocks.triggerSync,141}));142143vi.mock("./alarms", () => ({144 deferAutoLockForOperation: mocks.deferAutoLock,145}));146147vi.mock("./sync-vault-adapter", () => ({148 createSyncVaultAdapter: mocks.createSyncAdapter,149}));150151import {152 cancelRelayAdoptionSource,153 startRelayAdoptionSource,154} from "./relay-adoption-bg";155156const encode = (value: unknown) =>157 new TextEncoder().encode(JSON.stringify(value));158159function adoptionV2Frames(options: {160 rootKeyHex: string;161 vaultId: string;162 deviceId?: string;163 deviceName?: string;164 longTermPublicKeyHex?: string;165 signingPublicKeyHex?: string;166 license?: string;167 snapshotCapable?: boolean;168}): unknown[] {169 const license = options.license ?? "PPK-test";170 return [171 { type: "ADOPT_CONFIRM_REQUIRED" },172 {173 type: "ADOPT_PREPARE",174 device_id: options.deviceId ?? "phone-source-id",175 device_name: options.deviceName ?? "June's phone",176 device_type: "android",177 long_term_public_key_hex: options.longTermPublicKeyHex ?? "99".repeat(32),178 signing_public_key_hex: options.signingPublicKeyHex ?? "aa".repeat(32),179 vault_id: options.vaultId,180 relay_url: "wss://relay.peachpasswords.com",181 license,182 snapshot_capable: options.snapshotCapable,183 },184 {185 type: "ADOPT_GRANT",186 root_key_hex: options.rootKeyHex,187 vault_id: options.vaultId,188 license,189 },190 { type: "ADOPT_COMPLETE" },191 ];192}193194function socketWithFrames(195 frames: unknown[],196 options: { closeOnEmpty?: boolean } = {},197) {198 const queue = frames.map((frame) => {199 const encoded = encode(frame);200 201 return (frame as { type?: string }).type === "ADOPT_SNAPSHOT"202 ? padSyncFrame(encoded)203 : encoded;204 });205 return {206 send: vi.fn(),207 receive: vi.fn(async () => {208 const next = queue.shift();209 if (!next) {210 211 212 213 if (options.closeOnEmpty) throw new Error("socket_closed");214 return new Promise<Uint8Array>(() => undefined);215 }216 return next;217 }),218 close: vi.fn(),219 closedBeforeFrame: vi.fn(() => false),220 };221}222223async function waitForPush(type: string): Promise<void> {224 await vi.waitFor(() => {225 expect(mocks.pushes.some((message) => message.type === type)).toBe(true);226 });227}228229beforeEach(() => {230 mocks.pushes.length = 0;231 mocks.sasDecisions.length = 0;232 mocks.openSocket.mockReset();233 mocks.getLocalIdentity.mockReset();234 mocks.getRootKeyHex.mockReset();235 mocks.upsert.mockReset();236 mocks.clearRevocation.mockReset();237 mocks.clearRevocation.mockResolvedValue(undefined);238 mocks.hasIdentityConflict.mockReset();239 mocks.hasIdentityConflict.mockResolvedValue(false);240 mocks.rotateDeviceId.mockReset();241 mocks.rotateDeviceId.mockResolvedValue(undefined);242 mocks.setPairSecret.mockReset();243 mocks.triggerSync.mockReset();244 mocks.triggerSync.mockResolvedValue(undefined);245 mocks.releaseAutoLock.mockReset();246 mocks.deferAutoLock.mockReset();247 mocks.deferAutoLock.mockReturnValue(mocks.releaseAutoLock);248 mocks.storage.clear();249 mocks.storageSet.mockReset();250 mocks.storageSet.mockImplementation(async (key: string, value: string) => {251 mocks.storage.set(key, value);252 });253 mocks.storageGet.mockReset();254 mocks.storageGet.mockImplementation(async (key: string) => mocks.storage.get(key) ?? null);255 mocks.storageRemove.mockReset();256 mocks.storageRemove.mockImplementation(async (key: string) => {257 mocks.storage.delete(key);258 });259 mocks.isProUser.mockReset();260 mocks.isProUser.mockResolvedValue(true);261 mocks.getLicenseKey.mockReset();262 mocks.getLicenseKey.mockResolvedValue("PPK-source");263 mocks.getLocalIdentity.mockResolvedValue({264 identity: {265 deviceId: "browser-source",266 deviceName: "June's Browser",267 deviceType: "extension",268 longTermPublicKeyHex: "a".repeat(64),269 vaultId: "b".repeat(64),270 },271 privateKey: new Uint8Array(32).fill(1),272 publicKey: new Uint8Array(32).fill(2),273 });274 mocks.getRootKeyHex.mockReturnValue("c".repeat(64));275 mocks.upsert.mockResolvedValue(undefined);276 mocks.setPairSecret.mockResolvedValue(undefined);277 const applyIncomingDeviceManifest = vi.fn(async () => undefined);278 mocks.__applyIncomingDeviceManifest = applyIncomingDeviceManifest;279 mocks.createSyncAdapter.mockReturnValue({280 buildEntryManifest: vi.fn(async () => ({})),281 getEncryptedEntries: vi.fn(async () => ({})),282 buildTombstoneManifest: vi.fn(async () => ({})),283 applyEntries: vi.fn(async () => undefined),284 applyTombstones: vi.fn(async () => undefined),285 buildOutgoingDeviceManifest: vi.fn(async () => null),286 applyIncomingDeviceManifest,287 });288 Object.defineProperty(globalThis, "chrome", {289 configurable: true,290 value: {291 runtime: {292 async sendMessage(message: { type: string; payload: unknown }) {293 mocks.pushes.push(message);294 295 296 297 298 if (message.type === "ADOPTION_SOURCE_SAS") {299 const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };300 if (typeof p?.id === "string" && typeof p.sas === "string") {301 if (p.displayOnly === false) {302 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");303 resolveReceiverSasConfirmation(p.id, p.sas, true);304 } else {305 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");306 mocks.sasDecisions.push({ id: p.id, ok: true });307 resolveSourceSasConfirmation(p.id, true);308 }309 }310 }311 return Promise.resolve();312 },313 },314 },315 });316});317318describe("relay adoption source background boundary", () => {319 it("shows the SAS and waits for the popup's single confirmation", async () => {320 const socket = socketWithFrames([321 {322 type: "PEACH_HELLO",323 token: "AAECAwQFBgcICQoLDA0ODw",324 },325 {326 type: "ADOPT_REQUEST",327 device_id: "request-only-label",328 device_name: "June's phone",329 device_type: "android",330 },331 { type: "ADOPT_CONFIRM", confirmed: true },332 {333 type: "ADOPT_IDENTITY",334 device_id: "android-receiver",335 device_name: "June's phone",336 device_type: "android",337 long_term_public_key_hex: "d".repeat(64),338 },339 { type: "ADOPT_APPLIED" },340 ]);341 mocks.openSocket.mockResolvedValue(socket);342343 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");344 const sasIds: string[] = [];345 Object.defineProperty(globalThis, "chrome", {346 configurable: true,347 value: {348 storage: chromeStorage,349 runtime: {350 async sendMessage(message: { type: string; payload: unknown }) {351 mocks.pushes.push(message);352 if (message.type === "ADOPTION_SOURCE_SAS") {353 const p = message.payload as { id?: string };354 if (typeof p?.id === "string") {355 356 357 sasIds.push(p.id);358 }359 return Promise.resolve();360 }361 return Promise.resolve();362 },363 },364 },365 });366 void resolveSourceSasConfirmation; 367368 const started = await startRelayAdoptionSource();369 expect(started.invitation).toMatch(/^peach-pair-v2:/);370 expect(mocks.deferAutoLock).toHaveBeenCalledWith(5 * 60 * 1000);371 await waitForPush("ADOPTION_SOURCE_SAS");372 373 374 await vi.waitFor(() => {375 expect(mocks.sasDecisions.length).toBe(0);376 });377 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();378 expect(mocks.upsert).not.toHaveBeenCalled();379380 381 const id = sasIds[0];382 expect(typeof id).toBe("string");383 resolveSourceSasConfirmation(id, true);384 mocks.sasDecisions.push({ id, ok: true });385386 await waitForPush("ADOPTION_SOURCE_RESULT");387388 await vi.waitFor(() => expect(mocks.releaseAutoLock).toHaveBeenCalled());389390 expect(mocks.getRootKeyHex).toHaveBeenCalled();391 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({392 deviceId: "android-receiver",393 relayUrl: "wss://relay.peachpasswords.com",394 }));395 396 397 const sourceRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;398 expect(sourceRow.rendezvousSecretHex).toBeUndefined();399 expect(mocks.setPairSecret).toHaveBeenCalledWith(400 "android-receiver",401 expect.stringMatching(/^[0-9a-f]{64}$/),402 expect.any(Uint8Array),403 );404 expect(mocks.pushes).toContainEqual({405 type: "ADOPTION_SOURCE_RESULT",406 payload: { status: "complete" },407 });408 expect(mocks.triggerSync).toHaveBeenCalledWith({409 reason: "manual",410 force: true,411 });412 const sentTypes = socket.send.mock.calls413 .map(([bytes]) => {414 try {415 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;416 } catch {417 return undefined;418 }419 });420 expect(sentTypes).toContain("ADOPT_COMPLETE");421 });422423 it("fails adoption without sending snapshot frames when duress is oversized", async () => {424 const socket = socketWithFrames([425 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },426 {427 type: "ADOPT_REQUEST",428 device_id: "request-only-label",429 device_name: "June's phone",430 device_type: "android",431 snapshot_capable: true,432 },433 { type: "ADOPT_CONFIRM", confirmed: true },434 {435 type: "ADOPT_IDENTITY",436 device_id: "android-receiver",437 device_name: "June's phone",438 device_type: "android",439 long_term_public_key_hex: "d".repeat(64),440 },441 { type: "ADOPT_APPLIED" },442 ]);443 mocks.openSocket.mockResolvedValue(socket);444 const { exportDuressSlotForAdoption } = await import("./vault");445 vi.mocked(exportDuressSlotForAdoption).mockResolvedValueOnce(446 "x".repeat(768 * 1024),447 );448449 await startRelayAdoptionSource();450 await waitForPush("ADOPTION_SOURCE_RESULT");451452 expect(mocks.pushes).toContainEqual({453 type: "ADOPTION_SOURCE_RESULT",454 payload: {455 status: "error",456 error: "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE",457 },458 });459 expect(mocks.pushes).not.toContainEqual(expect.objectContaining({460 type: "ADOPTION_SOURCE_RESULT",461 payload: expect.objectContaining({ status: "complete" }),462 }));463 const sentTypes = socket.send.mock.calls.map(([bytes]) =>464 JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,465 );466 expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");467 expect(mocks.triggerSync).not.toHaveBeenCalled();468 });469470 it("sends no snapshot frames and completes via sync fallback when the vault exceeds the inline budget", async () => {471 472 473 474 const socket = socketWithFrames([475 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },476 {477 type: "ADOPT_REQUEST",478 device_id: "request-only-label",479 device_name: "June's phone",480 device_type: "android",481 snapshot_capable: true,482 },483 { type: "ADOPT_CONFIRM", confirmed: true },484 {485 type: "ADOPT_IDENTITY",486 device_id: "android-receiver",487 device_name: "June's phone",488 device_type: "android",489 long_term_public_key_hex: "d".repeat(64),490 },491 { type: "ADOPT_APPLIED" },492 493 494 495 { type: "ADOPT_SNAPSHOT_APPLIED", entries_received: 0, tombstones_received: 0 },496 ]);497 mocks.openSocket.mockResolvedValue(socket);498 499 const envelope = "c".repeat(690_000);500 const entries: Record<string, string> = {};501 for (let i = 0; i < 16; i++) entries[`big-${i}`] = envelope;502 mocks.createSyncAdapter.mockReturnValue({503 buildEntryManifest: vi.fn(async () => Object.fromEntries(504 Object.keys(entries).map((id) => [id, 1]),505 )),506 getEncryptedEntries: vi.fn(async () => entries),507 buildTombstoneManifest: vi.fn(async () => ({})),508 applyEntries: vi.fn(async () => undefined),509 applyTombstones: vi.fn(async () => undefined),510 buildOutgoingDeviceManifest: vi.fn(async () => null),511 applyIncomingDeviceManifest: vi.fn(async () => undefined),512 });513514 await startRelayAdoptionSource();515 await waitForPush("ADOPTION_SOURCE_RESULT");516517 expect(mocks.pushes).toContainEqual({518 type: "ADOPTION_SOURCE_RESULT",519 payload: expect.objectContaining({ status: "complete" }),520 });521 const sentTypes = socket.send.mock.calls.map(([bytes]) =>522 JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,523 );524 expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");525 526 expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });527 });528529 it("fails closed when the popup rejects the SAS", async () => {530 const socket = socketWithFrames([531 {532 type: "PEACH_HELLO",533 token: "AAECAwQFBgcICQoLDA0ODw",534 },535 {536 type: "ADOPT_REQUEST",537 device_id: "request-only-label",538 device_name: "New phone",539 device_type: "android",540 },541 { type: "ADOPT_CONFIRM", confirmed: false },542 ]);543 mocks.openSocket.mockResolvedValue(socket);544545 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");546 let pendingId: string | undefined;547 Object.defineProperty(globalThis, "chrome", {548 configurable: true,549 value: {550 runtime: {551 sendMessage(message: { type: string; payload: unknown }) {552 mocks.pushes.push(message);553 if (message.type === "ADOPTION_SOURCE_SAS") {554 const p = message.payload as { id?: string };555 if (typeof p?.id === "string") pendingId = p.id;556 }557 return Promise.resolve();558 },559 },560 },561 });562563 await startRelayAdoptionSource();564 await waitForPush("ADOPTION_SOURCE_SAS");565 expect(typeof pendingId).toBe("string");566 resolveSourceSasConfirmation(pendingId!, false);567 mocks.sasDecisions.push({ id: pendingId!, ok: false });568569 await waitForPush("ADOPTION_SOURCE_RESULT");570571 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();572 expect(mocks.upsert).not.toHaveBeenCalled();573 expect(mocks.pushes).toContainEqual({574 type: "ADOPTION_SOURCE_RESULT",575 payload: { status: "error", error: "adoption_rejected" },576 });577 });578579 it("rejects before reading the root key when neither peer has Pro", async () => {580 const socket = socketWithFrames([581 {582 type: "PEACH_HELLO",583 token: "AAECAwQFBgcICQoLDA0ODw",584 },585 {586 type: "ADOPT_REQUEST",587 device_id: "free-receiver",588 device_name: "Free phone",589 device_type: "android",590 },591 { type: "ADOPT_CONFIRM", confirmed: true },592 ]);593 mocks.isProUser.mockResolvedValue(false);594 mocks.getLicenseKey.mockResolvedValue(undefined);595 mocks.openSocket.mockResolvedValue(socket);596597 await startRelayAdoptionSource();598 await waitForPush("ADOPTION_SOURCE_RESULT");599600 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();601 expect(mocks.upsert).not.toHaveBeenCalled();602 expect(mocks.pushes).toContainEqual({603 type: "ADOPTION_SOURCE_RESULT",604 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },605 });606 const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {607 try {608 return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];609 } catch {610 return [];611 }612 });613 expect(sentFrames).toContainEqual({614 type: "ADOPT_REJECT",615 reason: "ADOPT_PRO_REQUIRED",616 });617 });618619 it("fails closed when the user never answers the SAS", async () => {620 621 622 623 624 const socket = socketWithFrames([]);625 mocks.openSocket.mockResolvedValue(socket);626627 Object.defineProperty(globalThis, "chrome", {628 value: {629 storage: chromeStorage,630 runtime: {631 sendMessage(message: { type: string; payload: unknown }) {632 mocks.pushes.push(message);633 return Promise.resolve();634 },635 },636 },637 });638639 await startRelayAdoptionSource();640 cancelRelayAdoptionSource();641642 expect(socket.close).toHaveBeenCalled();643 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();644 expect(mocks.upsert).not.toHaveBeenCalled();645 });646647 it("cancelling the source screen closes the room without reading the root", async () => {648 const socket = socketWithFrames([]);649 mocks.openSocket.mockResolvedValue(socket);650651 await startRelayAdoptionSource();652 cancelRelayAdoptionSource();653654 expect(socket.close).toHaveBeenCalled();655 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();656 expect(mocks.upsert).not.toHaveBeenCalled();657 });658});659660describe("relay adoption receiver background", () => {661 beforeEach(async () => {662 663 664 665 666 const { cancelRelayAdoptionSource } = await import("./relay-adoption-bg");667 cancelRelayAdoptionSource();668 mocks.pushes.length = 0;669 mocks.openSocket.mockReset();670 mocks.getDeviceName.mockReset();671 mocks.getDeviceName.mockResolvedValue("Receiver Browser");672 mocks.createVault.mockReset();673 mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });674 mocks.activateLicense.mockReset();675 mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });676 mocks.isProUser.mockReset();677 mocks.isProUser.mockResolvedValue(true);678 mocks.getLicenseKey.mockReset();679 mocks.getLicenseKey.mockResolvedValue(undefined);680 mocks.localIdentity.mockReset();681 mocks.localIdentity.mockResolvedValue({682 deviceId: "receiver-id",683 deviceName: "Receiver Browser",684 deviceType: "extension",685 longTermPublicKeyHex: "11".repeat(32),686 signingPublicKeyHex: "22".repeat(32),687 });688 mocks.rollbackVault.mockReset();689 mocks.rollbackVault.mockResolvedValue(undefined);690 mocks.storageSet.mockReset();691 mocks.storageSet.mockImplementation(async (key: string, value: string) => {692 mocks.storage.set(key, value);693 });694 mocks.upsert.mockReset();695 mocks.upsert.mockResolvedValue(undefined);696 mocks.setPairSecret.mockReset();697 mocks.setPairSecret.mockResolvedValue(undefined);698 mocks.triggerSync.mockReset();699 mocks.triggerSync.mockResolvedValue(undefined);700 mocks.openSocket.mockResolvedValue(socketWithFrames([]));701 Object.defineProperty(globalThis, "chrome", {702 value: {703 storage: chromeStorage,704 runtime: {705 async sendMessage(message: { type: string; payload?: unknown }) {706 mocks.pushes.push({ type: message.type, payload: message.payload });707 if (message.type === "ADOPTION_SOURCE_SAS") {708 const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };709 if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {710 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");711 resolveReceiverSasConfirmation(p.id, p.sas, true);712 }713 }714 return { ok: true };715 },716 onMessage: {717 addListener() { },718 removeListener() { },719 },720 },721 },722 });723 });724725 it("opens a relay socket and returns an empty invitation", async () => {726 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");727 const started = await startRelayAdoptionReceiver("receiver-password");728 expect(started.invitation).toMatch(/^peach-pair-v2:/);729 730 const decoded = decodeInvitation(started.invitation);731 expect(decoded?.vault).toBe("empty");732 expect(mocks.openSocket).toHaveBeenCalled();733 });734735 it("rejects an empty password", async () => {736 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");737 await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");738 });739740 it("surfaces the Pro-required rejection without creating a vault", async () => {741 mocks.openSocket.mockResolvedValue(socketWithFrames([742 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },743 { type: "ADOPT_CONFIRM_REQUIRED" },744 { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },745 ]));746747 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");748 await startRelayAdoptionReceiver("receiver-password");749 await waitForPush("ADOPTION_SOURCE_RESULT");750751 expect(mocks.createVault).not.toHaveBeenCalled();752 expect(mocks.rollbackVault).not.toHaveBeenCalled();753 expect(mocks.pushes).toContainEqual({754 type: "ADOPTION_SOURCE_RESULT",755 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },756 });757 });758759 it("the receiver wires requestAdoption, not grantAdoption", async () => {760 761 762 763 764 765 const rootKeyHex = "f0".repeat(32);766 const vaultId = "b".repeat(64);767 const socket = socketWithFrames([768 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },769 ...adoptionV2Frames({ rootKeyHex, vaultId }),770 ], { closeOnEmpty: true });771 mocks.openSocket.mockResolvedValue(socket);772 mocks.createVault.mockResolvedValue({ vaultId });773774 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");775 Object.defineProperty(globalThis, "chrome", {776 value: {777 storage: chromeStorage,778 runtime: {779 async sendMessage(message: { type: string; payload?: unknown }) {780 mocks.pushes.push({ type: message.type, payload: message.payload });781 if (message.type === "ADOPTION_SOURCE_SAS") {782 const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };783 if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {784 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");785 resolveReceiverSasConfirmation(p.id, p.sas, true);786 }787 }788 return { ok: true };789 },790 onMessage: { addListener: () => undefined, removeListener: () => undefined },791 },792 },793 });794 await startRelayAdoptionReceiver("receiver-password");795 await waitForPush("ADOPTION_SOURCE_RESULT");796797 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })798 .mock.calls799 .map(([bytes]) => {800 try {801 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;802 } catch {803 return undefined;804 }805 });806 807 808 expect(sentTypes).toContain("ADOPT_REQUEST_V2");809 expect(sentTypes).not.toContain("ADOPT_GRANT");810 });811812 it("completes the handshake end-to-end: vault created → paired", async () => {813 814 815 816 817 818 const rootKeyHex = "f0".repeat(32);819 820 821 const vaultId = "b".repeat(64);822 const frames = [823 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },824 ...adoptionV2Frames({ rootKeyHex, vaultId }),825 ];826 mocks.createVault.mockResolvedValue({ vaultId });827 mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));828829 830 Object.defineProperty(globalThis, "chrome", {831 value: {832 storage: chromeStorage,833 runtime: {834 async sendMessage(message: { type: string; payload?: unknown }) {835 mocks.pushes.push({ type: message.type, payload: message.payload });836 if (message.type === "ADOPTION_SOURCE_SAS") {837 const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };838 if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {839 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");840 resolveReceiverSasConfirmation(p.id, p.sas, true);841 }842 }843 return { ok: true };844 },845 onMessage: {846 addListener: () => undefined,847 removeListener: () => undefined,848 },849 },850 },851 });852853 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");854 await startRelayAdoptionReceiver("receiver-password");855856 await waitForPush("ADOPTION_SOURCE_RESULT");857858 859 expect(mocks.pushes).toContainEqual({860 type: "ADOPTION_SOURCE_SAS",861 payload: expect.objectContaining({862 sas: expect.stringMatching(/^\d{8}$/),863 displayOnly: false,864 }),865 });866867 868 869 expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);870871 872 873 expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");874 expect(mocks.isProUser).toHaveBeenCalled();875 expect(mocks.localIdentity).toHaveBeenCalled();876 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({877 deviceId: "phone-source-id",878 }));879 const receiverRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;880 expect(receiverRow.rendezvousSecretHex).toBeUndefined();881 expect(mocks.setPairSecret).toHaveBeenCalledWith(882 "phone-source-id",883 expect.stringMatching(/^[0-9a-f]{64}$/),884 expect.any(Uint8Array),885 );886 expect(mocks.storageSet).toHaveBeenCalledWith(887 "peach_initial_sync_pending_device_id",888 "phone-source-id",889 );890 expect(mocks.rollbackVault).not.toHaveBeenCalled();891892 893 expect(mocks.pushes).toContainEqual({894 type: "ADOPTION_SOURCE_RESULT",895 payload: { status: "complete" },896 });897 });898899 it("rolls back and refuses completion when a carried Pro license is not activated", async () => {900 const rootKeyHex = "f0".repeat(32);901 const vaultId = "b".repeat(64);902 mocks.createVault.mockResolvedValue({ vaultId });903 mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });904 mocks.isProUser.mockResolvedValue(false);905 mocks.openSocket.mockResolvedValue(socketWithFrames([906 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },907 ...adoptionV2Frames({ rootKeyHex, vaultId, license: "PPK-invalid" }),908 ]));909910 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");911 await startRelayAdoptionReceiver("receiver-password");912 await waitForPush("ADOPTION_SOURCE_RESULT");913914 expect(mocks.rollbackVault).toHaveBeenCalledOnce();915 expect(mocks.localIdentity).toHaveBeenCalled();916 expect(mocks.upsert).not.toHaveBeenCalled();917 expect(mocks.pushes).toContainEqual(expect.objectContaining({918 type: "ADOPTION_SOURCE_RESULT",919 payload: expect.objectContaining({920 status: "error",921 error: "license_transfer_failed",922 }),923 }));924 });925926 it("rolls back a newly created vault when a later adoption step fails", async () => {927 const rootKeyHex = "f0".repeat(32);928 const vaultId = "b".repeat(64);929 mocks.createVault.mockResolvedValue({ vaultId });930 mocks.upsert.mockRejectedValue(new Error("pair_persist_failed"));931 mocks.openSocket.mockResolvedValue(socketWithFrames([932 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },933 ...adoptionV2Frames({ rootKeyHex, vaultId }),934 ]));935936 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");937 await startRelayAdoptionReceiver("receiver-password");938 await waitForPush("ADOPTION_SOURCE_RESULT");939940 expect(mocks.rollbackVault).toHaveBeenCalledOnce();941 expect(mocks.upsert).toHaveBeenCalledOnce();942 expect(mocks.pushes).toContainEqual(expect.objectContaining({943 type: "ADOPTION_SOURCE_RESULT",944 payload: expect.objectContaining({ status: "error", error: "pair_persist_failed" }),945 }));946 });947948 it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {949 950 951 952 const rootKeyHex = "f0".repeat(32);953 const vaultId = "b".repeat(64);954 mocks.createVault.mockResolvedValue({ vaultId });955 mocks.openSocket.mockResolvedValue(socketWithFrames([956 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },957 ...adoptionV2Frames({958 rootKeyHex,959 vaultId,960 deviceId: "old-phone-id",961 deviceName: "Old phone",962 }),963 ], { closeOnEmpty: true }));964965 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");966 await startRelayAdoptionReceiver("receiver-password");967 await waitForPush("ADOPTION_SOURCE_RESULT");968969 970 expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });971 972 expect(mocks.pushes).toContainEqual(expect.objectContaining({973 type: "ADOPTION_SOURCE_RESULT",974 payload: expect.objectContaining({ status: "complete" }),975 }));976 977 expect(mocks.storageSet).toHaveBeenCalledWith(978 "peach_initial_sync_pending_device_id",979 "old-phone-id",980 );981 });982983 it("applies the source's device manifest after the snapshot, then acks", async () => {984 985 986 987 988 989 const rootKeyHex = "f0".repeat(32);990 const vaultId = "b".repeat(64);991 const longTermPubkeyHex = "99".repeat(32);992 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");993 const { deriveSyncSigningKeypair } = await import(994 "../shared/legacy-identity-derivation.test-support"995 );996 const { pubkeyToHex } = await import("../shared/sync-identity");997 const issuerKp = await deriveSyncSigningKeypair(998 new Uint8Array(32).fill(0x55),999 "phone-source-id",1000 );1001 const manifest = await buildSignedDeviceManifest(1002 "phone-source-id",1003 [1004 {1005 deviceId: "peer-b",1006 deviceName: "Pool B",1007 deviceType: "extension",1008 longTermPublicKeyHex: "11".repeat(32),1009 signingPublicKeyHex: "22".repeat(32),1010 wsPort: 49152,1011 relayUrl: "wss://relay.peachpasswords.com",1012 pairedAt: 1,1013 },1014 ],1015 issuerKp.privateKey,1016 );1017 const socket = socketWithFrames([1018 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1019 ...adoptionV2Frames({1020 rootKeyHex,1021 vaultId,1022 longTermPublicKeyHex: longTermPubkeyHex,1023 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),1024 snapshotCapable: true,1025 }),1026 1027 1028 { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1029 { type: "ADOPT_DEVICE_MANIFEST", manifest },1030 ], { closeOnEmpty: true });1031 mocks.createVault.mockResolvedValue({ vaultId });1032 mocks.openSocket.mockResolvedValue(socket);1033 mocks.__applyIncomingDeviceManifest.mockClear();10341035 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1036 await startRelayAdoptionReceiver("receiver-password");1037 await waitForPush("ADOPTION_SOURCE_RESULT");10381039 expect(mocks.__applyIncomingDeviceManifest).toHaveBeenCalledTimes(1);1040 const [forwarded, pubkey] = mocks.__applyIncomingDeviceManifest.mock.calls[0];1041 expect(forwarded).toEqual(manifest);1042 expect(pubkey).toBeInstanceOf(Uint8Array);1043 expect(pubkey.length).toBe(32);1044 1045 1046 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1047 .mock.calls1048 .map(([bytes]) => {1049 try {1050 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1051 } catch {1052 return undefined;1053 }1054 });1055 expect(sentTypes).toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1056 1057 expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST");1058 });10591060 it("does not call applyIncomingDeviceManifest when the source omits the manifest frame", async () => {1061 1062 1063 1064 1065 const rootKeyHex = "f0".repeat(32);1066 const vaultId = "b".repeat(64);1067 const longTermPubkeyHex = "99".repeat(32);1068 const socket = socketWithFrames([1069 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1070 ...adoptionV2Frames({1071 rootKeyHex,1072 vaultId,1073 longTermPublicKeyHex: longTermPubkeyHex,1074 snapshotCapable: true,1075 }),1076 1077 1078 { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1079 ], { closeOnEmpty: true });1080 mocks.createVault.mockResolvedValue({ vaultId });1081 mocks.openSocket.mockResolvedValue(socket);1082 mocks.__applyIncomingDeviceManifest.mockClear();10831084 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1085 await startRelayAdoptionReceiver("receiver-password");1086 await waitForPush("ADOPTION_SOURCE_RESULT");10871088 expect(mocks.__applyIncomingDeviceManifest).not.toHaveBeenCalled();1089 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1090 .mock.calls1091 .map(([bytes]) => {1092 try {1093 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1094 } catch {1095 return undefined;1096 }1097 });1098 expect(sentTypes).toContain("ADOPT_SNAPSHOT_APPLIED");1099 expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1100 });1101});11021103function decodeInvitation(encoded: string): { vault?: string } | null {1104 1105 1106 const at = encoded.indexOf(":");1107 if (at < 0) return null;1108 const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");1109 try {1110 const json = Buffer.from(b64, "base64").toString("utf8");1111 return JSON.parse(json) as { vault?: string };1112 } catch {1113 return null;1114 }1115}