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 releaseAutoLock: vi.fn(),23 deferAutoLock: vi.fn(),24 sasDecisions: [] as Array<{ id: string; ok: boolean }>,25}));2627vi.mock("../shared/pairing-dial", () => ({28 openRelayPairingSocket: mocks.openSocket,29}));3031vi.mock("../shared/noise-js", () => ({32 noiseNKResponder: vi.fn(async () => ({33 handshakeHash: new Uint8Array(32).fill(7),34 encrypt: (bytes: Uint8Array) => bytes,35 decrypt: (bytes: Uint8Array) => bytes,36 close: vi.fn(),37 })),38}));3940vi.mock("../shared/rendezvous", async (importOriginal) => {41 const actual = await importOriginal<typeof import("../shared/rendezvous")>();42 return {43 ...actual,44 generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",45 };46});474849vi.mock("./device-identity", () => ({50 getLocalPairingIdentity: async () => ({51 identity: {52 deviceId: "test-device",53 deviceName: "Test",54 deviceType: "extension",55 longTermPublicKeyHex: "aa".repeat(32),56 vaultId: "bb".repeat(32),57 },58 privateKey: new Uint8Array(32).fill(7),59 publicKey: new Uint8Array(32).fill(8),60 }),61 getDeviceName: mocks.getDeviceName,62 buildLocalIdentity: mocks.localIdentity,63}));6465vi.mock("./sync-settings", async (importOriginal) => ({66 ...(await importOriginal<typeof import("./sync-settings")>()),67 getSyncSettings: async () => ({68 mode: "public" as const,69 relayUrl: "wss://relay.peachpasswords.com",70 }),71}));7273vi.mock("./license", () => ({74 isProUser: mocks.isProUser,75 getLicenseKey: mocks.getLicenseKey,76 activateLicense: mocks.activateLicense,77}));7879vi.mock("./vault", () => ({80 isUnlocked: vi.fn(() => true),81 getRootKeyHex: mocks.getRootKeyHex,82 createVaultFromRootKey: mocks.createVault,83 rollbackAdoptedVault: mocks.rollbackVault,84 exportDuressSlotForAdoption: vi.fn(async () => null),85 installAdoptedDuressSlot: vi.fn(async () => undefined),86}));8788vi.mock("../shared/sync-identity", async (importOriginal) => {89 90 91 92 93 const actual = await importOriginal<typeof import("../shared/sync-identity")>();94 return {95 ...actual,96 computeVaultId: vi.fn(async () => "b".repeat(64)),97 };98});99100vi.mock("../shared/storage", () => ({101 storageSet: mocks.storageSet,102}));103104vi.mock("./paired-devices", () => ({105 upsertPairedDevice: mocks.upsert,106}));107108vi.mock("./pair-secrets", () => ({109 setPairSecret: mocks.setPairSecret,110 isValidPairSecret: (value: unknown): value is string =>111 typeof value === "string" && /^[0-9a-f]{64}$/.test(value),112}));113114vi.mock("./sync-trigger", () => ({115 triggerSyncIfReachable: mocks.triggerSync,116}));117118vi.mock("./alarms", () => ({119 deferAutoLockForOperation: mocks.deferAutoLock,120}));121122vi.mock("./sync-vault-adapter", () => ({123 createSyncVaultAdapter: mocks.createSyncAdapter,124}));125126import {127 cancelRelayAdoptionSource,128 startRelayAdoptionSource,129} from "./relay-adoption-bg";130131const encode = (value: unknown) =>132 new TextEncoder().encode(JSON.stringify(value));133134function socketWithFrames(135 frames: unknown[],136 options: { closeOnEmpty?: boolean } = {},137) {138 const queue = frames.map((frame) => {139 const encoded = encode(frame);140 141 return (frame as { type?: string }).type === "ADOPT_SNAPSHOT"142 ? padSyncFrame(encoded)143 : encoded;144 });145 return {146 send: vi.fn(),147 receive: vi.fn(async () => {148 const next = queue.shift();149 if (!next) {150 151 152 153 if (options.closeOnEmpty) throw new Error("socket_closed");154 return new Promise<Uint8Array>(() => undefined);155 }156 return next;157 }),158 close: vi.fn(),159 closedBeforeFrame: vi.fn(() => false),160 };161}162163async function waitForPush(type: string): Promise<void> {164 await vi.waitFor(() => {165 expect(mocks.pushes.some((message) => message.type === type)).toBe(true);166 });167}168169beforeEach(() => {170 mocks.pushes.length = 0;171 mocks.sasDecisions.length = 0;172 mocks.openSocket.mockReset();173 mocks.getLocalIdentity.mockReset();174 mocks.getRootKeyHex.mockReset();175 mocks.upsert.mockReset();176 mocks.setPairSecret.mockReset();177 mocks.triggerSync.mockReset();178 mocks.triggerSync.mockResolvedValue(undefined);179 mocks.releaseAutoLock.mockReset();180 mocks.deferAutoLock.mockReset();181 mocks.deferAutoLock.mockReturnValue(mocks.releaseAutoLock);182 mocks.isProUser.mockReset();183 mocks.isProUser.mockResolvedValue(true);184 mocks.getLicenseKey.mockReset();185 mocks.getLicenseKey.mockResolvedValue("PPK-source");186 mocks.getLocalIdentity.mockResolvedValue({187 identity: {188 deviceId: "browser-source",189 deviceName: "June's Browser",190 deviceType: "extension",191 longTermPublicKeyHex: "a".repeat(64),192 vaultId: "b".repeat(64),193 },194 privateKey: new Uint8Array(32).fill(1),195 publicKey: new Uint8Array(32).fill(2),196 });197 mocks.getRootKeyHex.mockReturnValue("c".repeat(64));198 mocks.upsert.mockResolvedValue(undefined);199 mocks.setPairSecret.mockResolvedValue(undefined);200 const applyIncomingDeviceManifest = vi.fn(async () => undefined);201 mocks.__applyIncomingDeviceManifest = applyIncomingDeviceManifest;202 mocks.createSyncAdapter.mockReturnValue({203 buildEntryManifest: vi.fn(async () => ({})),204 getEncryptedEntries: vi.fn(async () => ({})),205 buildTombstoneManifest: vi.fn(async () => ({})),206 applyEntries: vi.fn(async () => undefined),207 applyTombstones: vi.fn(async () => undefined),208 buildOutgoingDeviceManifest: vi.fn(async () => null),209 applyIncomingDeviceManifest,210 });211 Object.defineProperty(globalThis, "chrome", {212 configurable: true,213 value: {214 runtime: {215 async sendMessage(message: { type: string; payload: unknown }) {216 mocks.pushes.push(message);217 218 219 220 221 if (message.type === "ADOPTION_SOURCE_SAS") {222 const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };223 if (typeof p?.id === "string" && typeof p.sas === "string") {224 if (p.displayOnly === false) {225 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");226 resolveReceiverSasConfirmation(p.id, p.sas, true);227 } else {228 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");229 mocks.sasDecisions.push({ id: p.id, ok: true });230 resolveSourceSasConfirmation(p.id, true);231 }232 }233 }234 return Promise.resolve();235 },236 },237 },238 });239});240241describe("relay adoption source background boundary", () => {242 it("shows the SAS and waits for the popup's single confirmation", async () => {243 const socket = socketWithFrames([244 {245 type: "PEACH_HELLO",246 token: "AAECAwQFBgcICQoLDA0ODw",247 },248 {249 type: "ADOPT_REQUEST",250 device_id: "request-only-label",251 device_name: "June's phone",252 device_type: "android",253 },254 { type: "ADOPT_CONFIRM", confirmed: true },255 {256 type: "ADOPT_IDENTITY",257 device_id: "android-receiver",258 device_name: "June's phone",259 device_type: "android",260 long_term_public_key_hex: "d".repeat(64),261 },262 { type: "ADOPT_APPLIED" },263 ]);264 mocks.openSocket.mockResolvedValue(socket);265266 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");267 const sasIds: string[] = [];268 Object.defineProperty(globalThis, "chrome", {269 configurable: true,270 value: {271 runtime: {272 async sendMessage(message: { type: string; payload: unknown }) {273 mocks.pushes.push(message);274 if (message.type === "ADOPTION_SOURCE_SAS") {275 const p = message.payload as { id?: string };276 if (typeof p?.id === "string") {277 278 279 sasIds.push(p.id);280 }281 return Promise.resolve();282 }283 return Promise.resolve();284 },285 },286 },287 });288 void resolveSourceSasConfirmation; 289290 const started = await startRelayAdoptionSource();291 expect(started.invitation).toMatch(/^peach-pair-v2:/);292 expect(mocks.deferAutoLock).toHaveBeenCalledWith(5 * 60 * 1000);293 await waitForPush("ADOPTION_SOURCE_SAS");294 295 296 await vi.waitFor(() => {297 expect(mocks.sasDecisions.length).toBe(0);298 });299 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();300 expect(mocks.upsert).not.toHaveBeenCalled();301302 303 const id = sasIds[0];304 expect(typeof id).toBe("string");305 resolveSourceSasConfirmation(id, true);306 mocks.sasDecisions.push({ id, ok: true });307308 await waitForPush("ADOPTION_SOURCE_RESULT");309310 await vi.waitFor(() => expect(mocks.releaseAutoLock).toHaveBeenCalled());311312 expect(mocks.getRootKeyHex).toHaveBeenCalled();313 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({314 deviceId: "android-receiver",315 relayUrl: "wss://relay.peachpasswords.com",316 }));317 318 319 const sourceRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;320 expect(sourceRow.rendezvousSecretHex).toBeUndefined();321 expect(mocks.setPairSecret).toHaveBeenCalledWith(322 "android-receiver",323 expect.stringMatching(/^[0-9a-f]{64}$/),324 expect.any(Uint8Array),325 );326 expect(mocks.pushes).toContainEqual({327 type: "ADOPTION_SOURCE_RESULT",328 payload: { status: "complete" },329 });330 expect(mocks.triggerSync).toHaveBeenCalledWith({331 reason: "manual",332 force: true,333 });334 const sentTypes = socket.send.mock.calls335 .map(([bytes]) => {336 try {337 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;338 } catch {339 return undefined;340 }341 });342 expect(sentTypes).toContain("ADOPT_COMPLETE");343 });344345 it("fails adoption without sending snapshot frames when duress is oversized", async () => {346 const socket = socketWithFrames([347 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },348 {349 type: "ADOPT_REQUEST",350 device_id: "request-only-label",351 device_name: "June's phone",352 device_type: "android",353 snapshot_capable: true,354 },355 { type: "ADOPT_CONFIRM", confirmed: true },356 {357 type: "ADOPT_IDENTITY",358 device_id: "android-receiver",359 device_name: "June's phone",360 device_type: "android",361 long_term_public_key_hex: "d".repeat(64),362 },363 { type: "ADOPT_APPLIED" },364 ]);365 mocks.openSocket.mockResolvedValue(socket);366 const { exportDuressSlotForAdoption } = await import("./vault");367 vi.mocked(exportDuressSlotForAdoption).mockResolvedValueOnce(368 "x".repeat(768 * 1024),369 );370371 await startRelayAdoptionSource();372 await waitForPush("ADOPTION_SOURCE_RESULT");373374 expect(mocks.pushes).toContainEqual({375 type: "ADOPTION_SOURCE_RESULT",376 payload: {377 status: "error",378 error: "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE",379 },380 });381 expect(mocks.pushes).not.toContainEqual(expect.objectContaining({382 type: "ADOPTION_SOURCE_RESULT",383 payload: expect.objectContaining({ status: "complete" }),384 }));385 const sentTypes = socket.send.mock.calls.map(([bytes]) =>386 JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,387 );388 expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");389 expect(mocks.triggerSync).not.toHaveBeenCalled();390 });391392 it("sends no snapshot frames and completes via sync fallback when the vault exceeds the inline budget", async () => {393 394 395 396 const socket = socketWithFrames([397 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },398 {399 type: "ADOPT_REQUEST",400 device_id: "request-only-label",401 device_name: "June's phone",402 device_type: "android",403 snapshot_capable: true,404 },405 { type: "ADOPT_CONFIRM", confirmed: true },406 {407 type: "ADOPT_IDENTITY",408 device_id: "android-receiver",409 device_name: "June's phone",410 device_type: "android",411 long_term_public_key_hex: "d".repeat(64),412 },413 { type: "ADOPT_APPLIED" },414 415 416 417 { type: "ADOPT_SNAPSHOT_APPLIED", entries_received: 0, tombstones_received: 0 },418 ]);419 mocks.openSocket.mockResolvedValue(socket);420 421 const envelope = "c".repeat(690_000);422 const entries: Record<string, string> = {};423 for (let i = 0; i < 16; i++) entries[`big-${i}`] = envelope;424 mocks.createSyncAdapter.mockReturnValue({425 buildEntryManifest: vi.fn(async () => Object.fromEntries(426 Object.keys(entries).map((id) => [id, 1]),427 )),428 getEncryptedEntries: vi.fn(async () => entries),429 buildTombstoneManifest: vi.fn(async () => ({})),430 applyEntries: vi.fn(async () => undefined),431 applyTombstones: vi.fn(async () => undefined),432 buildOutgoingDeviceManifest: vi.fn(async () => null),433 applyIncomingDeviceManifest: vi.fn(async () => undefined),434 });435436 await startRelayAdoptionSource();437 await waitForPush("ADOPTION_SOURCE_RESULT");438439 expect(mocks.pushes).toContainEqual({440 type: "ADOPTION_SOURCE_RESULT",441 payload: expect.objectContaining({ status: "complete" }),442 });443 const sentTypes = socket.send.mock.calls.map(([bytes]) =>444 JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,445 );446 expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");447 448 expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });449 });450451 it("fails closed when the popup rejects the SAS", async () => {452 const socket = socketWithFrames([453 {454 type: "PEACH_HELLO",455 token: "AAECAwQFBgcICQoLDA0ODw",456 },457 {458 type: "ADOPT_REQUEST",459 device_id: "request-only-label",460 device_name: "New phone",461 device_type: "android",462 },463 { type: "ADOPT_CONFIRM", confirmed: false },464 ]);465 mocks.openSocket.mockResolvedValue(socket);466467 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");468 let pendingId: string | undefined;469 Object.defineProperty(globalThis, "chrome", {470 configurable: true,471 value: {472 runtime: {473 sendMessage(message: { type: string; payload: unknown }) {474 mocks.pushes.push(message);475 if (message.type === "ADOPTION_SOURCE_SAS") {476 const p = message.payload as { id?: string };477 if (typeof p?.id === "string") pendingId = p.id;478 }479 return Promise.resolve();480 },481 },482 },483 });484485 await startRelayAdoptionSource();486 await waitForPush("ADOPTION_SOURCE_SAS");487 expect(typeof pendingId).toBe("string");488 resolveSourceSasConfirmation(pendingId!, false);489 mocks.sasDecisions.push({ id: pendingId!, ok: false });490491 await waitForPush("ADOPTION_SOURCE_RESULT");492493 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();494 expect(mocks.upsert).not.toHaveBeenCalled();495 expect(mocks.pushes).toContainEqual({496 type: "ADOPTION_SOURCE_RESULT",497 payload: { status: "error", error: "adoption_rejected" },498 });499 });500501 it("rejects before reading the root key when neither peer has Pro", async () => {502 const socket = socketWithFrames([503 {504 type: "PEACH_HELLO",505 token: "AAECAwQFBgcICQoLDA0ODw",506 },507 {508 type: "ADOPT_REQUEST",509 device_id: "free-receiver",510 device_name: "Free phone",511 device_type: "android",512 },513 { type: "ADOPT_CONFIRM", confirmed: true },514 ]);515 mocks.isProUser.mockResolvedValue(false);516 mocks.getLicenseKey.mockResolvedValue(undefined);517 mocks.openSocket.mockResolvedValue(socket);518519 await startRelayAdoptionSource();520 await waitForPush("ADOPTION_SOURCE_RESULT");521522 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();523 expect(mocks.upsert).not.toHaveBeenCalled();524 expect(mocks.pushes).toContainEqual({525 type: "ADOPTION_SOURCE_RESULT",526 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },527 });528 const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {529 try {530 return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];531 } catch {532 return [];533 }534 });535 expect(sentFrames).toContainEqual({536 type: "ADOPT_REJECT",537 reason: "ADOPT_PRO_REQUIRED",538 });539 });540541 it("fails closed when the user never answers the SAS", async () => {542 543 544 545 546 const socket = socketWithFrames([]);547 mocks.openSocket.mockResolvedValue(socket);548549 Object.defineProperty(globalThis, "chrome", {550 configurable: true,551 value: {552 runtime: {553 sendMessage(message: { type: string; payload: unknown }) {554 mocks.pushes.push(message);555 return Promise.resolve();556 },557 },558 },559 });560561 await startRelayAdoptionSource();562 cancelRelayAdoptionSource();563564 expect(socket.close).toHaveBeenCalled();565 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();566 expect(mocks.upsert).not.toHaveBeenCalled();567 });568569 it("cancelling the source screen closes the room without reading the root", async () => {570 const socket = socketWithFrames([]);571 mocks.openSocket.mockResolvedValue(socket);572573 await startRelayAdoptionSource();574 cancelRelayAdoptionSource();575576 expect(socket.close).toHaveBeenCalled();577 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();578 expect(mocks.upsert).not.toHaveBeenCalled();579 });580});581582describe("relay adoption receiver background", () => {583 beforeEach(async () => {584 585 586 587 588 const { cancelRelayAdoptionSource } = await import("./relay-adoption-bg");589 cancelRelayAdoptionSource();590 mocks.pushes.length = 0;591 mocks.openSocket.mockReset();592 mocks.getDeviceName.mockReset();593 mocks.getDeviceName.mockResolvedValue("Receiver Browser");594 mocks.createVault.mockReset();595 mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });596 mocks.activateLicense.mockReset();597 mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });598 mocks.isProUser.mockReset();599 mocks.isProUser.mockResolvedValue(true);600 mocks.getLicenseKey.mockReset();601 mocks.getLicenseKey.mockResolvedValue(undefined);602 mocks.localIdentity.mockReset();603 mocks.localIdentity.mockResolvedValue({604 deviceId: "receiver-id",605 deviceName: "Receiver Browser",606 deviceType: "extension",607 longTermPublicKeyHex: "11".repeat(32),608 signingPublicKeyHex: "22".repeat(32),609 });610 mocks.rollbackVault.mockReset();611 mocks.rollbackVault.mockResolvedValue(undefined);612 mocks.storageSet.mockReset();613 mocks.storageSet.mockResolvedValue(undefined);614 mocks.upsert.mockReset();615 mocks.upsert.mockResolvedValue(undefined);616 mocks.setPairSecret.mockReset();617 mocks.setPairSecret.mockResolvedValue(undefined);618 mocks.triggerSync.mockReset();619 mocks.triggerSync.mockResolvedValue(undefined);620 mocks.openSocket.mockResolvedValue(socketWithFrames([]));621 Object.defineProperty(globalThis, "chrome", {622 configurable: true,623 value: {624 runtime: {625 async sendMessage(message: { type: string; payload?: unknown }) {626 mocks.pushes.push({ type: message.type, payload: message.payload });627 if (message.type === "ADOPTION_SOURCE_SAS") {628 const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };629 if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {630 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");631 resolveReceiverSasConfirmation(p.id, p.sas, true);632 }633 }634 return { ok: true };635 },636 onMessage: {637 addListener() { },638 removeListener() { },639 },640 },641 },642 });643 });644645 it("opens a relay socket and returns an empty invitation", async () => {646 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");647 const started = await startRelayAdoptionReceiver("receiver-password");648 expect(started.invitation).toMatch(/^peach-pair-v2:/);649 650 const decoded = decodeInvitation(started.invitation);651 expect(decoded?.vault).toBe("empty");652 expect(mocks.openSocket).toHaveBeenCalled();653 });654655 it("rejects an empty password", async () => {656 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");657 await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");658 });659660 it("surfaces the Pro-required rejection without creating a vault", async () => {661 mocks.openSocket.mockResolvedValue(socketWithFrames([662 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },663 { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },664 ]));665666 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");667 await startRelayAdoptionReceiver("receiver-password");668 await waitForPush("ADOPTION_SOURCE_RESULT");669670 expect(mocks.createVault).not.toHaveBeenCalled();671 expect(mocks.rollbackVault).not.toHaveBeenCalled();672 expect(mocks.pushes).toContainEqual({673 type: "ADOPTION_SOURCE_RESULT",674 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },675 });676 });677678 it("the receiver wires requestAdoption, not grantAdoption", async () => {679 680 681 682 683 684 const rootKeyHex = "f0".repeat(32);685 const vaultId = "b".repeat(64);686 const socket = socketWithFrames([687 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },688 {689 type: "ADOPT_GRANT",690 root_key_hex: rootKeyHex,691 vault_id: vaultId,692 license: "PPK-test",693 source: {694 device_id: "phone-source-id",695 device_name: "June's phone",696 device_type: "android",697 long_term_public_key_hex: "99".repeat(32),698 },699 },700 { type: "ADOPT_COMPLETE" },701 ], { closeOnEmpty: true });702 mocks.openSocket.mockResolvedValue(socket);703 mocks.createVault.mockResolvedValue({ vaultId });704705 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");706 Object.defineProperty(globalThis, "chrome", {707 configurable: true,708 value: {709 runtime: {710 async sendMessage(message: { type: string; payload?: unknown }) {711 mocks.pushes.push({ type: message.type, payload: message.payload });712 if (message.type === "ADOPTION_SOURCE_SAS") {713 const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };714 if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {715 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");716 resolveReceiverSasConfirmation(p.id, p.sas, true);717 }718 }719 return { ok: true };720 },721 onMessage: { addListener: () => undefined, removeListener: () => undefined },722 },723 },724 });725 await startRelayAdoptionReceiver("receiver-password");726 await waitForPush("ADOPTION_SOURCE_RESULT");727728 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })729 .mock.calls730 .map(([bytes]) => {731 try {732 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;733 } catch {734 return undefined;735 }736 });737 738 739 expect(sentTypes).toContain("ADOPT_REQUEST");740 expect(sentTypes).not.toContain("ADOPT_GRANT");741 });742743 it("completes the handshake end-to-end: vault created → paired", async () => {744 745 746 747 748 749 const rootKeyHex = "f0".repeat(32);750 751 752 const vaultId = "b".repeat(64);753 const frames = [754 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },755 756 757 {758 type: "ADOPT_GRANT",759 root_key_hex: rootKeyHex,760 vault_id: vaultId,761 license: "PPK-test",762 source: {763 device_id: "phone-source-id",764 device_name: "June's phone",765 device_type: "android",766 long_term_public_key_hex: "99".repeat(32),767 signing_public_key_hex: "aa".repeat(32),768 relay_url: "wss://relay.peachpasswords.com",769 },770 },771 { type: "ADOPT_COMPLETE" },772 ];773 mocks.createVault.mockResolvedValue({ vaultId });774 mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));775776 777 Object.defineProperty(globalThis, "chrome", {778 configurable: true,779 value: {780 runtime: {781 async sendMessage(message: { type: string; payload?: unknown }) {782 mocks.pushes.push({ type: message.type, payload: message.payload });783 if (message.type === "ADOPTION_SOURCE_SAS") {784 const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };785 if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {786 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");787 resolveReceiverSasConfirmation(p.id, p.sas, true);788 }789 }790 return { ok: true };791 },792 onMessage: {793 addListener: () => undefined,794 removeListener: () => undefined,795 },796 },797 },798 });799800 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");801 await startRelayAdoptionReceiver("receiver-password");802803 await waitForPush("ADOPTION_SOURCE_RESULT");804805 806 expect(mocks.pushes).toContainEqual({807 type: "ADOPTION_SOURCE_SAS",808 payload: expect.objectContaining({809 sas: expect.stringMatching(/^\d{8}$/),810 displayOnly: false,811 }),812 });813814 815 816 expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);817818 819 820 expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");821 expect(mocks.isProUser).toHaveBeenCalled();822 expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));823 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({824 deviceId: "phone-source-id",825 }));826 const receiverRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;827 expect(receiverRow.rendezvousSecretHex).toBeUndefined();828 expect(mocks.setPairSecret).toHaveBeenCalledWith(829 "phone-source-id",830 expect.stringMatching(/^[0-9a-f]{64}$/),831 expect.any(Uint8Array),832 );833 expect(mocks.storageSet).toHaveBeenCalledWith(834 "peach_initial_sync_pending_device_id",835 "phone-source-id",836 );837 expect(mocks.rollbackVault).not.toHaveBeenCalled();838839 840 expect(mocks.pushes).toContainEqual({841 type: "ADOPTION_SOURCE_RESULT",842 payload: { status: "complete" },843 });844 });845846 it("rolls back and refuses completion when a carried Pro license is not activated", async () => {847 const rootKeyHex = "f0".repeat(32);848 const vaultId = "b".repeat(64);849 mocks.createVault.mockResolvedValue({ vaultId });850 mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });851 mocks.isProUser.mockResolvedValue(false);852 mocks.openSocket.mockResolvedValue(socketWithFrames([853 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },854 {855 type: "ADOPT_GRANT",856 root_key_hex: rootKeyHex,857 vault_id: vaultId,858 license: "PPK-invalid",859 source: {860 device_id: "phone-source-id",861 device_name: "June's phone",862 device_type: "android",863 long_term_public_key_hex: "99".repeat(32),864 },865 },866 ]));867868 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");869 await startRelayAdoptionReceiver("receiver-password");870 await waitForPush("ADOPTION_SOURCE_RESULT");871872 expect(mocks.rollbackVault).toHaveBeenCalledOnce();873 expect(mocks.localIdentity).not.toHaveBeenCalled();874 expect(mocks.upsert).not.toHaveBeenCalled();875 expect(mocks.pushes).toContainEqual(expect.objectContaining({876 type: "ADOPTION_SOURCE_RESULT",877 payload: expect.objectContaining({878 status: "error",879 error: "license_transfer_failed",880 }),881 }));882 });883884 it("rolls back a newly created vault when a later adoption step fails", async () => {885 const rootKeyHex = "f0".repeat(32);886 const vaultId = "b".repeat(64);887 mocks.createVault.mockResolvedValue({ vaultId });888 mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));889 mocks.openSocket.mockResolvedValue(socketWithFrames([890 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },891 {892 type: "ADOPT_GRANT",893 root_key_hex: rootKeyHex,894 vault_id: vaultId,895 license: "PPK-test",896 source: {897 device_id: "phone-source-id",898 device_name: "June's phone",899 device_type: "android",900 long_term_public_key_hex: "99".repeat(32),901 },902 },903 ]));904905 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");906 await startRelayAdoptionReceiver("receiver-password");907 await waitForPush("ADOPTION_SOURCE_RESULT");908909 expect(mocks.rollbackVault).toHaveBeenCalledOnce();910 expect(mocks.upsert).not.toHaveBeenCalled();911 expect(mocks.pushes).toContainEqual(expect.objectContaining({912 type: "ADOPTION_SOURCE_RESULT",913 payload: expect.objectContaining({ status: "error", error: "identity_failed" }),914 }));915 });916917 it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {918 919 920 921 const rootKeyHex = "f0".repeat(32);922 const vaultId = "b".repeat(64);923 mocks.createVault.mockResolvedValue({ vaultId });924 mocks.openSocket.mockResolvedValue(socketWithFrames([925 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },926 {927 type: "ADOPT_GRANT",928 root_key_hex: rootKeyHex,929 vault_id: vaultId,930 license: "PPK-test",931 source: {932 device_id: "old-phone-id",933 device_name: "Old phone",934 device_type: "android",935 long_term_public_key_hex: "99".repeat(32),936 },937 938 },939 { type: "ADOPT_COMPLETE" },940 ], { closeOnEmpty: true }));941942 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");943 await startRelayAdoptionReceiver("receiver-password");944 await waitForPush("ADOPTION_SOURCE_RESULT");945946 947 expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });948 949 expect(mocks.pushes).toContainEqual(expect.objectContaining({950 type: "ADOPTION_SOURCE_RESULT",951 payload: expect.objectContaining({ status: "complete" }),952 }));953 954 expect(mocks.storageSet).toHaveBeenCalledWith(955 "peach_initial_sync_pending_device_id",956 "old-phone-id",957 );958 });959960 it("applies the source's device manifest after the snapshot, then acks", async () => {961 962 963 964 965 966 const rootKeyHex = "f0".repeat(32);967 const vaultId = "b".repeat(64);968 const longTermPubkeyHex = "99".repeat(32);969 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");970 const { deriveSyncSigningKeypair, pubkeyToHex } = await import(971 "../shared/sync-identity"972 );973 const issuerKp = await deriveSyncSigningKeypair(974 new Uint8Array(32).fill(0x55),975 "phone-source-id",976 );977 const manifest = await buildSignedDeviceManifest(978 "phone-source-id",979 [980 {981 deviceId: "peer-b",982 deviceName: "Pool B",983 deviceType: "extension",984 longTermPublicKeyHex: "11".repeat(32),985 signingPublicKeyHex: "22".repeat(32),986 wsPort: 49152,987 relayUrl: "wss://relay.peachpasswords.com",988 pairedAt: 1,989 },990 ],991 issuerKp.privateKey,992 );993 const socket = socketWithFrames([994 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },995 {996 type: "ADOPT_GRANT",997 root_key_hex: rootKeyHex,998 vault_id: vaultId,999 license: "PPK-test",1000 snapshot_capable: true,1001 source: {1002 device_id: "phone-source-id",1003 device_name: "June's phone",1004 device_type: "android",1005 long_term_public_key_hex: longTermPubkeyHex,1006 signing_public_key_hex: pubkeyToHex(issuerKp.publicKeyRaw),1007 relay_url: "wss://relay.peachpasswords.com",1008 },1009 },1010 1011 1012 { type: "ADOPT_COMPLETE" },1013 1014 1015 { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1016 { type: "ADOPT_DEVICE_MANIFEST", manifest },1017 ], { closeOnEmpty: true });1018 mocks.createVault.mockResolvedValue({ vaultId });1019 mocks.openSocket.mockResolvedValue(socket);1020 mocks.__applyIncomingDeviceManifest.mockClear();10211022 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1023 await startRelayAdoptionReceiver("receiver-password");1024 await waitForPush("ADOPTION_SOURCE_RESULT");10251026 expect(mocks.__applyIncomingDeviceManifest).toHaveBeenCalledTimes(1);1027 const [forwarded, pubkey] = mocks.__applyIncomingDeviceManifest.mock.calls[0];1028 expect(forwarded).toEqual(manifest);1029 expect(pubkey).toBeInstanceOf(Uint8Array);1030 expect(pubkey.length).toBe(32);1031 1032 1033 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1034 .mock.calls1035 .map(([bytes]) => {1036 try {1037 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1038 } catch {1039 return undefined;1040 }1041 });1042 expect(sentTypes).toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1043 1044 expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST");1045 });10461047 it("does not call applyIncomingDeviceManifest when the source omits the manifest frame", async () => {1048 1049 1050 1051 1052 const rootKeyHex = "f0".repeat(32);1053 const vaultId = "b".repeat(64);1054 const longTermPubkeyHex = "99".repeat(32);1055 const socket = socketWithFrames([1056 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1057 {1058 type: "ADOPT_GRANT",1059 root_key_hex: rootKeyHex,1060 vault_id: vaultId,1061 license: "PPK-test",1062 snapshot_capable: true,1063 source: {1064 device_id: "phone-source-id",1065 device_name: "June's phone",1066 device_type: "android",1067 long_term_public_key_hex: longTermPubkeyHex,1068 signing_public_key_hex: "aa".repeat(32),1069 relay_url: "wss://relay.peachpasswords.com",1070 },1071 },1072 { type: "ADOPT_COMPLETE" },1073 1074 1075 { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1076 ], { closeOnEmpty: true });1077 mocks.createVault.mockResolvedValue({ vaultId });1078 mocks.openSocket.mockResolvedValue(socket);1079 mocks.__applyIncomingDeviceManifest.mockClear();10801081 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1082 await startRelayAdoptionReceiver("receiver-password");1083 await waitForPush("ADOPTION_SOURCE_RESULT");10841085 expect(mocks.__applyIncomingDeviceManifest).not.toHaveBeenCalled();1086 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1087 .mock.calls1088 .map(([bytes]) => {1089 try {1090 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1091 } catch {1092 return undefined;1093 }1094 });1095 expect(sentTypes).toContain("ADOPT_SNAPSHOT_APPLIED");1096 expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1097 });1098});10991100function decodeInvitation(encoded: string): { vault?: string } | null {1101 1102 1103 const at = encoded.indexOf(":");1104 if (at < 0) return null;1105 const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");1106 try {1107 const json = Buffer.from(b64, "base64").toString("utf8");1108 return JSON.parse(json) as { vault?: string };1109 } catch {1110 return null;1111 }1112}