1import { beforeEach, describe, expect, it, vi } from "vitest";23const mocks = vi.hoisted(() => ({4 openSocket: vi.fn(),5 getLocalIdentity: vi.fn(),6 getDeviceName: vi.fn(),7 getRootKeyHex: vi.fn(),8 upsert: vi.fn(),9 setPairSecret: vi.fn(),10 pushes: [] as Array<{ type: string; payload: unknown }>,11 triggerSync: vi.fn(),12 createVault: vi.fn(),13 activateLicense: vi.fn(),14 isProUser: vi.fn(),15 getLicenseKey: vi.fn(),16 localIdentity: vi.fn(),17 rollbackVault: vi.fn(),18 createSyncAdapter: vi.fn(),19 __applyIncomingDeviceManifest: vi.fn(),20 storageSet: vi.fn(),21 sasDecisions: [] as Array<{ id: string; ok: boolean }>,22}));2324vi.mock("../shared/pairing-dial", () => ({25 openRelayPairingSocket: mocks.openSocket,26}));2728vi.mock("../shared/noise-js", () => ({29 noiseNKResponder: vi.fn(async () => ({30 handshakeHash: new Uint8Array(32).fill(7),31 encrypt: (bytes: Uint8Array) => bytes,32 decrypt: (bytes: Uint8Array) => bytes,33 close: vi.fn(),34 })),35}));3637vi.mock("../shared/rendezvous", async (importOriginal) => {38 const actual = await importOriginal<typeof import("../shared/rendezvous")>();39 return {40 ...actual,41 generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",42 };43});444546vi.mock("./device-identity", () => ({47 getLocalPairingIdentity: async () => ({48 identity: {49 deviceId: "test-device",50 deviceName: "Test",51 deviceType: "extension",52 longTermPublicKeyHex: "aa".repeat(32),53 vaultId: "bb".repeat(32),54 },55 privateKey: new Uint8Array(32).fill(7),56 publicKey: new Uint8Array(32).fill(8),57 }),58 getDeviceName: mocks.getDeviceName,59 buildLocalIdentity: mocks.localIdentity,60}));6162vi.mock("./sync-settings", async (importOriginal) => ({63 ...(await importOriginal<typeof import("./sync-settings")>()),64 getSyncSettings: async () => ({65 mode: "public" as const,66 relayUrl: "wss://relay.peachpasswords.com",67 }),68}));6970vi.mock("./license", () => ({71 isProUser: mocks.isProUser,72 getLicenseKey: mocks.getLicenseKey,73 activateLicense: mocks.activateLicense,74}));7576vi.mock("./vault", () => ({77 isUnlocked: vi.fn(() => true),78 getRootKeyHex: mocks.getRootKeyHex,79 createVaultFromRootKey: mocks.createVault,80 rollbackAdoptedVault: mocks.rollbackVault,81 exportDuressSlotForAdoption: vi.fn(async () => null),82 installAdoptedDuressSlot: vi.fn(async () => undefined),83}));8485vi.mock("../shared/sync-identity", async (importOriginal) => {86 87 88 89 90 const actual = await importOriginal<typeof import("../shared/sync-identity")>();91 return {92 ...actual,93 computeVaultId: vi.fn(async () => "b".repeat(64)),94 };95});9697vi.mock("../shared/storage", () => ({98 storageSet: mocks.storageSet,99}));100101vi.mock("./paired-devices", () => ({102 upsertPairedDevice: mocks.upsert,103}));104105vi.mock("./pair-secrets", () => ({106 setPairSecret: mocks.setPairSecret,107 isValidPairSecret: (value: unknown): value is string =>108 typeof value === "string" && /^[0-9a-f]{64}$/.test(value),109}));110111vi.mock("./sync-trigger", () => ({112 triggerSyncIfReachable: mocks.triggerSync,113}));114115vi.mock("./sync-vault-adapter", () => ({116 createSyncVaultAdapter: mocks.createSyncAdapter,117}));118119import {120 cancelRelayAdoptionSource,121 startRelayAdoptionSource,122} from "./relay-adoption-bg";123124const encode = (value: unknown) =>125 new TextEncoder().encode(JSON.stringify(value));126127function socketWithFrames(128 frames: unknown[],129 options: { closeOnEmpty?: boolean } = {},130) {131 const queue = frames.map(encode);132 return {133 send: vi.fn(),134 receive: vi.fn(async () => {135 const next = queue.shift();136 if (!next) {137 138 139 140 if (options.closeOnEmpty) throw new Error("socket_closed");141 return new Promise<Uint8Array>(() => undefined);142 }143 return next;144 }),145 close: vi.fn(),146 closedBeforeFrame: vi.fn(() => false),147 };148}149150async function waitForPush(type: string): Promise<void> {151 await vi.waitFor(() => {152 expect(mocks.pushes.some((message) => message.type === type)).toBe(true);153 });154}155156beforeEach(() => {157 mocks.pushes.length = 0;158 mocks.sasDecisions.length = 0;159 mocks.openSocket.mockReset();160 mocks.getLocalIdentity.mockReset();161 mocks.getRootKeyHex.mockReset();162 mocks.upsert.mockReset();163 mocks.setPairSecret.mockReset();164 mocks.triggerSync.mockReset();165 mocks.triggerSync.mockResolvedValue(undefined);166 mocks.isProUser.mockReset();167 mocks.isProUser.mockResolvedValue(true);168 mocks.getLicenseKey.mockReset();169 mocks.getLicenseKey.mockResolvedValue("PPK-source");170 mocks.getLocalIdentity.mockResolvedValue({171 identity: {172 deviceId: "browser-source",173 deviceName: "June's Browser",174 deviceType: "extension",175 longTermPublicKeyHex: "a".repeat(64),176 vaultId: "b".repeat(64),177 },178 privateKey: new Uint8Array(32).fill(1),179 publicKey: new Uint8Array(32).fill(2),180 });181 mocks.getRootKeyHex.mockReturnValue("c".repeat(64));182 mocks.upsert.mockResolvedValue(undefined);183 mocks.setPairSecret.mockResolvedValue(undefined);184 const applyIncomingDeviceManifest = vi.fn(async () => undefined);185 mocks.__applyIncomingDeviceManifest = applyIncomingDeviceManifest;186 mocks.createSyncAdapter.mockReturnValue({187 buildEntryManifest: vi.fn(async () => ({})),188 getEncryptedEntries: vi.fn(async () => ({})),189 buildTombstoneManifest: vi.fn(async () => ({})),190 applyEntries: vi.fn(async () => undefined),191 applyTombstones: vi.fn(async () => undefined),192 buildOutgoingDeviceManifest: vi.fn(async () => null),193 applyIncomingDeviceManifest,194 });195 Object.defineProperty(globalThis, "chrome", {196 configurable: true,197 value: {198 runtime: {199 async sendMessage(message: { type: string; payload: unknown }) {200 mocks.pushes.push(message);201 202 203 204 205 if (message.type === "ADOPTION_SOURCE_SAS") {206 const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };207 if (typeof p?.id === "string" && typeof p.sas === "string") {208 if (p.displayOnly === false) {209 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");210 resolveReceiverSasConfirmation(p.id, p.sas, true);211 } else {212 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");213 mocks.sasDecisions.push({ id: p.id, ok: true });214 resolveSourceSasConfirmation(p.id, true);215 }216 }217 }218 return Promise.resolve();219 },220 },221 },222 });223});224225describe("relay adoption source background boundary", () => {226 it("shows the SAS and waits for the popup's single confirmation", async () => {227 const socket = socketWithFrames([228 {229 type: "PEACH_HELLO",230 token: "AAECAwQFBgcICQoLDA0ODw",231 },232 {233 type: "ADOPT_REQUEST",234 device_id: "request-only-label",235 device_name: "June's phone",236 device_type: "android",237 },238 { type: "ADOPT_CONFIRM", confirmed: true },239 {240 type: "ADOPT_IDENTITY",241 device_id: "android-receiver",242 device_name: "June's phone",243 device_type: "android",244 long_term_public_key_hex: "d".repeat(64),245 },246 { type: "ADOPT_APPLIED" },247 ]);248 mocks.openSocket.mockResolvedValue(socket);249250 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");251 const sasIds: string[] = [];252 Object.defineProperty(globalThis, "chrome", {253 configurable: true,254 value: {255 runtime: {256 async sendMessage(message: { type: string; payload: unknown }) {257 mocks.pushes.push(message);258 if (message.type === "ADOPTION_SOURCE_SAS") {259 const p = message.payload as { id?: string };260 if (typeof p?.id === "string") {261 262 263 sasIds.push(p.id);264 }265 return Promise.resolve();266 }267 return Promise.resolve();268 },269 },270 },271 });272 void resolveSourceSasConfirmation; 273274 const started = await startRelayAdoptionSource();275 expect(started.invitation).toMatch(/^peach-pair-v2:/);276 await waitForPush("ADOPTION_SOURCE_SAS");277 278 279 await vi.waitFor(() => {280 expect(mocks.sasDecisions.length).toBe(0);281 });282 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();283 expect(mocks.upsert).not.toHaveBeenCalled();284285 286 const id = sasIds[0];287 expect(typeof id).toBe("string");288 resolveSourceSasConfirmation(id, true);289 mocks.sasDecisions.push({ id, ok: true });290291 await waitForPush("ADOPTION_SOURCE_RESULT");292293 expect(mocks.getRootKeyHex).toHaveBeenCalled();294 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({295 deviceId: "android-receiver",296 relayUrl: "wss://relay.peachpasswords.com",297 }));298 299 300 const sourceRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;301 expect(sourceRow.rendezvousSecretHex).toBeUndefined();302 expect(mocks.setPairSecret).toHaveBeenCalledWith(303 "android-receiver",304 expect.stringMatching(/^[0-9a-f]{64}$/),305 expect.any(Uint8Array),306 );307 expect(mocks.pushes).toContainEqual(expect.objectContaining({308 type: "ADOPTION_SOURCE_RESULT",309 payload: expect.objectContaining({ status: "complete" }),310 }));311 expect(mocks.triggerSync).toHaveBeenCalledWith({312 reason: "manual",313 force: true,314 });315 const sentTypes = socket.send.mock.calls316 .map(([bytes]) => {317 try {318 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;319 } catch {320 return undefined;321 }322 });323 expect(sentTypes).toContain("ADOPT_COMPLETE");324 });325326 it("fails adoption without sending snapshot frames when duress is oversized", async () => {327 const socket = socketWithFrames([328 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },329 {330 type: "ADOPT_REQUEST",331 device_id: "request-only-label",332 device_name: "June's phone",333 device_type: "android",334 snapshot_capable: true,335 },336 { type: "ADOPT_CONFIRM", confirmed: true },337 {338 type: "ADOPT_IDENTITY",339 device_id: "android-receiver",340 device_name: "June's phone",341 device_type: "android",342 long_term_public_key_hex: "d".repeat(64),343 },344 { type: "ADOPT_APPLIED" },345 ]);346 mocks.openSocket.mockResolvedValue(socket);347 const { exportDuressSlotForAdoption } = await import("./vault");348 vi.mocked(exportDuressSlotForAdoption).mockResolvedValueOnce(349 "x".repeat(768 * 1024),350 );351352 await startRelayAdoptionSource();353 await waitForPush("ADOPTION_SOURCE_RESULT");354355 expect(mocks.pushes).toContainEqual({356 type: "ADOPTION_SOURCE_RESULT",357 payload: {358 status: "error",359 error: "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE",360 },361 });362 expect(mocks.pushes).not.toContainEqual(expect.objectContaining({363 type: "ADOPTION_SOURCE_RESULT",364 payload: expect.objectContaining({ status: "complete" }),365 }));366 const sentTypes = socket.send.mock.calls.map(([bytes]) =>367 JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,368 );369 expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");370 expect(mocks.triggerSync).not.toHaveBeenCalled();371 });372373 it("sends no snapshot frames and completes via sync fallback when the vault exceeds the inline budget", async () => {374 375 376 377 const socket = socketWithFrames([378 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },379 {380 type: "ADOPT_REQUEST",381 device_id: "request-only-label",382 device_name: "June's phone",383 device_type: "android",384 snapshot_capable: true,385 },386 { type: "ADOPT_CONFIRM", confirmed: true },387 {388 type: "ADOPT_IDENTITY",389 device_id: "android-receiver",390 device_name: "June's phone",391 device_type: "android",392 long_term_public_key_hex: "d".repeat(64),393 },394 { type: "ADOPT_APPLIED" },395 396 397 398 { type: "ADOPT_SNAPSHOT_APPLIED", entries_received: 0, tombstones_received: 0 },399 ]);400 mocks.openSocket.mockResolvedValue(socket);401 402 const envelope = "c".repeat(690_000);403 const entries: Record<string, string> = {};404 for (let i = 0; i < 16; i++) entries[`big-${i}`] = envelope;405 mocks.createSyncAdapter.mockReturnValue({406 buildEntryManifest: vi.fn(async () => Object.fromEntries(407 Object.keys(entries).map((id) => [id, 1]),408 )),409 getEncryptedEntries: vi.fn(async () => entries),410 buildTombstoneManifest: vi.fn(async () => ({})),411 applyEntries: vi.fn(async () => undefined),412 applyTombstones: vi.fn(async () => undefined),413 buildOutgoingDeviceManifest: vi.fn(async () => null),414 applyIncomingDeviceManifest: vi.fn(async () => undefined),415 });416417 await startRelayAdoptionSource();418 await waitForPush("ADOPTION_SOURCE_RESULT");419420 expect(mocks.pushes).toContainEqual({421 type: "ADOPTION_SOURCE_RESULT",422 payload: expect.objectContaining({ status: "complete" }),423 });424 const sentTypes = socket.send.mock.calls.map(([bytes]) =>425 JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,426 );427 expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");428 429 expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });430 });431432 it("fails closed when the popup rejects the SAS", async () => {433 const socket = socketWithFrames([434 {435 type: "PEACH_HELLO",436 token: "AAECAwQFBgcICQoLDA0ODw",437 },438 {439 type: "ADOPT_REQUEST",440 device_id: "request-only-label",441 device_name: "New phone",442 device_type: "android",443 },444 { type: "ADOPT_CONFIRM", confirmed: false },445 ]);446 mocks.openSocket.mockResolvedValue(socket);447448 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");449 let pendingId: string | undefined;450 Object.defineProperty(globalThis, "chrome", {451 configurable: true,452 value: {453 runtime: {454 sendMessage(message: { type: string; payload: unknown }) {455 mocks.pushes.push(message);456 if (message.type === "ADOPTION_SOURCE_SAS") {457 const p = message.payload as { id?: string };458 if (typeof p?.id === "string") pendingId = p.id;459 }460 return Promise.resolve();461 },462 },463 },464 });465466 await startRelayAdoptionSource();467 await waitForPush("ADOPTION_SOURCE_SAS");468 expect(typeof pendingId).toBe("string");469 resolveSourceSasConfirmation(pendingId!, false);470 mocks.sasDecisions.push({ id: pendingId!, ok: false });471472 await waitForPush("ADOPTION_SOURCE_RESULT");473474 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();475 expect(mocks.upsert).not.toHaveBeenCalled();476 expect(mocks.pushes).toContainEqual({477 type: "ADOPTION_SOURCE_RESULT",478 payload: { status: "error", error: "adoption_rejected" },479 });480 });481482 it("rejects before reading the root key when neither peer has Pro", async () => {483 const socket = socketWithFrames([484 {485 type: "PEACH_HELLO",486 token: "AAECAwQFBgcICQoLDA0ODw",487 },488 {489 type: "ADOPT_REQUEST",490 device_id: "free-receiver",491 device_name: "Free phone",492 device_type: "android",493 },494 { type: "ADOPT_CONFIRM", confirmed: true },495 ]);496 mocks.isProUser.mockResolvedValue(false);497 mocks.getLicenseKey.mockResolvedValue(undefined);498 mocks.openSocket.mockResolvedValue(socket);499500 await startRelayAdoptionSource();501 await waitForPush("ADOPTION_SOURCE_RESULT");502503 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();504 expect(mocks.upsert).not.toHaveBeenCalled();505 expect(mocks.pushes).toContainEqual({506 type: "ADOPTION_SOURCE_RESULT",507 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },508 });509 const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {510 try {511 return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];512 } catch {513 return [];514 }515 });516 expect(sentFrames).toContainEqual({517 type: "ADOPT_REJECT",518 reason: "ADOPT_PRO_REQUIRED",519 });520 });521522 it("fails closed when the user never answers the SAS", async () => {523 524 525 526 527 const socket = socketWithFrames([]);528 mocks.openSocket.mockResolvedValue(socket);529530 Object.defineProperty(globalThis, "chrome", {531 configurable: true,532 value: {533 runtime: {534 sendMessage(message: { type: string; payload: unknown }) {535 mocks.pushes.push(message);536 return Promise.resolve();537 },538 },539 },540 });541542 await startRelayAdoptionSource();543 cancelRelayAdoptionSource();544545 expect(socket.close).toHaveBeenCalled();546 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();547 expect(mocks.upsert).not.toHaveBeenCalled();548 });549550 it("cancelling the source screen closes the room without reading the root", async () => {551 const socket = socketWithFrames([]);552 mocks.openSocket.mockResolvedValue(socket);553554 await startRelayAdoptionSource();555 cancelRelayAdoptionSource();556557 expect(socket.close).toHaveBeenCalled();558 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();559 expect(mocks.upsert).not.toHaveBeenCalled();560 });561});562563describe("relay adoption receiver background", () => {564 beforeEach(async () => {565 566 567 568 569 const { cancelRelayAdoptionSource } = await import("./relay-adoption-bg");570 cancelRelayAdoptionSource();571 mocks.pushes.length = 0;572 mocks.openSocket.mockReset();573 mocks.getDeviceName.mockReset();574 mocks.getDeviceName.mockResolvedValue("Receiver Browser");575 mocks.createVault.mockReset();576 mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });577 mocks.activateLicense.mockReset();578 mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });579 mocks.isProUser.mockReset();580 mocks.isProUser.mockResolvedValue(true);581 mocks.getLicenseKey.mockReset();582 mocks.getLicenseKey.mockResolvedValue(undefined);583 mocks.localIdentity.mockReset();584 mocks.localIdentity.mockResolvedValue({585 deviceId: "receiver-id",586 deviceName: "Receiver Browser",587 deviceType: "extension",588 longTermPublicKeyHex: "11".repeat(32),589 signingPublicKeyHex: "22".repeat(32),590 });591 mocks.rollbackVault.mockReset();592 mocks.rollbackVault.mockResolvedValue(undefined);593 mocks.storageSet.mockReset();594 mocks.storageSet.mockResolvedValue(undefined);595 mocks.upsert.mockReset();596 mocks.upsert.mockResolvedValue(undefined);597 mocks.setPairSecret.mockReset();598 mocks.setPairSecret.mockResolvedValue(undefined);599 mocks.triggerSync.mockReset();600 mocks.triggerSync.mockResolvedValue(undefined);601 mocks.openSocket.mockResolvedValue(socketWithFrames([]));602 Object.defineProperty(globalThis, "chrome", {603 configurable: true,604 value: {605 runtime: {606 async sendMessage(message: { type: string; payload?: unknown }) {607 mocks.pushes.push({ type: message.type, payload: message.payload });608 if (message.type === "ADOPTION_SOURCE_SAS") {609 const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };610 if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {611 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");612 resolveReceiverSasConfirmation(p.id, p.sas, true);613 }614 }615 return { ok: true };616 },617 onMessage: {618 addListener() { },619 removeListener() { },620 },621 },622 },623 });624 });625626 it("opens a relay socket and returns an empty invitation", async () => {627 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");628 const started = await startRelayAdoptionReceiver("receiver-password");629 expect(started.invitation).toMatch(/^peach-pair-v2:/);630 631 const decoded = decodeInvitation(started.invitation);632 expect(decoded?.vault).toBe("empty");633 expect(mocks.openSocket).toHaveBeenCalled();634 });635636 it("rejects an empty password", async () => {637 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");638 await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");639 });640641 it("surfaces the Pro-required rejection without creating a vault", async () => {642 mocks.openSocket.mockResolvedValue(socketWithFrames([643 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },644 { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },645 ]));646647 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");648 await startRelayAdoptionReceiver("receiver-password");649 await waitForPush("ADOPTION_SOURCE_RESULT");650651 expect(mocks.createVault).not.toHaveBeenCalled();652 expect(mocks.rollbackVault).not.toHaveBeenCalled();653 expect(mocks.pushes).toContainEqual({654 type: "ADOPTION_SOURCE_RESULT",655 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },656 });657 });658659 it("the receiver wires requestAdoption, not grantAdoption", async () => {660 661 662 663 664 665 const rootKeyHex = "f0".repeat(32);666 const vaultId = "b".repeat(64);667 const socket = socketWithFrames([668 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },669 {670 type: "ADOPT_GRANT",671 root_key_hex: rootKeyHex,672 vault_id: vaultId,673 license: "PPK-test",674 source: {675 device_id: "phone-source-id",676 device_name: "June's phone",677 device_type: "android",678 long_term_public_key_hex: "99".repeat(32),679 },680 },681 { type: "ADOPT_COMPLETE" },682 ], { closeOnEmpty: true });683 mocks.openSocket.mockResolvedValue(socket);684 mocks.createVault.mockResolvedValue({ vaultId });685686 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");687 Object.defineProperty(globalThis, "chrome", {688 configurable: true,689 value: {690 runtime: {691 async sendMessage(message: { type: string; payload?: unknown }) {692 mocks.pushes.push({ type: message.type, payload: message.payload });693 if (message.type === "ADOPTION_SOURCE_SAS") {694 const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };695 if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {696 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");697 resolveReceiverSasConfirmation(p.id, p.sas, true);698 }699 }700 return { ok: true };701 },702 onMessage: { addListener: () => undefined, removeListener: () => undefined },703 },704 },705 });706 await startRelayAdoptionReceiver("receiver-password");707 await waitForPush("ADOPTION_SOURCE_RESULT");708709 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })710 .mock.calls711 .map(([bytes]) => {712 try {713 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;714 } catch {715 return undefined;716 }717 });718 719 720 expect(sentTypes).toContain("ADOPT_REQUEST");721 expect(sentTypes).not.toContain("ADOPT_GRANT");722 });723724 it("completes the handshake end-to-end: vault created → paired", async () => {725 726 727 728 729 730 const rootKeyHex = "f0".repeat(32);731 732 733 const vaultId = "b".repeat(64);734 const frames = [735 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },736 737 738 {739 type: "ADOPT_GRANT",740 root_key_hex: rootKeyHex,741 vault_id: vaultId,742 license: "PPK-test",743 source: {744 device_id: "phone-source-id",745 device_name: "June's phone",746 device_type: "android",747 long_term_public_key_hex: "99".repeat(32),748 signing_public_key_hex: "aa".repeat(32),749 relay_url: "wss://relay.peachpasswords.com",750 },751 },752 { type: "ADOPT_COMPLETE" },753 ];754 mocks.createVault.mockResolvedValue({ vaultId });755 mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));756757 758 Object.defineProperty(globalThis, "chrome", {759 configurable: true,760 value: {761 runtime: {762 async sendMessage(message: { type: string; payload?: unknown }) {763 mocks.pushes.push({ type: message.type, payload: message.payload });764 if (message.type === "ADOPTION_SOURCE_SAS") {765 const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };766 if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {767 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");768 resolveReceiverSasConfirmation(p.id, p.sas, true);769 }770 }771 return { ok: true };772 },773 onMessage: {774 addListener: () => undefined,775 removeListener: () => undefined,776 },777 },778 },779 });780781 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");782 await startRelayAdoptionReceiver("receiver-password");783784 await waitForPush("ADOPTION_SOURCE_RESULT");785786 787 expect(mocks.pushes).toContainEqual({788 type: "ADOPTION_SOURCE_SAS",789 payload: expect.objectContaining({790 sas: expect.stringMatching(/^\d{8}$/),791 displayOnly: false,792 }),793 });794795 796 797 expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);798799 800 801 expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");802 expect(mocks.isProUser).toHaveBeenCalled();803 expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));804 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({805 deviceId: "phone-source-id",806 }));807 const receiverRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;808 expect(receiverRow.rendezvousSecretHex).toBeUndefined();809 expect(mocks.setPairSecret).toHaveBeenCalledWith(810 "phone-source-id",811 expect.stringMatching(/^[0-9a-f]{64}$/),812 expect.any(Uint8Array),813 );814 expect(mocks.storageSet).toHaveBeenCalledWith(815 "peach_initial_sync_pending_device_id",816 "phone-source-id",817 );818 expect(mocks.rollbackVault).not.toHaveBeenCalled();819820 821 expect(mocks.pushes).toContainEqual(expect.objectContaining({822 type: "ADOPTION_SOURCE_RESULT",823 payload: expect.objectContaining({ status: "complete" }),824 }));825 });826827 it("rolls back and refuses completion when a carried Pro license is not activated", async () => {828 const rootKeyHex = "f0".repeat(32);829 const vaultId = "b".repeat(64);830 mocks.createVault.mockResolvedValue({ vaultId });831 mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });832 mocks.isProUser.mockResolvedValue(false);833 mocks.openSocket.mockResolvedValue(socketWithFrames([834 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },835 {836 type: "ADOPT_GRANT",837 root_key_hex: rootKeyHex,838 vault_id: vaultId,839 license: "PPK-invalid",840 source: {841 device_id: "phone-source-id",842 device_name: "June's phone",843 device_type: "android",844 long_term_public_key_hex: "99".repeat(32),845 },846 },847 ]));848849 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");850 await startRelayAdoptionReceiver("receiver-password");851 await waitForPush("ADOPTION_SOURCE_RESULT");852853 expect(mocks.rollbackVault).toHaveBeenCalledOnce();854 expect(mocks.localIdentity).not.toHaveBeenCalled();855 expect(mocks.upsert).not.toHaveBeenCalled();856 expect(mocks.pushes).toContainEqual(expect.objectContaining({857 type: "ADOPTION_SOURCE_RESULT",858 payload: expect.objectContaining({859 status: "error",860 error: "license_transfer_failed",861 }),862 }));863 });864865 it("rolls back a newly created vault when a later adoption step fails", async () => {866 const rootKeyHex = "f0".repeat(32);867 const vaultId = "b".repeat(64);868 mocks.createVault.mockResolvedValue({ vaultId });869 mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));870 mocks.openSocket.mockResolvedValue(socketWithFrames([871 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },872 {873 type: "ADOPT_GRANT",874 root_key_hex: rootKeyHex,875 vault_id: vaultId,876 license: "PPK-test",877 source: {878 device_id: "phone-source-id",879 device_name: "June's phone",880 device_type: "android",881 long_term_public_key_hex: "99".repeat(32),882 },883 },884 ]));885886 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");887 await startRelayAdoptionReceiver("receiver-password");888 await waitForPush("ADOPTION_SOURCE_RESULT");889890 expect(mocks.rollbackVault).toHaveBeenCalledOnce();891 expect(mocks.upsert).not.toHaveBeenCalled();892 expect(mocks.pushes).toContainEqual(expect.objectContaining({893 type: "ADOPTION_SOURCE_RESULT",894 payload: expect.objectContaining({ status: "error", error: "identity_failed" }),895 }));896 });897898 it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {899 900 901 902 const rootKeyHex = "f0".repeat(32);903 const vaultId = "b".repeat(64);904 mocks.createVault.mockResolvedValue({ vaultId });905 mocks.openSocket.mockResolvedValue(socketWithFrames([906 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },907 {908 type: "ADOPT_GRANT",909 root_key_hex: rootKeyHex,910 vault_id: vaultId,911 license: "PPK-test",912 source: {913 device_id: "old-phone-id",914 device_name: "Old phone",915 device_type: "android",916 long_term_public_key_hex: "99".repeat(32),917 },918 919 },920 { type: "ADOPT_COMPLETE" },921 ], { closeOnEmpty: true }));922923 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");924 await startRelayAdoptionReceiver("receiver-password");925 await waitForPush("ADOPTION_SOURCE_RESULT");926927 928 expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });929 930 expect(mocks.pushes).toContainEqual(expect.objectContaining({931 type: "ADOPTION_SOURCE_RESULT",932 payload: expect.objectContaining({ status: "complete" }),933 }));934 935 expect(mocks.storageSet).toHaveBeenCalledWith(936 "peach_initial_sync_pending_device_id",937 "old-phone-id",938 );939 });940941 it("applies the source's device manifest after the snapshot, then acks", async () => {942 943 944 945 946 947 const rootKeyHex = "f0".repeat(32);948 const vaultId = "b".repeat(64);949 const longTermPubkeyHex = "99".repeat(32);950 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");951 const { deriveSyncSigningKeypair, pubkeyToHex } = await import(952 "../shared/sync-identity"953 );954 const issuerKp = await deriveSyncSigningKeypair(955 new Uint8Array(32).fill(0x55),956 "phone-source-id",957 );958 const manifest = await buildSignedDeviceManifest(959 "phone-source-id",960 [961 {962 deviceId: "peer-b",963 deviceName: "Pool B",964 deviceType: "extension",965 longTermPublicKeyHex: "11".repeat(32),966 signingPublicKeyHex: "22".repeat(32),967 wsPort: 49152,968 relayUrl: "wss://relay.peachpasswords.com",969 pairedAt: 1,970 },971 ],972 issuerKp.privateKey,973 );974 const socket = socketWithFrames([975 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },976 {977 type: "ADOPT_GRANT",978 root_key_hex: rootKeyHex,979 vault_id: vaultId,980 license: "PPK-test",981 snapshot_capable: true,982 source: {983 device_id: "phone-source-id",984 device_name: "June's phone",985 device_type: "android",986 long_term_public_key_hex: longTermPubkeyHex,987 signing_public_key_hex: pubkeyToHex(issuerKp.publicKeyRaw),988 relay_url: "wss://relay.peachpasswords.com",989 },990 },991 992 993 { type: "ADOPT_COMPLETE" },994 995 996 { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },997 { type: "ADOPT_DEVICE_MANIFEST", manifest },998 ], { closeOnEmpty: true });999 mocks.createVault.mockResolvedValue({ vaultId });1000 mocks.openSocket.mockResolvedValue(socket);1001 mocks.__applyIncomingDeviceManifest.mockClear();10021003 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1004 await startRelayAdoptionReceiver("receiver-password");1005 await waitForPush("ADOPTION_SOURCE_RESULT");10061007 expect(mocks.__applyIncomingDeviceManifest).toHaveBeenCalledTimes(1);1008 const [forwarded, pubkey] = mocks.__applyIncomingDeviceManifest.mock.calls[0];1009 expect(forwarded).toEqual(manifest);1010 expect(pubkey).toBeInstanceOf(Uint8Array);1011 expect(pubkey.length).toBe(32);1012 1013 1014 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1015 .mock.calls1016 .map(([bytes]) => {1017 try {1018 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1019 } catch {1020 return undefined;1021 }1022 });1023 expect(sentTypes).toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1024 1025 expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST");1026 });10271028 it("does not call applyIncomingDeviceManifest when the source omits the manifest frame", async () => {1029 1030 1031 1032 1033 const rootKeyHex = "f0".repeat(32);1034 const vaultId = "b".repeat(64);1035 const longTermPubkeyHex = "99".repeat(32);1036 const socket = socketWithFrames([1037 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1038 {1039 type: "ADOPT_GRANT",1040 root_key_hex: rootKeyHex,1041 vault_id: vaultId,1042 license: "PPK-test",1043 snapshot_capable: true,1044 source: {1045 device_id: "phone-source-id",1046 device_name: "June's phone",1047 device_type: "android",1048 long_term_public_key_hex: longTermPubkeyHex,1049 signing_public_key_hex: "aa".repeat(32),1050 relay_url: "wss://relay.peachpasswords.com",1051 },1052 },1053 { type: "ADOPT_COMPLETE" },1054 1055 1056 { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1057 ], { closeOnEmpty: true });1058 mocks.createVault.mockResolvedValue({ vaultId });1059 mocks.openSocket.mockResolvedValue(socket);1060 mocks.__applyIncomingDeviceManifest.mockClear();10611062 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1063 await startRelayAdoptionReceiver("receiver-password");1064 await waitForPush("ADOPTION_SOURCE_RESULT");10651066 expect(mocks.__applyIncomingDeviceManifest).not.toHaveBeenCalled();1067 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1068 .mock.calls1069 .map(([bytes]) => {1070 try {1071 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1072 } catch {1073 return undefined;1074 }1075 });1076 expect(sentTypes).toContain("ADOPT_SNAPSHOT_APPLIED");1077 expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1078 });1079});10801081function decodeInvitation(encoded: string): { vault?: string } | null {1082 1083 1084 const at = encoded.indexOf(":");1085 if (at < 0) return null;1086 const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");1087 try {1088 const json = Buffer.from(b64, "base64").toString("utf8");1089 return JSON.parse(json) as { vault?: string };1090 } catch {1091 return null;1092 }1093}