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 setPairSecret: vi.fn(),11 pushes: [] as Array<{ type: string; payload: unknown }>,12 triggerSync: vi.fn(),13 createVault: vi.fn(),14 activateLicense: vi.fn(),15 isProUser: vi.fn(),16 getLicenseKey: vi.fn(),17 localIdentity: vi.fn(),18 rollbackVault: vi.fn(),19 createSyncAdapter: vi.fn(),20 __applyIncomingDeviceManifest: vi.fn(),21 storageSet: vi.fn(),22 sasDecisions: [] as Array<{ id: string; ok: boolean }>,23}));2425vi.mock("../shared/pairing-dial", () => ({26 openRelayPairingSocket: mocks.openSocket,27}));2829vi.mock("../shared/noise-js", () => ({30 noiseNKResponder: vi.fn(async () => ({31 handshakeHash: new Uint8Array(32).fill(7),32 encrypt: (bytes: Uint8Array) => bytes,33 decrypt: (bytes: Uint8Array) => bytes,34 close: vi.fn(),35 })),36}));3738vi.mock("../shared/rendezvous", async (importOriginal) => {39 const actual = await importOriginal<typeof import("../shared/rendezvous")>();40 return {41 ...actual,42 generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",43 };44});454647vi.mock("./device-identity", () => ({48 getLocalPairingIdentity: async () => ({49 identity: {50 deviceId: "test-device",51 deviceName: "Test",52 deviceType: "extension",53 longTermPublicKeyHex: "aa".repeat(32),54 vaultId: "bb".repeat(32),55 },56 privateKey: new Uint8Array(32).fill(7),57 publicKey: new Uint8Array(32).fill(8),58 }),59 getDeviceName: mocks.getDeviceName,60 buildLocalIdentity: mocks.localIdentity,61}));6263vi.mock("./sync-settings", async (importOriginal) => ({64 ...(await importOriginal<typeof import("./sync-settings")>()),65 getSyncSettings: async () => ({66 mode: "public" as const,67 relayUrl: "wss://relay.peachpasswords.com",68 }),69}));7071vi.mock("./license", () => ({72 isProUser: mocks.isProUser,73 getLicenseKey: mocks.getLicenseKey,74 activateLicense: mocks.activateLicense,75}));7677vi.mock("./vault", () => ({78 isUnlocked: vi.fn(() => true),79 getRootKeyHex: mocks.getRootKeyHex,80 createVaultFromRootKey: mocks.createVault,81 rollbackAdoptedVault: mocks.rollbackVault,82 exportDuressSlotForAdoption: vi.fn(async () => null),83 installAdoptedDuressSlot: vi.fn(async () => undefined),84}));8586vi.mock("../shared/sync-identity", async (importOriginal) => {87 88 89 90 91 const actual = await importOriginal<typeof import("../shared/sync-identity")>();92 return {93 ...actual,94 computeVaultId: vi.fn(async () => "b".repeat(64)),95 };96});9798vi.mock("../shared/storage", () => ({99 storageSet: mocks.storageSet,100}));101102vi.mock("./paired-devices", () => ({103 upsertPairedDevice: mocks.upsert,104}));105106vi.mock("./pair-secrets", () => ({107 setPairSecret: mocks.setPairSecret,108 isValidPairSecret: (value: unknown): value is string =>109 typeof value === "string" && /^[0-9a-f]{64}$/.test(value),110}));111112vi.mock("./sync-trigger", () => ({113 triggerSyncIfReachable: mocks.triggerSync,114}));115116vi.mock("./sync-vault-adapter", () => ({117 createSyncVaultAdapter: mocks.createSyncAdapter,118}));119120import {121 cancelRelayAdoptionSource,122 startRelayAdoptionSource,123} from "./relay-adoption-bg";124125const encode = (value: unknown) =>126 new TextEncoder().encode(JSON.stringify(value));127128function socketWithFrames(129 frames: unknown[],130 options: { closeOnEmpty?: boolean } = {},131) {132 const queue = frames.map((frame) => {133 const encoded = encode(frame);134 135 return (frame as { type?: string }).type === "ADOPT_SNAPSHOT"136 ? padSyncFrame(encoded)137 : encoded;138 });139 return {140 send: vi.fn(),141 receive: vi.fn(async () => {142 const next = queue.shift();143 if (!next) {144 145 146 147 if (options.closeOnEmpty) throw new Error("socket_closed");148 return new Promise<Uint8Array>(() => undefined);149 }150 return next;151 }),152 close: vi.fn(),153 closedBeforeFrame: vi.fn(() => false),154 };155}156157async function waitForPush(type: string): Promise<void> {158 await vi.waitFor(() => {159 expect(mocks.pushes.some((message) => message.type === type)).toBe(true);160 });161}162163beforeEach(() => {164 mocks.pushes.length = 0;165 mocks.sasDecisions.length = 0;166 mocks.openSocket.mockReset();167 mocks.getLocalIdentity.mockReset();168 mocks.getRootKeyHex.mockReset();169 mocks.upsert.mockReset();170 mocks.setPairSecret.mockReset();171 mocks.triggerSync.mockReset();172 mocks.triggerSync.mockResolvedValue(undefined);173 mocks.isProUser.mockReset();174 mocks.isProUser.mockResolvedValue(true);175 mocks.getLicenseKey.mockReset();176 mocks.getLicenseKey.mockResolvedValue("PPK-source");177 mocks.getLocalIdentity.mockResolvedValue({178 identity: {179 deviceId: "browser-source",180 deviceName: "June's Browser",181 deviceType: "extension",182 longTermPublicKeyHex: "a".repeat(64),183 vaultId: "b".repeat(64),184 },185 privateKey: new Uint8Array(32).fill(1),186 publicKey: new Uint8Array(32).fill(2),187 });188 mocks.getRootKeyHex.mockReturnValue("c".repeat(64));189 mocks.upsert.mockResolvedValue(undefined);190 mocks.setPairSecret.mockResolvedValue(undefined);191 const applyIncomingDeviceManifest = vi.fn(async () => undefined);192 mocks.__applyIncomingDeviceManifest = applyIncomingDeviceManifest;193 mocks.createSyncAdapter.mockReturnValue({194 buildEntryManifest: vi.fn(async () => ({})),195 getEncryptedEntries: vi.fn(async () => ({})),196 buildTombstoneManifest: vi.fn(async () => ({})),197 applyEntries: vi.fn(async () => undefined),198 applyTombstones: vi.fn(async () => undefined),199 buildOutgoingDeviceManifest: vi.fn(async () => null),200 applyIncomingDeviceManifest,201 });202 Object.defineProperty(globalThis, "chrome", {203 configurable: true,204 value: {205 runtime: {206 async sendMessage(message: { type: string; payload: unknown }) {207 mocks.pushes.push(message);208 209 210 211 212 if (message.type === "ADOPTION_SOURCE_SAS") {213 const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };214 if (typeof p?.id === "string" && typeof p.sas === "string") {215 if (p.displayOnly === false) {216 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");217 resolveReceiverSasConfirmation(p.id, p.sas, true);218 } else {219 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");220 mocks.sasDecisions.push({ id: p.id, ok: true });221 resolveSourceSasConfirmation(p.id, true);222 }223 }224 }225 return Promise.resolve();226 },227 },228 },229 });230});231232describe("relay adoption source background boundary", () => {233 it("shows the SAS and waits for the popup's single confirmation", async () => {234 const socket = socketWithFrames([235 {236 type: "PEACH_HELLO",237 token: "AAECAwQFBgcICQoLDA0ODw",238 },239 {240 type: "ADOPT_REQUEST",241 device_id: "request-only-label",242 device_name: "June's phone",243 device_type: "android",244 },245 { type: "ADOPT_CONFIRM", confirmed: true },246 {247 type: "ADOPT_IDENTITY",248 device_id: "android-receiver",249 device_name: "June's phone",250 device_type: "android",251 long_term_public_key_hex: "d".repeat(64),252 },253 { type: "ADOPT_APPLIED" },254 ]);255 mocks.openSocket.mockResolvedValue(socket);256257 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");258 const sasIds: string[] = [];259 Object.defineProperty(globalThis, "chrome", {260 configurable: true,261 value: {262 runtime: {263 async sendMessage(message: { type: string; payload: unknown }) {264 mocks.pushes.push(message);265 if (message.type === "ADOPTION_SOURCE_SAS") {266 const p = message.payload as { id?: string };267 if (typeof p?.id === "string") {268 269 270 sasIds.push(p.id);271 }272 return Promise.resolve();273 }274 return Promise.resolve();275 },276 },277 },278 });279 void resolveSourceSasConfirmation; 280281 const started = await startRelayAdoptionSource();282 expect(started.invitation).toMatch(/^peach-pair-v2:/);283 await waitForPush("ADOPTION_SOURCE_SAS");284 285 286 await vi.waitFor(() => {287 expect(mocks.sasDecisions.length).toBe(0);288 });289 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();290 expect(mocks.upsert).not.toHaveBeenCalled();291292 293 const id = sasIds[0];294 expect(typeof id).toBe("string");295 resolveSourceSasConfirmation(id, true);296 mocks.sasDecisions.push({ id, ok: true });297298 await waitForPush("ADOPTION_SOURCE_RESULT");299300 expect(mocks.getRootKeyHex).toHaveBeenCalled();301 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({302 deviceId: "android-receiver",303 relayUrl: "wss://relay.peachpasswords.com",304 }));305 306 307 const sourceRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;308 expect(sourceRow.rendezvousSecretHex).toBeUndefined();309 expect(mocks.setPairSecret).toHaveBeenCalledWith(310 "android-receiver",311 expect.stringMatching(/^[0-9a-f]{64}$/),312 expect.any(Uint8Array),313 );314 expect(mocks.pushes).toContainEqual({315 type: "ADOPTION_SOURCE_RESULT",316 payload: { status: "complete" },317 });318 expect(mocks.triggerSync).toHaveBeenCalledWith({319 reason: "manual",320 force: true,321 });322 const sentTypes = socket.send.mock.calls323 .map(([bytes]) => {324 try {325 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;326 } catch {327 return undefined;328 }329 });330 expect(sentTypes).toContain("ADOPT_COMPLETE");331 });332333 it("fails adoption without sending snapshot frames when duress is oversized", async () => {334 const socket = socketWithFrames([335 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },336 {337 type: "ADOPT_REQUEST",338 device_id: "request-only-label",339 device_name: "June's phone",340 device_type: "android",341 snapshot_capable: true,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);354 const { exportDuressSlotForAdoption } = await import("./vault");355 vi.mocked(exportDuressSlotForAdoption).mockResolvedValueOnce(356 "x".repeat(768 * 1024),357 );358359 await startRelayAdoptionSource();360 await waitForPush("ADOPTION_SOURCE_RESULT");361362 expect(mocks.pushes).toContainEqual({363 type: "ADOPTION_SOURCE_RESULT",364 payload: {365 status: "error",366 error: "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE",367 },368 });369 expect(mocks.pushes).not.toContainEqual(expect.objectContaining({370 type: "ADOPTION_SOURCE_RESULT",371 payload: expect.objectContaining({ status: "complete" }),372 }));373 const sentTypes = socket.send.mock.calls.map(([bytes]) =>374 JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,375 );376 expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");377 expect(mocks.triggerSync).not.toHaveBeenCalled();378 });379380 it("sends no snapshot frames and completes via sync fallback when the vault exceeds the inline budget", async () => {381 382 383 384 const socket = socketWithFrames([385 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },386 {387 type: "ADOPT_REQUEST",388 device_id: "request-only-label",389 device_name: "June's phone",390 device_type: "android",391 snapshot_capable: true,392 },393 { type: "ADOPT_CONFIRM", confirmed: true },394 {395 type: "ADOPT_IDENTITY",396 device_id: "android-receiver",397 device_name: "June's phone",398 device_type: "android",399 long_term_public_key_hex: "d".repeat(64),400 },401 { type: "ADOPT_APPLIED" },402 403 404 405 { type: "ADOPT_SNAPSHOT_APPLIED", entries_received: 0, tombstones_received: 0 },406 ]);407 mocks.openSocket.mockResolvedValue(socket);408 409 const envelope = "c".repeat(690_000);410 const entries: Record<string, string> = {};411 for (let i = 0; i < 16; i++) entries[`big-${i}`] = envelope;412 mocks.createSyncAdapter.mockReturnValue({413 buildEntryManifest: vi.fn(async () => Object.fromEntries(414 Object.keys(entries).map((id) => [id, 1]),415 )),416 getEncryptedEntries: vi.fn(async () => entries),417 buildTombstoneManifest: vi.fn(async () => ({})),418 applyEntries: vi.fn(async () => undefined),419 applyTombstones: vi.fn(async () => undefined),420 buildOutgoingDeviceManifest: vi.fn(async () => null),421 applyIncomingDeviceManifest: vi.fn(async () => undefined),422 });423424 await startRelayAdoptionSource();425 await waitForPush("ADOPTION_SOURCE_RESULT");426427 expect(mocks.pushes).toContainEqual({428 type: "ADOPTION_SOURCE_RESULT",429 payload: expect.objectContaining({ status: "complete" }),430 });431 const sentTypes = socket.send.mock.calls.map(([bytes]) =>432 JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,433 );434 expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");435 436 expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });437 });438439 it("fails closed when the popup rejects the SAS", async () => {440 const socket = socketWithFrames([441 {442 type: "PEACH_HELLO",443 token: "AAECAwQFBgcICQoLDA0ODw",444 },445 {446 type: "ADOPT_REQUEST",447 device_id: "request-only-label",448 device_name: "New phone",449 device_type: "android",450 },451 { type: "ADOPT_CONFIRM", confirmed: false },452 ]);453 mocks.openSocket.mockResolvedValue(socket);454455 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");456 let pendingId: string | undefined;457 Object.defineProperty(globalThis, "chrome", {458 configurable: true,459 value: {460 runtime: {461 sendMessage(message: { type: string; payload: unknown }) {462 mocks.pushes.push(message);463 if (message.type === "ADOPTION_SOURCE_SAS") {464 const p = message.payload as { id?: string };465 if (typeof p?.id === "string") pendingId = p.id;466 }467 return Promise.resolve();468 },469 },470 },471 });472473 await startRelayAdoptionSource();474 await waitForPush("ADOPTION_SOURCE_SAS");475 expect(typeof pendingId).toBe("string");476 resolveSourceSasConfirmation(pendingId!, false);477 mocks.sasDecisions.push({ id: pendingId!, ok: false });478479 await waitForPush("ADOPTION_SOURCE_RESULT");480481 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();482 expect(mocks.upsert).not.toHaveBeenCalled();483 expect(mocks.pushes).toContainEqual({484 type: "ADOPTION_SOURCE_RESULT",485 payload: { status: "error", error: "adoption_rejected" },486 });487 });488489 it("rejects before reading the root key when neither peer has Pro", async () => {490 const socket = socketWithFrames([491 {492 type: "PEACH_HELLO",493 token: "AAECAwQFBgcICQoLDA0ODw",494 },495 {496 type: "ADOPT_REQUEST",497 device_id: "free-receiver",498 device_name: "Free phone",499 device_type: "android",500 },501 { type: "ADOPT_CONFIRM", confirmed: true },502 ]);503 mocks.isProUser.mockResolvedValue(false);504 mocks.getLicenseKey.mockResolvedValue(undefined);505 mocks.openSocket.mockResolvedValue(socket);506507 await startRelayAdoptionSource();508 await waitForPush("ADOPTION_SOURCE_RESULT");509510 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();511 expect(mocks.upsert).not.toHaveBeenCalled();512 expect(mocks.pushes).toContainEqual({513 type: "ADOPTION_SOURCE_RESULT",514 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },515 });516 const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {517 try {518 return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];519 } catch {520 return [];521 }522 });523 expect(sentFrames).toContainEqual({524 type: "ADOPT_REJECT",525 reason: "ADOPT_PRO_REQUIRED",526 });527 });528529 it("fails closed when the user never answers the SAS", async () => {530 531 532 533 534 const socket = socketWithFrames([]);535 mocks.openSocket.mockResolvedValue(socket);536537 Object.defineProperty(globalThis, "chrome", {538 configurable: true,539 value: {540 runtime: {541 sendMessage(message: { type: string; payload: unknown }) {542 mocks.pushes.push(message);543 return Promise.resolve();544 },545 },546 },547 });548549 await startRelayAdoptionSource();550 cancelRelayAdoptionSource();551552 expect(socket.close).toHaveBeenCalled();553 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();554 expect(mocks.upsert).not.toHaveBeenCalled();555 });556557 it("cancelling the source screen closes the room without reading the root", async () => {558 const socket = socketWithFrames([]);559 mocks.openSocket.mockResolvedValue(socket);560561 await startRelayAdoptionSource();562 cancelRelayAdoptionSource();563564 expect(socket.close).toHaveBeenCalled();565 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();566 expect(mocks.upsert).not.toHaveBeenCalled();567 });568});569570describe("relay adoption receiver background", () => {571 beforeEach(async () => {572 573 574 575 576 const { cancelRelayAdoptionSource } = await import("./relay-adoption-bg");577 cancelRelayAdoptionSource();578 mocks.pushes.length = 0;579 mocks.openSocket.mockReset();580 mocks.getDeviceName.mockReset();581 mocks.getDeviceName.mockResolvedValue("Receiver Browser");582 mocks.createVault.mockReset();583 mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });584 mocks.activateLicense.mockReset();585 mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });586 mocks.isProUser.mockReset();587 mocks.isProUser.mockResolvedValue(true);588 mocks.getLicenseKey.mockReset();589 mocks.getLicenseKey.mockResolvedValue(undefined);590 mocks.localIdentity.mockReset();591 mocks.localIdentity.mockResolvedValue({592 deviceId: "receiver-id",593 deviceName: "Receiver Browser",594 deviceType: "extension",595 longTermPublicKeyHex: "11".repeat(32),596 signingPublicKeyHex: "22".repeat(32),597 });598 mocks.rollbackVault.mockReset();599 mocks.rollbackVault.mockResolvedValue(undefined);600 mocks.storageSet.mockReset();601 mocks.storageSet.mockResolvedValue(undefined);602 mocks.upsert.mockReset();603 mocks.upsert.mockResolvedValue(undefined);604 mocks.setPairSecret.mockReset();605 mocks.setPairSecret.mockResolvedValue(undefined);606 mocks.triggerSync.mockReset();607 mocks.triggerSync.mockResolvedValue(undefined);608 mocks.openSocket.mockResolvedValue(socketWithFrames([]));609 Object.defineProperty(globalThis, "chrome", {610 configurable: true,611 value: {612 runtime: {613 async sendMessage(message: { type: string; payload?: unknown }) {614 mocks.pushes.push({ type: message.type, payload: message.payload });615 if (message.type === "ADOPTION_SOURCE_SAS") {616 const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };617 if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {618 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");619 resolveReceiverSasConfirmation(p.id, p.sas, true);620 }621 }622 return { ok: true };623 },624 onMessage: {625 addListener() { },626 removeListener() { },627 },628 },629 },630 });631 });632633 it("opens a relay socket and returns an empty invitation", async () => {634 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");635 const started = await startRelayAdoptionReceiver("receiver-password");636 expect(started.invitation).toMatch(/^peach-pair-v2:/);637 638 const decoded = decodeInvitation(started.invitation);639 expect(decoded?.vault).toBe("empty");640 expect(mocks.openSocket).toHaveBeenCalled();641 });642643 it("rejects an empty password", async () => {644 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");645 await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");646 });647648 it("surfaces the Pro-required rejection without creating a vault", async () => {649 mocks.openSocket.mockResolvedValue(socketWithFrames([650 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },651 { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },652 ]));653654 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");655 await startRelayAdoptionReceiver("receiver-password");656 await waitForPush("ADOPTION_SOURCE_RESULT");657658 expect(mocks.createVault).not.toHaveBeenCalled();659 expect(mocks.rollbackVault).not.toHaveBeenCalled();660 expect(mocks.pushes).toContainEqual({661 type: "ADOPTION_SOURCE_RESULT",662 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },663 });664 });665666 it("the receiver wires requestAdoption, not grantAdoption", async () => {667 668 669 670 671 672 const rootKeyHex = "f0".repeat(32);673 const vaultId = "b".repeat(64);674 const socket = socketWithFrames([675 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },676 {677 type: "ADOPT_GRANT",678 root_key_hex: rootKeyHex,679 vault_id: vaultId,680 license: "PPK-test",681 source: {682 device_id: "phone-source-id",683 device_name: "June's phone",684 device_type: "android",685 long_term_public_key_hex: "99".repeat(32),686 },687 },688 { type: "ADOPT_COMPLETE" },689 ], { closeOnEmpty: true });690 mocks.openSocket.mockResolvedValue(socket);691 mocks.createVault.mockResolvedValue({ vaultId });692693 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");694 Object.defineProperty(globalThis, "chrome", {695 configurable: true,696 value: {697 runtime: {698 async sendMessage(message: { type: string; payload?: unknown }) {699 mocks.pushes.push({ type: message.type, payload: message.payload });700 if (message.type === "ADOPTION_SOURCE_SAS") {701 const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };702 if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {703 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");704 resolveReceiverSasConfirmation(p.id, p.sas, true);705 }706 }707 return { ok: true };708 },709 onMessage: { addListener: () => undefined, removeListener: () => undefined },710 },711 },712 });713 await startRelayAdoptionReceiver("receiver-password");714 await waitForPush("ADOPTION_SOURCE_RESULT");715716 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })717 .mock.calls718 .map(([bytes]) => {719 try {720 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;721 } catch {722 return undefined;723 }724 });725 726 727 expect(sentTypes).toContain("ADOPT_REQUEST");728 expect(sentTypes).not.toContain("ADOPT_GRANT");729 });730731 it("completes the handshake end-to-end: vault created → paired", async () => {732 733 734 735 736 737 const rootKeyHex = "f0".repeat(32);738 739 740 const vaultId = "b".repeat(64);741 const frames = [742 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },743 744 745 {746 type: "ADOPT_GRANT",747 root_key_hex: rootKeyHex,748 vault_id: vaultId,749 license: "PPK-test",750 source: {751 device_id: "phone-source-id",752 device_name: "June's phone",753 device_type: "android",754 long_term_public_key_hex: "99".repeat(32),755 signing_public_key_hex: "aa".repeat(32),756 relay_url: "wss://relay.peachpasswords.com",757 },758 },759 { type: "ADOPT_COMPLETE" },760 ];761 mocks.createVault.mockResolvedValue({ vaultId });762 mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));763764 765 Object.defineProperty(globalThis, "chrome", {766 configurable: true,767 value: {768 runtime: {769 async sendMessage(message: { type: string; payload?: unknown }) {770 mocks.pushes.push({ type: message.type, payload: message.payload });771 if (message.type === "ADOPTION_SOURCE_SAS") {772 const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };773 if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {774 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");775 resolveReceiverSasConfirmation(p.id, p.sas, true);776 }777 }778 return { ok: true };779 },780 onMessage: {781 addListener: () => undefined,782 removeListener: () => undefined,783 },784 },785 },786 });787788 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");789 await startRelayAdoptionReceiver("receiver-password");790791 await waitForPush("ADOPTION_SOURCE_RESULT");792793 794 expect(mocks.pushes).toContainEqual({795 type: "ADOPTION_SOURCE_SAS",796 payload: expect.objectContaining({797 sas: expect.stringMatching(/^\d{8}$/),798 displayOnly: false,799 }),800 });801802 803 804 expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);805806 807 808 expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");809 expect(mocks.isProUser).toHaveBeenCalled();810 expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));811 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({812 deviceId: "phone-source-id",813 }));814 const receiverRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;815 expect(receiverRow.rendezvousSecretHex).toBeUndefined();816 expect(mocks.setPairSecret).toHaveBeenCalledWith(817 "phone-source-id",818 expect.stringMatching(/^[0-9a-f]{64}$/),819 expect.any(Uint8Array),820 );821 expect(mocks.storageSet).toHaveBeenCalledWith(822 "peach_initial_sync_pending_device_id",823 "phone-source-id",824 );825 expect(mocks.rollbackVault).not.toHaveBeenCalled();826827 828 expect(mocks.pushes).toContainEqual({829 type: "ADOPTION_SOURCE_RESULT",830 payload: { status: "complete" },831 });832 });833834 it("rolls back and refuses completion when a carried Pro license is not activated", async () => {835 const rootKeyHex = "f0".repeat(32);836 const vaultId = "b".repeat(64);837 mocks.createVault.mockResolvedValue({ vaultId });838 mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });839 mocks.isProUser.mockResolvedValue(false);840 mocks.openSocket.mockResolvedValue(socketWithFrames([841 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },842 {843 type: "ADOPT_GRANT",844 root_key_hex: rootKeyHex,845 vault_id: vaultId,846 license: "PPK-invalid",847 source: {848 device_id: "phone-source-id",849 device_name: "June's phone",850 device_type: "android",851 long_term_public_key_hex: "99".repeat(32),852 },853 },854 ]));855856 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");857 await startRelayAdoptionReceiver("receiver-password");858 await waitForPush("ADOPTION_SOURCE_RESULT");859860 expect(mocks.rollbackVault).toHaveBeenCalledOnce();861 expect(mocks.localIdentity).not.toHaveBeenCalled();862 expect(mocks.upsert).not.toHaveBeenCalled();863 expect(mocks.pushes).toContainEqual(expect.objectContaining({864 type: "ADOPTION_SOURCE_RESULT",865 payload: expect.objectContaining({866 status: "error",867 error: "license_transfer_failed",868 }),869 }));870 });871872 it("rolls back a newly created vault when a later adoption step fails", async () => {873 const rootKeyHex = "f0".repeat(32);874 const vaultId = "b".repeat(64);875 mocks.createVault.mockResolvedValue({ vaultId });876 mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));877 mocks.openSocket.mockResolvedValue(socketWithFrames([878 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },879 {880 type: "ADOPT_GRANT",881 root_key_hex: rootKeyHex,882 vault_id: vaultId,883 license: "PPK-test",884 source: {885 device_id: "phone-source-id",886 device_name: "June's phone",887 device_type: "android",888 long_term_public_key_hex: "99".repeat(32),889 },890 },891 ]));892893 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");894 await startRelayAdoptionReceiver("receiver-password");895 await waitForPush("ADOPTION_SOURCE_RESULT");896897 expect(mocks.rollbackVault).toHaveBeenCalledOnce();898 expect(mocks.upsert).not.toHaveBeenCalled();899 expect(mocks.pushes).toContainEqual(expect.objectContaining({900 type: "ADOPTION_SOURCE_RESULT",901 payload: expect.objectContaining({ status: "error", error: "identity_failed" }),902 }));903 });904905 it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {906 907 908 909 const rootKeyHex = "f0".repeat(32);910 const vaultId = "b".repeat(64);911 mocks.createVault.mockResolvedValue({ vaultId });912 mocks.openSocket.mockResolvedValue(socketWithFrames([913 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },914 {915 type: "ADOPT_GRANT",916 root_key_hex: rootKeyHex,917 vault_id: vaultId,918 license: "PPK-test",919 source: {920 device_id: "old-phone-id",921 device_name: "Old phone",922 device_type: "android",923 long_term_public_key_hex: "99".repeat(32),924 },925 926 },927 { type: "ADOPT_COMPLETE" },928 ], { closeOnEmpty: true }));929930 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");931 await startRelayAdoptionReceiver("receiver-password");932 await waitForPush("ADOPTION_SOURCE_RESULT");933934 935 expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });936 937 expect(mocks.pushes).toContainEqual(expect.objectContaining({938 type: "ADOPTION_SOURCE_RESULT",939 payload: expect.objectContaining({ status: "complete" }),940 }));941 942 expect(mocks.storageSet).toHaveBeenCalledWith(943 "peach_initial_sync_pending_device_id",944 "old-phone-id",945 );946 });947948 it("applies the source's device manifest after the snapshot, then acks", async () => {949 950 951 952 953 954 const rootKeyHex = "f0".repeat(32);955 const vaultId = "b".repeat(64);956 const longTermPubkeyHex = "99".repeat(32);957 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");958 const { deriveSyncSigningKeypair, pubkeyToHex } = await import(959 "../shared/sync-identity"960 );961 const issuerKp = await deriveSyncSigningKeypair(962 new Uint8Array(32).fill(0x55),963 "phone-source-id",964 );965 const manifest = await buildSignedDeviceManifest(966 "phone-source-id",967 [968 {969 deviceId: "peer-b",970 deviceName: "Pool B",971 deviceType: "extension",972 longTermPublicKeyHex: "11".repeat(32),973 signingPublicKeyHex: "22".repeat(32),974 wsPort: 49152,975 relayUrl: "wss://relay.peachpasswords.com",976 pairedAt: 1,977 },978 ],979 issuerKp.privateKey,980 );981 const socket = socketWithFrames([982 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },983 {984 type: "ADOPT_GRANT",985 root_key_hex: rootKeyHex,986 vault_id: vaultId,987 license: "PPK-test",988 snapshot_capable: true,989 source: {990 device_id: "phone-source-id",991 device_name: "June's phone",992 device_type: "android",993 long_term_public_key_hex: longTermPubkeyHex,994 signing_public_key_hex: pubkeyToHex(issuerKp.publicKeyRaw),995 relay_url: "wss://relay.peachpasswords.com",996 },997 },998 999 1000 { type: "ADOPT_COMPLETE" },1001 1002 1003 { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1004 { type: "ADOPT_DEVICE_MANIFEST", manifest },1005 ], { closeOnEmpty: true });1006 mocks.createVault.mockResolvedValue({ vaultId });1007 mocks.openSocket.mockResolvedValue(socket);1008 mocks.__applyIncomingDeviceManifest.mockClear();10091010 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1011 await startRelayAdoptionReceiver("receiver-password");1012 await waitForPush("ADOPTION_SOURCE_RESULT");10131014 expect(mocks.__applyIncomingDeviceManifest).toHaveBeenCalledTimes(1);1015 const [forwarded, pubkey] = mocks.__applyIncomingDeviceManifest.mock.calls[0];1016 expect(forwarded).toEqual(manifest);1017 expect(pubkey).toBeInstanceOf(Uint8Array);1018 expect(pubkey.length).toBe(32);1019 1020 1021 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1022 .mock.calls1023 .map(([bytes]) => {1024 try {1025 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1026 } catch {1027 return undefined;1028 }1029 });1030 expect(sentTypes).toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1031 1032 expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST");1033 });10341035 it("does not call applyIncomingDeviceManifest when the source omits the manifest frame", async () => {1036 1037 1038 1039 1040 const rootKeyHex = "f0".repeat(32);1041 const vaultId = "b".repeat(64);1042 const longTermPubkeyHex = "99".repeat(32);1043 const socket = socketWithFrames([1044 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1045 {1046 type: "ADOPT_GRANT",1047 root_key_hex: rootKeyHex,1048 vault_id: vaultId,1049 license: "PPK-test",1050 snapshot_capable: true,1051 source: {1052 device_id: "phone-source-id",1053 device_name: "June's phone",1054 device_type: "android",1055 long_term_public_key_hex: longTermPubkeyHex,1056 signing_public_key_hex: "aa".repeat(32),1057 relay_url: "wss://relay.peachpasswords.com",1058 },1059 },1060 { type: "ADOPT_COMPLETE" },1061 1062 1063 { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1064 ], { closeOnEmpty: true });1065 mocks.createVault.mockResolvedValue({ vaultId });1066 mocks.openSocket.mockResolvedValue(socket);1067 mocks.__applyIncomingDeviceManifest.mockClear();10681069 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1070 await startRelayAdoptionReceiver("receiver-password");1071 await waitForPush("ADOPTION_SOURCE_RESULT");10721073 expect(mocks.__applyIncomingDeviceManifest).not.toHaveBeenCalled();1074 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1075 .mock.calls1076 .map(([bytes]) => {1077 try {1078 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1079 } catch {1080 return undefined;1081 }1082 });1083 expect(sentTypes).toContain("ADOPT_SNAPSHOT_APPLIED");1084 expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1085 });1086});10871088function decodeInvitation(encoded: string): { vault?: string } | null {1089 1090 1091 const at = encoded.indexOf(":");1092 if (at < 0) return null;1093 const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");1094 try {1095 const json = Buffer.from(b64, "base64").toString("utf8");1096 return JSON.parse(json) as { vault?: string };1097 } catch {1098 return null;1099 }1100}