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 rotateDeviceId: mocks.rotateDeviceId,70}));7172vi.mock("./sync-settings", async (importOriginal) => ({73 ...(await importOriginal<typeof import("./sync-settings")>()),74 getSyncSettings: async () => ({75 mode: "public" as const,76 relayUrl: "wss://relay.peachpasswords.com",77 }),78}));7980vi.mock("./license", () => ({81 isProUser: mocks.isProUser,82 getLicenseKey: mocks.getLicenseKey,83 activateLicense: mocks.activateLicense,84}));8586vi.mock("./vault", () => ({87 isUnlocked: vi.fn(() => true),88 getRootKeyHex: mocks.getRootKeyHex,89 createVaultFromRootKey: mocks.createVault,90 rollbackAdoptedVault: mocks.rollbackVault,91 exportDuressSlotForAdoption: vi.fn(async () => null),92 installAdoptedDuressSlot: vi.fn(async () => undefined),93}));9495vi.mock("../shared/sync-identity", async (importOriginal) => {96 97 98 99 100 const actual = await importOriginal<typeof import("../shared/sync-identity")>();101 return {102 ...actual,103 computeVaultId: vi.fn(async () => "b".repeat(64)),104 };105});106107vi.mock("../shared/storage", () => ({108 storageSet: mocks.storageSet,109 storageGet: mocks.storageGet,110 storageRemove: mocks.storageRemove,111 storageClear: vi.fn(async () => mocks.storage.clear()),112}));113const chromeStorage = {114 local: {115 get: async (key: string) => ({ [key]: await mocks.storageGet(key) }),116 set: async (items: Record<string, string>) => {117 for (const [key, value] of Object.entries(items)) await mocks.storageSet(key, value);118 },119 remove: async (key: string) => mocks.storageRemove(key),120 clear: async () => mocks.storage.clear(),121 },122};123vi.mock("./paired-devices", () => ({124 upsertPairedDevice: mocks.upsert,125 upsertPairedDeviceAndClearRevocation: mocks.upsert,126 clearDeviceRevocation: mocks.clearRevocation,127 hasAdoptionIdentityConflict: mocks.hasIdentityConflict,128}));129130vi.mock("./pair-secrets", () => ({131 setPairSecret: mocks.setPairSecret,132 isValidPairSecret: (value: unknown): value is string =>133 typeof value === "string" && /^[0-9a-f]{64}$/.test(value),134}));135136vi.mock("./sync-trigger", () => ({137 triggerSyncIfReachable: mocks.triggerSync,138}));139140vi.mock("./alarms", () => ({141 deferAutoLockForOperation: mocks.deferAutoLock,142}));143144vi.mock("./sync-vault-adapter", () => ({145 createSyncVaultAdapter: mocks.createSyncAdapter,146}));147148import {149 cancelRelayAdoptionSource,150 startRelayAdoptionSource,151} from "./relay-adoption-bg";152153const encode = (value: unknown) =>154 new TextEncoder().encode(JSON.stringify(value));155156function socketWithFrames(157 frames: unknown[],158 options: { closeOnEmpty?: boolean } = {},159) {160 const queue = frames.map((frame) => {161 const encoded = encode(frame);162 163 return (frame as { type?: string }).type === "ADOPT_SNAPSHOT"164 ? padSyncFrame(encoded)165 : encoded;166 });167 return {168 send: vi.fn(),169 receive: vi.fn(async () => {170 const next = queue.shift();171 if (!next) {172 173 174 175 if (options.closeOnEmpty) throw new Error("socket_closed");176 return new Promise<Uint8Array>(() => undefined);177 }178 return next;179 }),180 close: vi.fn(),181 closedBeforeFrame: vi.fn(() => false),182 };183}184185async function waitForPush(type: string): Promise<void> {186 await vi.waitFor(() => {187 expect(mocks.pushes.some((message) => message.type === type)).toBe(true);188 });189}190191beforeEach(() => {192 mocks.pushes.length = 0;193 mocks.sasDecisions.length = 0;194 mocks.openSocket.mockReset();195 mocks.getLocalIdentity.mockReset();196 mocks.getRootKeyHex.mockReset();197 mocks.upsert.mockReset();198 mocks.clearRevocation.mockReset();199 mocks.clearRevocation.mockResolvedValue(undefined);200 mocks.hasIdentityConflict.mockReset();201 mocks.hasIdentityConflict.mockResolvedValue(false);202 mocks.rotateDeviceId.mockReset();203 mocks.rotateDeviceId.mockResolvedValue(undefined);204 mocks.setPairSecret.mockReset();205 mocks.triggerSync.mockReset();206 mocks.triggerSync.mockResolvedValue(undefined);207 mocks.releaseAutoLock.mockReset();208 mocks.deferAutoLock.mockReset();209 mocks.deferAutoLock.mockReturnValue(mocks.releaseAutoLock);210 mocks.storage.clear();211 mocks.storageSet.mockReset();212 mocks.storageSet.mockImplementation(async (key: string, value: string) => {213 mocks.storage.set(key, value);214 });215 mocks.storageGet.mockReset();216 mocks.storageGet.mockImplementation(async (key: string) => mocks.storage.get(key) ?? null);217 mocks.storageRemove.mockReset();218 mocks.storageRemove.mockImplementation(async (key: string) => {219 mocks.storage.delete(key);220 });221 mocks.isProUser.mockReset();222 mocks.isProUser.mockResolvedValue(true);223 mocks.getLicenseKey.mockReset();224 mocks.getLicenseKey.mockResolvedValue("PPK-source");225 mocks.getLocalIdentity.mockResolvedValue({226 identity: {227 deviceId: "browser-source",228 deviceName: "June's Browser",229 deviceType: "extension",230 longTermPublicKeyHex: "a".repeat(64),231 vaultId: "b".repeat(64),232 },233 privateKey: new Uint8Array(32).fill(1),234 publicKey: new Uint8Array(32).fill(2),235 });236 mocks.getRootKeyHex.mockReturnValue("c".repeat(64));237 mocks.upsert.mockResolvedValue(undefined);238 mocks.setPairSecret.mockResolvedValue(undefined);239 const applyIncomingDeviceManifest = vi.fn(async () => undefined);240 mocks.__applyIncomingDeviceManifest = applyIncomingDeviceManifest;241 mocks.createSyncAdapter.mockReturnValue({242 buildEntryManifest: vi.fn(async () => ({})),243 getEncryptedEntries: vi.fn(async () => ({})),244 buildTombstoneManifest: vi.fn(async () => ({})),245 applyEntries: vi.fn(async () => undefined),246 applyTombstones: vi.fn(async () => undefined),247 buildOutgoingDeviceManifest: vi.fn(async () => null),248 applyIncomingDeviceManifest,249 });250 Object.defineProperty(globalThis, "chrome", {251 configurable: true,252 value: {253 runtime: {254 async sendMessage(message: { type: string; payload: unknown }) {255 mocks.pushes.push(message);256 257 258 259 260 if (message.type === "ADOPTION_SOURCE_SAS") {261 const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };262 if (typeof p?.id === "string" && typeof p.sas === "string") {263 if (p.displayOnly === false) {264 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");265 resolveReceiverSasConfirmation(p.id, p.sas, true);266 } else {267 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");268 mocks.sasDecisions.push({ id: p.id, ok: true });269 resolveSourceSasConfirmation(p.id, true);270 }271 }272 }273 return Promise.resolve();274 },275 },276 },277 });278});279280describe("relay adoption source background boundary", () => {281 it("shows the SAS and waits for the popup's single confirmation", async () => {282 const socket = socketWithFrames([283 {284 type: "PEACH_HELLO",285 token: "AAECAwQFBgcICQoLDA0ODw",286 },287 {288 type: "ADOPT_REQUEST",289 device_id: "request-only-label",290 device_name: "June's phone",291 device_type: "android",292 },293 { type: "ADOPT_CONFIRM", confirmed: true },294 {295 type: "ADOPT_IDENTITY",296 device_id: "android-receiver",297 device_name: "June's phone",298 device_type: "android",299 long_term_public_key_hex: "d".repeat(64),300 },301 { type: "ADOPT_APPLIED" },302 ]);303 mocks.openSocket.mockResolvedValue(socket);304305 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");306 const sasIds: string[] = [];307 Object.defineProperty(globalThis, "chrome", {308 configurable: true,309 value: {310 storage: chromeStorage,311 runtime: {312 async sendMessage(message: { type: string; payload: unknown }) {313 mocks.pushes.push(message);314 if (message.type === "ADOPTION_SOURCE_SAS") {315 const p = message.payload as { id?: string };316 if (typeof p?.id === "string") {317 318 319 sasIds.push(p.id);320 }321 return Promise.resolve();322 }323 return Promise.resolve();324 },325 },326 },327 });328 void resolveSourceSasConfirmation; 329330 const started = await startRelayAdoptionSource();331 expect(started.invitation).toMatch(/^peach-pair-v2:/);332 expect(mocks.deferAutoLock).toHaveBeenCalledWith(5 * 60 * 1000);333 await waitForPush("ADOPTION_SOURCE_SAS");334 335 336 await vi.waitFor(() => {337 expect(mocks.sasDecisions.length).toBe(0);338 });339 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();340 expect(mocks.upsert).not.toHaveBeenCalled();341342 343 const id = sasIds[0];344 expect(typeof id).toBe("string");345 resolveSourceSasConfirmation(id, true);346 mocks.sasDecisions.push({ id, ok: true });347348 await waitForPush("ADOPTION_SOURCE_RESULT");349350 await vi.waitFor(() => expect(mocks.releaseAutoLock).toHaveBeenCalled());351352 expect(mocks.getRootKeyHex).toHaveBeenCalled();353 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({354 deviceId: "android-receiver",355 relayUrl: "wss://relay.peachpasswords.com",356 }));357 358 359 const sourceRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;360 expect(sourceRow.rendezvousSecretHex).toBeUndefined();361 expect(mocks.setPairSecret).toHaveBeenCalledWith(362 "android-receiver",363 expect.stringMatching(/^[0-9a-f]{64}$/),364 expect.any(Uint8Array),365 );366 expect(mocks.pushes).toContainEqual({367 type: "ADOPTION_SOURCE_RESULT",368 payload: { status: "complete" },369 });370 expect(mocks.triggerSync).toHaveBeenCalledWith({371 reason: "manual",372 force: true,373 });374 const sentTypes = socket.send.mock.calls375 .map(([bytes]) => {376 try {377 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;378 } catch {379 return undefined;380 }381 });382 expect(sentTypes).toContain("ADOPT_COMPLETE");383 });384385 it("fails adoption without sending snapshot frames when duress is oversized", async () => {386 const socket = socketWithFrames([387 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },388 {389 type: "ADOPT_REQUEST",390 device_id: "request-only-label",391 device_name: "June's phone",392 device_type: "android",393 snapshot_capable: true,394 },395 { type: "ADOPT_CONFIRM", confirmed: true },396 {397 type: "ADOPT_IDENTITY",398 device_id: "android-receiver",399 device_name: "June's phone",400 device_type: "android",401 long_term_public_key_hex: "d".repeat(64),402 },403 { type: "ADOPT_APPLIED" },404 ]);405 mocks.openSocket.mockResolvedValue(socket);406 const { exportDuressSlotForAdoption } = await import("./vault");407 vi.mocked(exportDuressSlotForAdoption).mockResolvedValueOnce(408 "x".repeat(768 * 1024),409 );410411 await startRelayAdoptionSource();412 await waitForPush("ADOPTION_SOURCE_RESULT");413414 expect(mocks.pushes).toContainEqual({415 type: "ADOPTION_SOURCE_RESULT",416 payload: {417 status: "error",418 error: "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE",419 },420 });421 expect(mocks.pushes).not.toContainEqual(expect.objectContaining({422 type: "ADOPTION_SOURCE_RESULT",423 payload: expect.objectContaining({ status: "complete" }),424 }));425 const sentTypes = socket.send.mock.calls.map(([bytes]) =>426 JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,427 );428 expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");429 expect(mocks.triggerSync).not.toHaveBeenCalled();430 });431432 it("sends no snapshot frames and completes via sync fallback when the vault exceeds the inline budget", async () => {433 434 435 436 const socket = socketWithFrames([437 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },438 {439 type: "ADOPT_REQUEST",440 device_id: "request-only-label",441 device_name: "June's phone",442 device_type: "android",443 snapshot_capable: true,444 },445 { type: "ADOPT_CONFIRM", confirmed: true },446 {447 type: "ADOPT_IDENTITY",448 device_id: "android-receiver",449 device_name: "June's phone",450 device_type: "android",451 long_term_public_key_hex: "d".repeat(64),452 },453 { type: "ADOPT_APPLIED" },454 455 456 457 { type: "ADOPT_SNAPSHOT_APPLIED", entries_received: 0, tombstones_received: 0 },458 ]);459 mocks.openSocket.mockResolvedValue(socket);460 461 const envelope = "c".repeat(690_000);462 const entries: Record<string, string> = {};463 for (let i = 0; i < 16; i++) entries[`big-${i}`] = envelope;464 mocks.createSyncAdapter.mockReturnValue({465 buildEntryManifest: vi.fn(async () => Object.fromEntries(466 Object.keys(entries).map((id) => [id, 1]),467 )),468 getEncryptedEntries: vi.fn(async () => entries),469 buildTombstoneManifest: vi.fn(async () => ({})),470 applyEntries: vi.fn(async () => undefined),471 applyTombstones: vi.fn(async () => undefined),472 buildOutgoingDeviceManifest: vi.fn(async () => null),473 applyIncomingDeviceManifest: vi.fn(async () => undefined),474 });475476 await startRelayAdoptionSource();477 await waitForPush("ADOPTION_SOURCE_RESULT");478479 expect(mocks.pushes).toContainEqual({480 type: "ADOPTION_SOURCE_RESULT",481 payload: expect.objectContaining({ status: "complete" }),482 });483 const sentTypes = socket.send.mock.calls.map(([bytes]) =>484 JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,485 );486 expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");487 488 expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });489 });490491 it("fails closed when the popup rejects the SAS", async () => {492 const socket = socketWithFrames([493 {494 type: "PEACH_HELLO",495 token: "AAECAwQFBgcICQoLDA0ODw",496 },497 {498 type: "ADOPT_REQUEST",499 device_id: "request-only-label",500 device_name: "New phone",501 device_type: "android",502 },503 { type: "ADOPT_CONFIRM", confirmed: false },504 ]);505 mocks.openSocket.mockResolvedValue(socket);506507 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");508 let pendingId: string | undefined;509 Object.defineProperty(globalThis, "chrome", {510 configurable: true,511 value: {512 runtime: {513 sendMessage(message: { type: string; payload: unknown }) {514 mocks.pushes.push(message);515 if (message.type === "ADOPTION_SOURCE_SAS") {516 const p = message.payload as { id?: string };517 if (typeof p?.id === "string") pendingId = p.id;518 }519 return Promise.resolve();520 },521 },522 },523 });524525 await startRelayAdoptionSource();526 await waitForPush("ADOPTION_SOURCE_SAS");527 expect(typeof pendingId).toBe("string");528 resolveSourceSasConfirmation(pendingId!, false);529 mocks.sasDecisions.push({ id: pendingId!, ok: false });530531 await waitForPush("ADOPTION_SOURCE_RESULT");532533 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();534 expect(mocks.upsert).not.toHaveBeenCalled();535 expect(mocks.pushes).toContainEqual({536 type: "ADOPTION_SOURCE_RESULT",537 payload: { status: "error", error: "adoption_rejected" },538 });539 });540541 it("rejects before reading the root key when neither peer has Pro", async () => {542 const socket = socketWithFrames([543 {544 type: "PEACH_HELLO",545 token: "AAECAwQFBgcICQoLDA0ODw",546 },547 {548 type: "ADOPT_REQUEST",549 device_id: "free-receiver",550 device_name: "Free phone",551 device_type: "android",552 },553 { type: "ADOPT_CONFIRM", confirmed: true },554 ]);555 mocks.isProUser.mockResolvedValue(false);556 mocks.getLicenseKey.mockResolvedValue(undefined);557 mocks.openSocket.mockResolvedValue(socket);558559 await startRelayAdoptionSource();560 await waitForPush("ADOPTION_SOURCE_RESULT");561562 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();563 expect(mocks.upsert).not.toHaveBeenCalled();564 expect(mocks.pushes).toContainEqual({565 type: "ADOPTION_SOURCE_RESULT",566 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },567 });568 const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {569 try {570 return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];571 } catch {572 return [];573 }574 });575 expect(sentFrames).toContainEqual({576 type: "ADOPT_REJECT",577 reason: "ADOPT_PRO_REQUIRED",578 });579 });580581 it("fails closed when the user never answers the SAS", async () => {582 583 584 585 586 const socket = socketWithFrames([]);587 mocks.openSocket.mockResolvedValue(socket);588589 Object.defineProperty(globalThis, "chrome", {590 value: {591 storage: chromeStorage,592 runtime: {593 sendMessage(message: { type: string; payload: unknown }) {594 mocks.pushes.push(message);595 return Promise.resolve();596 },597 },598 },599 });600601 await startRelayAdoptionSource();602 cancelRelayAdoptionSource();603604 expect(socket.close).toHaveBeenCalled();605 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();606 expect(mocks.upsert).not.toHaveBeenCalled();607 });608609 it("cancelling the source screen closes the room without reading the root", async () => {610 const socket = socketWithFrames([]);611 mocks.openSocket.mockResolvedValue(socket);612613 await startRelayAdoptionSource();614 cancelRelayAdoptionSource();615616 expect(socket.close).toHaveBeenCalled();617 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();618 expect(mocks.upsert).not.toHaveBeenCalled();619 });620});621622describe("relay adoption receiver background", () => {623 beforeEach(async () => {624 625 626 627 628 const { cancelRelayAdoptionSource } = await import("./relay-adoption-bg");629 cancelRelayAdoptionSource();630 mocks.pushes.length = 0;631 mocks.openSocket.mockReset();632 mocks.getDeviceName.mockReset();633 mocks.getDeviceName.mockResolvedValue("Receiver Browser");634 mocks.createVault.mockReset();635 mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });636 mocks.activateLicense.mockReset();637 mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });638 mocks.isProUser.mockReset();639 mocks.isProUser.mockResolvedValue(true);640 mocks.getLicenseKey.mockReset();641 mocks.getLicenseKey.mockResolvedValue(undefined);642 mocks.localIdentity.mockReset();643 mocks.localIdentity.mockResolvedValue({644 deviceId: "receiver-id",645 deviceName: "Receiver Browser",646 deviceType: "extension",647 longTermPublicKeyHex: "11".repeat(32),648 signingPublicKeyHex: "22".repeat(32),649 });650 mocks.rollbackVault.mockReset();651 mocks.rollbackVault.mockResolvedValue(undefined);652 mocks.storageSet.mockReset();653 mocks.storageSet.mockImplementation(async (key: string, value: string) => {654 mocks.storage.set(key, value);655 });656 mocks.upsert.mockReset();657 mocks.upsert.mockResolvedValue(undefined);658 mocks.setPairSecret.mockReset();659 mocks.setPairSecret.mockResolvedValue(undefined);660 mocks.triggerSync.mockReset();661 mocks.triggerSync.mockResolvedValue(undefined);662 mocks.openSocket.mockResolvedValue(socketWithFrames([]));663 Object.defineProperty(globalThis, "chrome", {664 value: {665 storage: chromeStorage,666 runtime: {667 async sendMessage(message: { type: string; payload?: unknown }) {668 mocks.pushes.push({ type: message.type, payload: message.payload });669 if (message.type === "ADOPTION_SOURCE_SAS") {670 const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };671 if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {672 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");673 resolveReceiverSasConfirmation(p.id, p.sas, true);674 }675 }676 return { ok: true };677 },678 onMessage: {679 addListener() { },680 removeListener() { },681 },682 },683 },684 });685 });686687 it("opens a relay socket and returns an empty invitation", async () => {688 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");689 const started = await startRelayAdoptionReceiver("receiver-password");690 expect(started.invitation).toMatch(/^peach-pair-v2:/);691 692 const decoded = decodeInvitation(started.invitation);693 expect(decoded?.vault).toBe("empty");694 expect(mocks.openSocket).toHaveBeenCalled();695 });696697 it("rejects an empty password", async () => {698 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");699 await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");700 });701702 it("surfaces the Pro-required rejection without creating a vault", async () => {703 mocks.openSocket.mockResolvedValue(socketWithFrames([704 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },705 { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },706 ]));707708 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");709 await startRelayAdoptionReceiver("receiver-password");710 await waitForPush("ADOPTION_SOURCE_RESULT");711712 expect(mocks.createVault).not.toHaveBeenCalled();713 expect(mocks.rollbackVault).not.toHaveBeenCalled();714 expect(mocks.pushes).toContainEqual({715 type: "ADOPTION_SOURCE_RESULT",716 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },717 });718 });719720 it("the receiver wires requestAdoption, not grantAdoption", async () => {721 722 723 724 725 726 const rootKeyHex = "f0".repeat(32);727 const vaultId = "b".repeat(64);728 const socket = socketWithFrames([729 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },730 {731 type: "ADOPT_GRANT",732 root_key_hex: rootKeyHex,733 vault_id: vaultId,734 license: "PPK-test",735 source: {736 device_id: "phone-source-id",737 device_name: "June's phone",738 device_type: "android",739 long_term_public_key_hex: "99".repeat(32),740 },741 },742 { type: "ADOPT_COMPLETE" },743 ], { closeOnEmpty: true });744 mocks.openSocket.mockResolvedValue(socket);745 mocks.createVault.mockResolvedValue({ vaultId });746747 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");748 Object.defineProperty(globalThis, "chrome", {749 value: {750 storage: chromeStorage,751 runtime: {752 async sendMessage(message: { type: string; payload?: unknown }) {753 mocks.pushes.push({ type: message.type, payload: message.payload });754 if (message.type === "ADOPTION_SOURCE_SAS") {755 const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };756 if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {757 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");758 resolveReceiverSasConfirmation(p.id, p.sas, true);759 }760 }761 return { ok: true };762 },763 onMessage: { addListener: () => undefined, removeListener: () => undefined },764 },765 },766 });767 await startRelayAdoptionReceiver("receiver-password");768 await waitForPush("ADOPTION_SOURCE_RESULT");769770 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })771 .mock.calls772 .map(([bytes]) => {773 try {774 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;775 } catch {776 return undefined;777 }778 });779 780 781 expect(sentTypes).toContain("ADOPT_REQUEST");782 expect(sentTypes).not.toContain("ADOPT_GRANT");783 });784785 it("completes the handshake end-to-end: vault created → paired", async () => {786 787 788 789 790 791 const rootKeyHex = "f0".repeat(32);792 793 794 const vaultId = "b".repeat(64);795 const frames = [796 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },797 798 799 {800 type: "ADOPT_GRANT",801 root_key_hex: rootKeyHex,802 vault_id: vaultId,803 license: "PPK-test",804 source: {805 device_id: "phone-source-id",806 device_name: "June's phone",807 device_type: "android",808 long_term_public_key_hex: "99".repeat(32),809 signing_public_key_hex: "aa".repeat(32),810 relay_url: "wss://relay.peachpasswords.com",811 },812 },813 { type: "ADOPT_COMPLETE" },814 ];815 mocks.createVault.mockResolvedValue({ vaultId });816 mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));817818 819 Object.defineProperty(globalThis, "chrome", {820 value: {821 storage: chromeStorage,822 runtime: {823 async sendMessage(message: { type: string; payload?: unknown }) {824 mocks.pushes.push({ type: message.type, payload: message.payload });825 if (message.type === "ADOPTION_SOURCE_SAS") {826 const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };827 if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {828 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");829 resolveReceiverSasConfirmation(p.id, p.sas, true);830 }831 }832 return { ok: true };833 },834 onMessage: {835 addListener: () => undefined,836 removeListener: () => undefined,837 },838 },839 },840 });841842 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");843 await startRelayAdoptionReceiver("receiver-password");844845 await waitForPush("ADOPTION_SOURCE_RESULT");846847 848 expect(mocks.pushes).toContainEqual({849 type: "ADOPTION_SOURCE_SAS",850 payload: expect.objectContaining({851 sas: expect.stringMatching(/^\d{8}$/),852 displayOnly: false,853 }),854 });855856 857 858 expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);859860 861 862 expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");863 expect(mocks.isProUser).toHaveBeenCalled();864 expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));865 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({866 deviceId: "phone-source-id",867 }));868 const receiverRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;869 expect(receiverRow.rendezvousSecretHex).toBeUndefined();870 expect(mocks.setPairSecret).toHaveBeenCalledWith(871 "phone-source-id",872 expect.stringMatching(/^[0-9a-f]{64}$/),873 expect.any(Uint8Array),874 );875 expect(mocks.storageSet).toHaveBeenCalledWith(876 "peach_initial_sync_pending_device_id",877 "phone-source-id",878 );879 expect(mocks.rollbackVault).not.toHaveBeenCalled();880881 882 expect(mocks.pushes).toContainEqual({883 type: "ADOPTION_SOURCE_RESULT",884 payload: { status: "complete" },885 });886 });887888 it("rolls back and refuses completion when a carried Pro license is not activated", async () => {889 const rootKeyHex = "f0".repeat(32);890 const vaultId = "b".repeat(64);891 mocks.createVault.mockResolvedValue({ vaultId });892 mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });893 mocks.isProUser.mockResolvedValue(false);894 mocks.openSocket.mockResolvedValue(socketWithFrames([895 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },896 {897 type: "ADOPT_GRANT",898 root_key_hex: rootKeyHex,899 vault_id: vaultId,900 license: "PPK-invalid",901 source: {902 device_id: "phone-source-id",903 device_name: "June's phone",904 device_type: "android",905 long_term_public_key_hex: "99".repeat(32),906 },907 },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).not.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.localIdentity.mockRejectedValue(new Error("identity_failed"));931 mocks.openSocket.mockResolvedValue(socketWithFrames([932 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },933 {934 type: "ADOPT_GRANT",935 root_key_hex: rootKeyHex,936 vault_id: vaultId,937 license: "PPK-test",938 source: {939 device_id: "phone-source-id",940 device_name: "June's phone",941 device_type: "android",942 long_term_public_key_hex: "99".repeat(32),943 },944 },945 ]));946947 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");948 await startRelayAdoptionReceiver("receiver-password");949 await waitForPush("ADOPTION_SOURCE_RESULT");950951 expect(mocks.rollbackVault).toHaveBeenCalledOnce();952 expect(mocks.upsert).not.toHaveBeenCalled();953 expect(mocks.pushes).toContainEqual(expect.objectContaining({954 type: "ADOPTION_SOURCE_RESULT",955 payload: expect.objectContaining({ status: "error", error: "identity_failed" }),956 }));957 });958959 it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {960 961 962 963 const rootKeyHex = "f0".repeat(32);964 const vaultId = "b".repeat(64);965 mocks.createVault.mockResolvedValue({ vaultId });966 mocks.openSocket.mockResolvedValue(socketWithFrames([967 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },968 {969 type: "ADOPT_GRANT",970 root_key_hex: rootKeyHex,971 vault_id: vaultId,972 license: "PPK-test",973 source: {974 device_id: "old-phone-id",975 device_name: "Old phone",976 device_type: "android",977 long_term_public_key_hex: "99".repeat(32),978 },979 980 },981 { type: "ADOPT_COMPLETE" },982 ], { closeOnEmpty: true }));983984 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");985 await startRelayAdoptionReceiver("receiver-password");986 await waitForPush("ADOPTION_SOURCE_RESULT");987988 989 expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });990 991 expect(mocks.pushes).toContainEqual(expect.objectContaining({992 type: "ADOPTION_SOURCE_RESULT",993 payload: expect.objectContaining({ status: "complete" }),994 }));995 996 expect(mocks.storageSet).toHaveBeenCalledWith(997 "peach_initial_sync_pending_device_id",998 "old-phone-id",999 );1000 });10011002 it("applies the source's device manifest after the snapshot, then acks", async () => {1003 1004 1005 1006 1007 1008 const rootKeyHex = "f0".repeat(32);1009 const vaultId = "b".repeat(64);1010 const longTermPubkeyHex = "99".repeat(32);1011 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");1012 const { deriveSyncSigningKeypair, pubkeyToHex } = await import(1013 "../shared/sync-identity"1014 );1015 const issuerKp = await deriveSyncSigningKeypair(1016 new Uint8Array(32).fill(0x55),1017 "phone-source-id",1018 );1019 const manifest = await buildSignedDeviceManifest(1020 "phone-source-id",1021 [1022 {1023 deviceId: "peer-b",1024 deviceName: "Pool B",1025 deviceType: "extension",1026 longTermPublicKeyHex: "11".repeat(32),1027 signingPublicKeyHex: "22".repeat(32),1028 wsPort: 49152,1029 relayUrl: "wss://relay.peachpasswords.com",1030 pairedAt: 1,1031 },1032 ],1033 issuerKp.privateKey,1034 );1035 const socket = socketWithFrames([1036 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1037 {1038 type: "ADOPT_GRANT",1039 root_key_hex: rootKeyHex,1040 vault_id: vaultId,1041 license: "PPK-test",1042 snapshot_capable: true,1043 source: {1044 device_id: "phone-source-id",1045 device_name: "June's phone",1046 device_type: "android",1047 long_term_public_key_hex: longTermPubkeyHex,1048 signing_public_key_hex: pubkeyToHex(issuerKp.publicKeyRaw),1049 relay_url: "wss://relay.peachpasswords.com",1050 },1051 },1052 1053 1054 { type: "ADOPT_COMPLETE" },1055 1056 1057 { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1058 { type: "ADOPT_DEVICE_MANIFEST", manifest },1059 ], { closeOnEmpty: true });1060 mocks.createVault.mockResolvedValue({ vaultId });1061 mocks.openSocket.mockResolvedValue(socket);1062 mocks.__applyIncomingDeviceManifest.mockClear();10631064 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1065 await startRelayAdoptionReceiver("receiver-password");1066 await waitForPush("ADOPTION_SOURCE_RESULT");10671068 expect(mocks.__applyIncomingDeviceManifest).toHaveBeenCalledTimes(1);1069 const [forwarded, pubkey] = mocks.__applyIncomingDeviceManifest.mock.calls[0];1070 expect(forwarded).toEqual(manifest);1071 expect(pubkey).toBeInstanceOf(Uint8Array);1072 expect(pubkey.length).toBe(32);1073 1074 1075 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1076 .mock.calls1077 .map(([bytes]) => {1078 try {1079 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1080 } catch {1081 return undefined;1082 }1083 });1084 expect(sentTypes).toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1085 1086 expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST");1087 });10881089 it("does not call applyIncomingDeviceManifest when the source omits the manifest frame", async () => {1090 1091 1092 1093 1094 const rootKeyHex = "f0".repeat(32);1095 const vaultId = "b".repeat(64);1096 const longTermPubkeyHex = "99".repeat(32);1097 const socket = socketWithFrames([1098 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1099 {1100 type: "ADOPT_GRANT",1101 root_key_hex: rootKeyHex,1102 vault_id: vaultId,1103 license: "PPK-test",1104 snapshot_capable: true,1105 source: {1106 device_id: "phone-source-id",1107 device_name: "June's phone",1108 device_type: "android",1109 long_term_public_key_hex: longTermPubkeyHex,1110 signing_public_key_hex: "aa".repeat(32),1111 relay_url: "wss://relay.peachpasswords.com",1112 },1113 },1114 { type: "ADOPT_COMPLETE" },1115 1116 1117 { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1118 ], { closeOnEmpty: true });1119 mocks.createVault.mockResolvedValue({ vaultId });1120 mocks.openSocket.mockResolvedValue(socket);1121 mocks.__applyIncomingDeviceManifest.mockClear();11221123 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1124 await startRelayAdoptionReceiver("receiver-password");1125 await waitForPush("ADOPTION_SOURCE_RESULT");11261127 expect(mocks.__applyIncomingDeviceManifest).not.toHaveBeenCalled();1128 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1129 .mock.calls1130 .map(([bytes]) => {1131 try {1132 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1133 } catch {1134 return undefined;1135 }1136 });1137 expect(sentTypes).toContain("ADOPT_SNAPSHOT_APPLIED");1138 expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1139 });1140});11411142function decodeInvitation(encoded: string): { vault?: string } | null {1143 1144 1145 const at = encoded.indexOf(":");1146 if (at < 0) return null;1147 const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");1148 try {1149 const json = Buffer.from(b64, "base64").toString("utf8");1150 return JSON.parse(json) as { vault?: string };1151 } catch {1152 return null;1153 }1154}