1import { beforeEach, describe, expect, it, vi } from "vitest";2import { padSyncFrame } from "../shared/sync-padding";34const mocks = vi.hoisted(() => ({5 openSocket: vi.fn(),6 getLocalIdentity: vi.fn(),7 getDeviceName: vi.fn(),8 getRootKeyHex: vi.fn(),9 upsert: vi.fn(),10 clearRevocation: vi.fn(),11 hasIdentityConflict: vi.fn(),12 rotateDeviceId: vi.fn(),13 setPairSecret: vi.fn(),14 pushes: [] as Array<{ type: string; payload: unknown }>,15 triggerSync: vi.fn(),16 createVault: vi.fn(),17 activateLicense: vi.fn(),18 isProUser: vi.fn(),19 getLicenseKey: vi.fn(),20 localIdentity: vi.fn(),21 rollbackVault: vi.fn(),22 createSyncAdapter: vi.fn(),23 __applyIncomingDeviceManifest: vi.fn(),24 storageSet: vi.fn(),25 releaseAutoLock: vi.fn(),26 deferAutoLock: vi.fn(),27 sasDecisions: [] as Array<{ id: string; ok: boolean }>,28}));2930vi.mock("../shared/pairing-dial", () => ({31 openRelayPairingSocket: mocks.openSocket,32}));3334vi.mock("../shared/noise-js", () => ({35 noiseNKResponder: vi.fn(async () => ({36 handshakeHash: new Uint8Array(32).fill(7),37 encrypt: (bytes: Uint8Array) => bytes,38 decrypt: (bytes: Uint8Array) => bytes,39 close: vi.fn(),40 })),41}));4243vi.mock("../shared/rendezvous", async (importOriginal) => {44 const actual = await importOriginal<typeof import("../shared/rendezvous")>();45 return {46 ...actual,47 generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",48 };49});505152vi.mock("./device-identity", () => ({53 getLocalPairingIdentity: async () => ({54 identity: {55 deviceId: "test-device",56 deviceName: "Test",57 deviceType: "extension",58 longTermPublicKeyHex: "aa".repeat(32),59 vaultId: "bb".repeat(32),60 },61 privateKey: new Uint8Array(32).fill(7),62 publicKey: new Uint8Array(32).fill(8),63 }),64 getDeviceName: mocks.getDeviceName,65 buildLocalIdentity: mocks.localIdentity,66 rotateDeviceId: mocks.rotateDeviceId,67}));6869vi.mock("./sync-settings", async (importOriginal) => ({70 ...(await importOriginal<typeof import("./sync-settings")>()),71 getSyncSettings: async () => ({72 mode: "public" as const,73 relayUrl: "wss://relay.peachpasswords.com",74 }),75}));7677vi.mock("./license", () => ({78 isProUser: mocks.isProUser,79 getLicenseKey: mocks.getLicenseKey,80 activateLicense: mocks.activateLicense,81}));8283vi.mock("./vault", () => ({84 isUnlocked: vi.fn(() => true),85 getRootKeyHex: mocks.getRootKeyHex,86 createVaultFromRootKey: mocks.createVault,87 rollbackAdoptedVault: mocks.rollbackVault,88 exportDuressSlotForAdoption: vi.fn(async () => null),89 installAdoptedDuressSlot: vi.fn(async () => undefined),90}));9192vi.mock("../shared/sync-identity", async (importOriginal) => {93 94 95 96 97 const actual = await importOriginal<typeof import("../shared/sync-identity")>();98 return {99 ...actual,100 computeVaultId: vi.fn(async () => "b".repeat(64)),101 };102});103104vi.mock("../shared/storage", () => ({105 storageSet: mocks.storageSet,106}));107108vi.mock("./paired-devices", () => ({109 upsertPairedDevice: mocks.upsert,110 clearDeviceRevocation: mocks.clearRevocation,111 hasAdoptionIdentityConflict: mocks.hasIdentityConflict,112}));113114vi.mock("./pair-secrets", () => ({115 setPairSecret: mocks.setPairSecret,116 isValidPairSecret: (value: unknown): value is string =>117 typeof value === "string" && /^[0-9a-f]{64}$/.test(value),118}));119120vi.mock("./sync-trigger", () => ({121 triggerSyncIfReachable: mocks.triggerSync,122}));123124vi.mock("./alarms", () => ({125 deferAutoLockForOperation: mocks.deferAutoLock,126}));127128vi.mock("./sync-vault-adapter", () => ({129 createSyncVaultAdapter: mocks.createSyncAdapter,130}));131132import {133 cancelRelayAdoptionSource,134 startRelayAdoptionSource,135} from "./relay-adoption-bg";136137const encode = (value: unknown) =>138 new TextEncoder().encode(JSON.stringify(value));139140function socketWithFrames(141 frames: unknown[],142 options: { closeOnEmpty?: boolean } = {},143) {144 const queue = frames.map((frame) => {145 const encoded = encode(frame);146 147 return (frame as { type?: string }).type === "ADOPT_SNAPSHOT"148 ? padSyncFrame(encoded)149 : encoded;150 });151 return {152 send: vi.fn(),153 receive: vi.fn(async () => {154 const next = queue.shift();155 if (!next) {156 157 158 159 if (options.closeOnEmpty) throw new Error("socket_closed");160 return new Promise<Uint8Array>(() => undefined);161 }162 return next;163 }),164 close: vi.fn(),165 closedBeforeFrame: vi.fn(() => false),166 };167}168169async function waitForPush(type: string): Promise<void> {170 await vi.waitFor(() => {171 expect(mocks.pushes.some((message) => message.type === type)).toBe(true);172 });173}174175beforeEach(() => {176 mocks.pushes.length = 0;177 mocks.sasDecisions.length = 0;178 mocks.openSocket.mockReset();179 mocks.getLocalIdentity.mockReset();180 mocks.getRootKeyHex.mockReset();181 mocks.upsert.mockReset();182 mocks.clearRevocation.mockReset();183 mocks.clearRevocation.mockResolvedValue(undefined);184 mocks.hasIdentityConflict.mockReset();185 mocks.hasIdentityConflict.mockResolvedValue(false);186 mocks.rotateDeviceId.mockReset();187 mocks.rotateDeviceId.mockResolvedValue(undefined);188 mocks.setPairSecret.mockReset();189 mocks.triggerSync.mockReset();190 mocks.triggerSync.mockResolvedValue(undefined);191 mocks.releaseAutoLock.mockReset();192 mocks.deferAutoLock.mockReset();193 mocks.deferAutoLock.mockReturnValue(mocks.releaseAutoLock);194 mocks.isProUser.mockReset();195 mocks.isProUser.mockResolvedValue(true);196 mocks.getLicenseKey.mockReset();197 mocks.getLicenseKey.mockResolvedValue("PPK-source");198 mocks.getLocalIdentity.mockResolvedValue({199 identity: {200 deviceId: "browser-source",201 deviceName: "June's Browser",202 deviceType: "extension",203 longTermPublicKeyHex: "a".repeat(64),204 vaultId: "b".repeat(64),205 },206 privateKey: new Uint8Array(32).fill(1),207 publicKey: new Uint8Array(32).fill(2),208 });209 mocks.getRootKeyHex.mockReturnValue("c".repeat(64));210 mocks.upsert.mockResolvedValue(undefined);211 mocks.setPairSecret.mockResolvedValue(undefined);212 const applyIncomingDeviceManifest = vi.fn(async () => undefined);213 mocks.__applyIncomingDeviceManifest = applyIncomingDeviceManifest;214 mocks.createSyncAdapter.mockReturnValue({215 buildEntryManifest: vi.fn(async () => ({})),216 getEncryptedEntries: vi.fn(async () => ({})),217 buildTombstoneManifest: vi.fn(async () => ({})),218 applyEntries: vi.fn(async () => undefined),219 applyTombstones: vi.fn(async () => undefined),220 buildOutgoingDeviceManifest: vi.fn(async () => null),221 applyIncomingDeviceManifest,222 });223 Object.defineProperty(globalThis, "chrome", {224 configurable: true,225 value: {226 runtime: {227 async sendMessage(message: { type: string; payload: unknown }) {228 mocks.pushes.push(message);229 230 231 232 233 if (message.type === "ADOPTION_SOURCE_SAS") {234 const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };235 if (typeof p?.id === "string" && typeof p.sas === "string") {236 if (p.displayOnly === false) {237 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");238 resolveReceiverSasConfirmation(p.id, p.sas, true);239 } else {240 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");241 mocks.sasDecisions.push({ id: p.id, ok: true });242 resolveSourceSasConfirmation(p.id, true);243 }244 }245 }246 return Promise.resolve();247 },248 },249 },250 });251});252253describe("relay adoption source background boundary", () => {254 it("shows the SAS and waits for the popup's single confirmation", async () => {255 const socket = socketWithFrames([256 {257 type: "PEACH_HELLO",258 token: "AAECAwQFBgcICQoLDA0ODw",259 },260 {261 type: "ADOPT_REQUEST",262 device_id: "request-only-label",263 device_name: "June's phone",264 device_type: "android",265 },266 { type: "ADOPT_CONFIRM", confirmed: true },267 {268 type: "ADOPT_IDENTITY",269 device_id: "android-receiver",270 device_name: "June's phone",271 device_type: "android",272 long_term_public_key_hex: "d".repeat(64),273 },274 { type: "ADOPT_APPLIED" },275 ]);276 mocks.openSocket.mockResolvedValue(socket);277278 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");279 const sasIds: string[] = [];280 Object.defineProperty(globalThis, "chrome", {281 configurable: true,282 value: {283 runtime: {284 async sendMessage(message: { type: string; payload: unknown }) {285 mocks.pushes.push(message);286 if (message.type === "ADOPTION_SOURCE_SAS") {287 const p = message.payload as { id?: string };288 if (typeof p?.id === "string") {289 290 291 sasIds.push(p.id);292 }293 return Promise.resolve();294 }295 return Promise.resolve();296 },297 },298 },299 });300 void resolveSourceSasConfirmation; 301302 const started = await startRelayAdoptionSource();303 expect(started.invitation).toMatch(/^peach-pair-v2:/);304 expect(mocks.deferAutoLock).toHaveBeenCalledWith(5 * 60 * 1000);305 await waitForPush("ADOPTION_SOURCE_SAS");306 307 308 await vi.waitFor(() => {309 expect(mocks.sasDecisions.length).toBe(0);310 });311 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();312 expect(mocks.upsert).not.toHaveBeenCalled();313314 315 const id = sasIds[0];316 expect(typeof id).toBe("string");317 resolveSourceSasConfirmation(id, true);318 mocks.sasDecisions.push({ id, ok: true });319320 await waitForPush("ADOPTION_SOURCE_RESULT");321322 await vi.waitFor(() => expect(mocks.releaseAutoLock).toHaveBeenCalled());323324 expect(mocks.getRootKeyHex).toHaveBeenCalled();325 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({326 deviceId: "android-receiver",327 relayUrl: "wss://relay.peachpasswords.com",328 }));329 330 331 const sourceRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;332 expect(sourceRow.rendezvousSecretHex).toBeUndefined();333 expect(mocks.setPairSecret).toHaveBeenCalledWith(334 "android-receiver",335 expect.stringMatching(/^[0-9a-f]{64}$/),336 expect.any(Uint8Array),337 );338 expect(mocks.pushes).toContainEqual({339 type: "ADOPTION_SOURCE_RESULT",340 payload: { status: "complete" },341 });342 expect(mocks.triggerSync).toHaveBeenCalledWith({343 reason: "manual",344 force: true,345 });346 const sentTypes = socket.send.mock.calls347 .map(([bytes]) => {348 try {349 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;350 } catch {351 return undefined;352 }353 });354 expect(sentTypes).toContain("ADOPT_COMPLETE");355 });356357 it("fails adoption without sending snapshot frames when duress is oversized", async () => {358 const socket = socketWithFrames([359 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },360 {361 type: "ADOPT_REQUEST",362 device_id: "request-only-label",363 device_name: "June's phone",364 device_type: "android",365 snapshot_capable: true,366 },367 { type: "ADOPT_CONFIRM", confirmed: true },368 {369 type: "ADOPT_IDENTITY",370 device_id: "android-receiver",371 device_name: "June's phone",372 device_type: "android",373 long_term_public_key_hex: "d".repeat(64),374 },375 { type: "ADOPT_APPLIED" },376 ]);377 mocks.openSocket.mockResolvedValue(socket);378 const { exportDuressSlotForAdoption } = await import("./vault");379 vi.mocked(exportDuressSlotForAdoption).mockResolvedValueOnce(380 "x".repeat(768 * 1024),381 );382383 await startRelayAdoptionSource();384 await waitForPush("ADOPTION_SOURCE_RESULT");385386 expect(mocks.pushes).toContainEqual({387 type: "ADOPTION_SOURCE_RESULT",388 payload: {389 status: "error",390 error: "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE",391 },392 });393 expect(mocks.pushes).not.toContainEqual(expect.objectContaining({394 type: "ADOPTION_SOURCE_RESULT",395 payload: expect.objectContaining({ status: "complete" }),396 }));397 const sentTypes = socket.send.mock.calls.map(([bytes]) =>398 JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,399 );400 expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");401 expect(mocks.triggerSync).not.toHaveBeenCalled();402 });403404 it("sends no snapshot frames and completes via sync fallback when the vault exceeds the inline budget", async () => {405 406 407 408 const socket = socketWithFrames([409 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },410 {411 type: "ADOPT_REQUEST",412 device_id: "request-only-label",413 device_name: "June's phone",414 device_type: "android",415 snapshot_capable: true,416 },417 { type: "ADOPT_CONFIRM", confirmed: true },418 {419 type: "ADOPT_IDENTITY",420 device_id: "android-receiver",421 device_name: "June's phone",422 device_type: "android",423 long_term_public_key_hex: "d".repeat(64),424 },425 { type: "ADOPT_APPLIED" },426 427 428 429 { type: "ADOPT_SNAPSHOT_APPLIED", entries_received: 0, tombstones_received: 0 },430 ]);431 mocks.openSocket.mockResolvedValue(socket);432 433 const envelope = "c".repeat(690_000);434 const entries: Record<string, string> = {};435 for (let i = 0; i < 16; i++) entries[`big-${i}`] = envelope;436 mocks.createSyncAdapter.mockReturnValue({437 buildEntryManifest: vi.fn(async () => Object.fromEntries(438 Object.keys(entries).map((id) => [id, 1]),439 )),440 getEncryptedEntries: vi.fn(async () => entries),441 buildTombstoneManifest: vi.fn(async () => ({})),442 applyEntries: vi.fn(async () => undefined),443 applyTombstones: vi.fn(async () => undefined),444 buildOutgoingDeviceManifest: vi.fn(async () => null),445 applyIncomingDeviceManifest: vi.fn(async () => undefined),446 });447448 await startRelayAdoptionSource();449 await waitForPush("ADOPTION_SOURCE_RESULT");450451 expect(mocks.pushes).toContainEqual({452 type: "ADOPTION_SOURCE_RESULT",453 payload: expect.objectContaining({ status: "complete" }),454 });455 const sentTypes = socket.send.mock.calls.map(([bytes]) =>456 JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,457 );458 expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");459 460 expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });461 });462463 it("fails closed when the popup rejects the SAS", async () => {464 const socket = socketWithFrames([465 {466 type: "PEACH_HELLO",467 token: "AAECAwQFBgcICQoLDA0ODw",468 },469 {470 type: "ADOPT_REQUEST",471 device_id: "request-only-label",472 device_name: "New phone",473 device_type: "android",474 },475 { type: "ADOPT_CONFIRM", confirmed: false },476 ]);477 mocks.openSocket.mockResolvedValue(socket);478479 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");480 let pendingId: string | undefined;481 Object.defineProperty(globalThis, "chrome", {482 configurable: true,483 value: {484 runtime: {485 sendMessage(message: { type: string; payload: unknown }) {486 mocks.pushes.push(message);487 if (message.type === "ADOPTION_SOURCE_SAS") {488 const p = message.payload as { id?: string };489 if (typeof p?.id === "string") pendingId = p.id;490 }491 return Promise.resolve();492 },493 },494 },495 });496497 await startRelayAdoptionSource();498 await waitForPush("ADOPTION_SOURCE_SAS");499 expect(typeof pendingId).toBe("string");500 resolveSourceSasConfirmation(pendingId!, false);501 mocks.sasDecisions.push({ id: pendingId!, ok: false });502503 await waitForPush("ADOPTION_SOURCE_RESULT");504505 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();506 expect(mocks.upsert).not.toHaveBeenCalled();507 expect(mocks.pushes).toContainEqual({508 type: "ADOPTION_SOURCE_RESULT",509 payload: { status: "error", error: "adoption_rejected" },510 });511 });512513 it("rejects before reading the root key when neither peer has Pro", async () => {514 const socket = socketWithFrames([515 {516 type: "PEACH_HELLO",517 token: "AAECAwQFBgcICQoLDA0ODw",518 },519 {520 type: "ADOPT_REQUEST",521 device_id: "free-receiver",522 device_name: "Free phone",523 device_type: "android",524 },525 { type: "ADOPT_CONFIRM", confirmed: true },526 ]);527 mocks.isProUser.mockResolvedValue(false);528 mocks.getLicenseKey.mockResolvedValue(undefined);529 mocks.openSocket.mockResolvedValue(socket);530531 await startRelayAdoptionSource();532 await waitForPush("ADOPTION_SOURCE_RESULT");533534 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();535 expect(mocks.upsert).not.toHaveBeenCalled();536 expect(mocks.pushes).toContainEqual({537 type: "ADOPTION_SOURCE_RESULT",538 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },539 });540 const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {541 try {542 return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];543 } catch {544 return [];545 }546 });547 expect(sentFrames).toContainEqual({548 type: "ADOPT_REJECT",549 reason: "ADOPT_PRO_REQUIRED",550 });551 });552553 it("fails closed when the user never answers the SAS", async () => {554 555 556 557 558 const socket = socketWithFrames([]);559 mocks.openSocket.mockResolvedValue(socket);560561 Object.defineProperty(globalThis, "chrome", {562 configurable: true,563 value: {564 runtime: {565 sendMessage(message: { type: string; payload: unknown }) {566 mocks.pushes.push(message);567 return Promise.resolve();568 },569 },570 },571 });572573 await startRelayAdoptionSource();574 cancelRelayAdoptionSource();575576 expect(socket.close).toHaveBeenCalled();577 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();578 expect(mocks.upsert).not.toHaveBeenCalled();579 });580581 it("cancelling the source screen closes the room without reading the root", async () => {582 const socket = socketWithFrames([]);583 mocks.openSocket.mockResolvedValue(socket);584585 await startRelayAdoptionSource();586 cancelRelayAdoptionSource();587588 expect(socket.close).toHaveBeenCalled();589 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();590 expect(mocks.upsert).not.toHaveBeenCalled();591 });592});593594describe("relay adoption receiver background", () => {595 beforeEach(async () => {596 597 598 599 600 const { cancelRelayAdoptionSource } = await import("./relay-adoption-bg");601 cancelRelayAdoptionSource();602 mocks.pushes.length = 0;603 mocks.openSocket.mockReset();604 mocks.getDeviceName.mockReset();605 mocks.getDeviceName.mockResolvedValue("Receiver Browser");606 mocks.createVault.mockReset();607 mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });608 mocks.activateLicense.mockReset();609 mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });610 mocks.isProUser.mockReset();611 mocks.isProUser.mockResolvedValue(true);612 mocks.getLicenseKey.mockReset();613 mocks.getLicenseKey.mockResolvedValue(undefined);614 mocks.localIdentity.mockReset();615 mocks.localIdentity.mockResolvedValue({616 deviceId: "receiver-id",617 deviceName: "Receiver Browser",618 deviceType: "extension",619 longTermPublicKeyHex: "11".repeat(32),620 signingPublicKeyHex: "22".repeat(32),621 });622 mocks.rollbackVault.mockReset();623 mocks.rollbackVault.mockResolvedValue(undefined);624 mocks.storageSet.mockReset();625 mocks.storageSet.mockResolvedValue(undefined);626 mocks.upsert.mockReset();627 mocks.upsert.mockResolvedValue(undefined);628 mocks.setPairSecret.mockReset();629 mocks.setPairSecret.mockResolvedValue(undefined);630 mocks.triggerSync.mockReset();631 mocks.triggerSync.mockResolvedValue(undefined);632 mocks.openSocket.mockResolvedValue(socketWithFrames([]));633 Object.defineProperty(globalThis, "chrome", {634 configurable: true,635 value: {636 runtime: {637 async sendMessage(message: { type: string; payload?: unknown }) {638 mocks.pushes.push({ type: message.type, payload: message.payload });639 if (message.type === "ADOPTION_SOURCE_SAS") {640 const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };641 if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {642 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");643 resolveReceiverSasConfirmation(p.id, p.sas, true);644 }645 }646 return { ok: true };647 },648 onMessage: {649 addListener() { },650 removeListener() { },651 },652 },653 },654 });655 });656657 it("opens a relay socket and returns an empty invitation", async () => {658 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");659 const started = await startRelayAdoptionReceiver("receiver-password");660 expect(started.invitation).toMatch(/^peach-pair-v2:/);661 662 const decoded = decodeInvitation(started.invitation);663 expect(decoded?.vault).toBe("empty");664 expect(mocks.openSocket).toHaveBeenCalled();665 });666667 it("rejects an empty password", async () => {668 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");669 await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");670 });671672 it("surfaces the Pro-required rejection without creating a vault", async () => {673 mocks.openSocket.mockResolvedValue(socketWithFrames([674 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },675 { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },676 ]));677678 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");679 await startRelayAdoptionReceiver("receiver-password");680 await waitForPush("ADOPTION_SOURCE_RESULT");681682 expect(mocks.createVault).not.toHaveBeenCalled();683 expect(mocks.rollbackVault).not.toHaveBeenCalled();684 expect(mocks.pushes).toContainEqual({685 type: "ADOPTION_SOURCE_RESULT",686 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },687 });688 });689690 it("the receiver wires requestAdoption, not grantAdoption", async () => {691 692 693 694 695 696 const rootKeyHex = "f0".repeat(32);697 const vaultId = "b".repeat(64);698 const socket = socketWithFrames([699 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },700 {701 type: "ADOPT_GRANT",702 root_key_hex: rootKeyHex,703 vault_id: vaultId,704 license: "PPK-test",705 source: {706 device_id: "phone-source-id",707 device_name: "June's phone",708 device_type: "android",709 long_term_public_key_hex: "99".repeat(32),710 },711 },712 { type: "ADOPT_COMPLETE" },713 ], { closeOnEmpty: true });714 mocks.openSocket.mockResolvedValue(socket);715 mocks.createVault.mockResolvedValue({ vaultId });716717 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");718 Object.defineProperty(globalThis, "chrome", {719 configurable: true,720 value: {721 runtime: {722 async sendMessage(message: { type: string; payload?: unknown }) {723 mocks.pushes.push({ type: message.type, payload: message.payload });724 if (message.type === "ADOPTION_SOURCE_SAS") {725 const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };726 if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {727 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");728 resolveReceiverSasConfirmation(p.id, p.sas, true);729 }730 }731 return { ok: true };732 },733 onMessage: { addListener: () => undefined, removeListener: () => undefined },734 },735 },736 });737 await startRelayAdoptionReceiver("receiver-password");738 await waitForPush("ADOPTION_SOURCE_RESULT");739740 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })741 .mock.calls742 .map(([bytes]) => {743 try {744 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;745 } catch {746 return undefined;747 }748 });749 750 751 expect(sentTypes).toContain("ADOPT_REQUEST");752 expect(sentTypes).not.toContain("ADOPT_GRANT");753 });754755 it("completes the handshake end-to-end: vault created → paired", async () => {756 757 758 759 760 761 const rootKeyHex = "f0".repeat(32);762 763 764 const vaultId = "b".repeat(64);765 const frames = [766 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },767 768 769 {770 type: "ADOPT_GRANT",771 root_key_hex: rootKeyHex,772 vault_id: vaultId,773 license: "PPK-test",774 source: {775 device_id: "phone-source-id",776 device_name: "June's phone",777 device_type: "android",778 long_term_public_key_hex: "99".repeat(32),779 signing_public_key_hex: "aa".repeat(32),780 relay_url: "wss://relay.peachpasswords.com",781 },782 },783 { type: "ADOPT_COMPLETE" },784 ];785 mocks.createVault.mockResolvedValue({ vaultId });786 mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));787788 789 Object.defineProperty(globalThis, "chrome", {790 configurable: true,791 value: {792 runtime: {793 async sendMessage(message: { type: string; payload?: unknown }) {794 mocks.pushes.push({ type: message.type, payload: message.payload });795 if (message.type === "ADOPTION_SOURCE_SAS") {796 const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };797 if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {798 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");799 resolveReceiverSasConfirmation(p.id, p.sas, true);800 }801 }802 return { ok: true };803 },804 onMessage: {805 addListener: () => undefined,806 removeListener: () => undefined,807 },808 },809 },810 });811812 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");813 await startRelayAdoptionReceiver("receiver-password");814815 await waitForPush("ADOPTION_SOURCE_RESULT");816817 818 expect(mocks.pushes).toContainEqual({819 type: "ADOPTION_SOURCE_SAS",820 payload: expect.objectContaining({821 sas: expect.stringMatching(/^\d{8}$/),822 displayOnly: false,823 }),824 });825826 827 828 expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);829830 831 832 expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");833 expect(mocks.isProUser).toHaveBeenCalled();834 expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));835 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({836 deviceId: "phone-source-id",837 }));838 const receiverRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;839 expect(receiverRow.rendezvousSecretHex).toBeUndefined();840 expect(mocks.setPairSecret).toHaveBeenCalledWith(841 "phone-source-id",842 expect.stringMatching(/^[0-9a-f]{64}$/),843 expect.any(Uint8Array),844 );845 expect(mocks.storageSet).toHaveBeenCalledWith(846 "peach_initial_sync_pending_device_id",847 "phone-source-id",848 );849 expect(mocks.rollbackVault).not.toHaveBeenCalled();850851 852 expect(mocks.pushes).toContainEqual({853 type: "ADOPTION_SOURCE_RESULT",854 payload: { status: "complete" },855 });856 });857858 it("rolls back and refuses completion when a carried Pro license is not activated", async () => {859 const rootKeyHex = "f0".repeat(32);860 const vaultId = "b".repeat(64);861 mocks.createVault.mockResolvedValue({ vaultId });862 mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });863 mocks.isProUser.mockResolvedValue(false);864 mocks.openSocket.mockResolvedValue(socketWithFrames([865 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },866 {867 type: "ADOPT_GRANT",868 root_key_hex: rootKeyHex,869 vault_id: vaultId,870 license: "PPK-invalid",871 source: {872 device_id: "phone-source-id",873 device_name: "June's phone",874 device_type: "android",875 long_term_public_key_hex: "99".repeat(32),876 },877 },878 ]));879880 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");881 await startRelayAdoptionReceiver("receiver-password");882 await waitForPush("ADOPTION_SOURCE_RESULT");883884 expect(mocks.rollbackVault).toHaveBeenCalledOnce();885 expect(mocks.localIdentity).not.toHaveBeenCalled();886 expect(mocks.upsert).not.toHaveBeenCalled();887 expect(mocks.pushes).toContainEqual(expect.objectContaining({888 type: "ADOPTION_SOURCE_RESULT",889 payload: expect.objectContaining({890 status: "error",891 error: "license_transfer_failed",892 }),893 }));894 });895896 it("rolls back a newly created vault when a later adoption step fails", async () => {897 const rootKeyHex = "f0".repeat(32);898 const vaultId = "b".repeat(64);899 mocks.createVault.mockResolvedValue({ vaultId });900 mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));901 mocks.openSocket.mockResolvedValue(socketWithFrames([902 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },903 {904 type: "ADOPT_GRANT",905 root_key_hex: rootKeyHex,906 vault_id: vaultId,907 license: "PPK-test",908 source: {909 device_id: "phone-source-id",910 device_name: "June's phone",911 device_type: "android",912 long_term_public_key_hex: "99".repeat(32),913 },914 },915 ]));916917 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");918 await startRelayAdoptionReceiver("receiver-password");919 await waitForPush("ADOPTION_SOURCE_RESULT");920921 expect(mocks.rollbackVault).toHaveBeenCalledOnce();922 expect(mocks.upsert).not.toHaveBeenCalled();923 expect(mocks.pushes).toContainEqual(expect.objectContaining({924 type: "ADOPTION_SOURCE_RESULT",925 payload: expect.objectContaining({ status: "error", error: "identity_failed" }),926 }));927 });928929 it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {930 931 932 933 const rootKeyHex = "f0".repeat(32);934 const vaultId = "b".repeat(64);935 mocks.createVault.mockResolvedValue({ vaultId });936 mocks.openSocket.mockResolvedValue(socketWithFrames([937 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },938 {939 type: "ADOPT_GRANT",940 root_key_hex: rootKeyHex,941 vault_id: vaultId,942 license: "PPK-test",943 source: {944 device_id: "old-phone-id",945 device_name: "Old phone",946 device_type: "android",947 long_term_public_key_hex: "99".repeat(32),948 },949 950 },951 { type: "ADOPT_COMPLETE" },952 ], { closeOnEmpty: true }));953954 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");955 await startRelayAdoptionReceiver("receiver-password");956 await waitForPush("ADOPTION_SOURCE_RESULT");957958 959 expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });960 961 expect(mocks.pushes).toContainEqual(expect.objectContaining({962 type: "ADOPTION_SOURCE_RESULT",963 payload: expect.objectContaining({ status: "complete" }),964 }));965 966 expect(mocks.storageSet).toHaveBeenCalledWith(967 "peach_initial_sync_pending_device_id",968 "old-phone-id",969 );970 });971972 it("applies the source's device manifest after the snapshot, then acks", async () => {973 974 975 976 977 978 const rootKeyHex = "f0".repeat(32);979 const vaultId = "b".repeat(64);980 const longTermPubkeyHex = "99".repeat(32);981 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");982 const { deriveSyncSigningKeypair, pubkeyToHex } = await import(983 "../shared/sync-identity"984 );985 const issuerKp = await deriveSyncSigningKeypair(986 new Uint8Array(32).fill(0x55),987 "phone-source-id",988 );989 const manifest = await buildSignedDeviceManifest(990 "phone-source-id",991 [992 {993 deviceId: "peer-b",994 deviceName: "Pool B",995 deviceType: "extension",996 longTermPublicKeyHex: "11".repeat(32),997 signingPublicKeyHex: "22".repeat(32),998 wsPort: 49152,999 relayUrl: "wss://relay.peachpasswords.com",1000 pairedAt: 1,1001 },1002 ],1003 issuerKp.privateKey,1004 );1005 const socket = socketWithFrames([1006 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1007 {1008 type: "ADOPT_GRANT",1009 root_key_hex: rootKeyHex,1010 vault_id: vaultId,1011 license: "PPK-test",1012 snapshot_capable: true,1013 source: {1014 device_id: "phone-source-id",1015 device_name: "June's phone",1016 device_type: "android",1017 long_term_public_key_hex: longTermPubkeyHex,1018 signing_public_key_hex: pubkeyToHex(issuerKp.publicKeyRaw),1019 relay_url: "wss://relay.peachpasswords.com",1020 },1021 },1022 1023 1024 { type: "ADOPT_COMPLETE" },1025 1026 1027 { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1028 { type: "ADOPT_DEVICE_MANIFEST", manifest },1029 ], { closeOnEmpty: true });1030 mocks.createVault.mockResolvedValue({ vaultId });1031 mocks.openSocket.mockResolvedValue(socket);1032 mocks.__applyIncomingDeviceManifest.mockClear();10331034 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1035 await startRelayAdoptionReceiver("receiver-password");1036 await waitForPush("ADOPTION_SOURCE_RESULT");10371038 expect(mocks.__applyIncomingDeviceManifest).toHaveBeenCalledTimes(1);1039 const [forwarded, pubkey] = mocks.__applyIncomingDeviceManifest.mock.calls[0];1040 expect(forwarded).toEqual(manifest);1041 expect(pubkey).toBeInstanceOf(Uint8Array);1042 expect(pubkey.length).toBe(32);1043 1044 1045 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1046 .mock.calls1047 .map(([bytes]) => {1048 try {1049 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1050 } catch {1051 return undefined;1052 }1053 });1054 expect(sentTypes).toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1055 1056 expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST");1057 });10581059 it("does not call applyIncomingDeviceManifest when the source omits the manifest frame", async () => {1060 1061 1062 1063 1064 const rootKeyHex = "f0".repeat(32);1065 const vaultId = "b".repeat(64);1066 const longTermPubkeyHex = "99".repeat(32);1067 const socket = socketWithFrames([1068 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1069 {1070 type: "ADOPT_GRANT",1071 root_key_hex: rootKeyHex,1072 vault_id: vaultId,1073 license: "PPK-test",1074 snapshot_capable: true,1075 source: {1076 device_id: "phone-source-id",1077 device_name: "June's phone",1078 device_type: "android",1079 long_term_public_key_hex: longTermPubkeyHex,1080 signing_public_key_hex: "aa".repeat(32),1081 relay_url: "wss://relay.peachpasswords.com",1082 },1083 },1084 { type: "ADOPT_COMPLETE" },1085 1086 1087 { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1088 ], { closeOnEmpty: true });1089 mocks.createVault.mockResolvedValue({ vaultId });1090 mocks.openSocket.mockResolvedValue(socket);1091 mocks.__applyIncomingDeviceManifest.mockClear();10921093 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1094 await startRelayAdoptionReceiver("receiver-password");1095 await waitForPush("ADOPTION_SOURCE_RESULT");10961097 expect(mocks.__applyIncomingDeviceManifest).not.toHaveBeenCalled();1098 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1099 .mock.calls1100 .map(([bytes]) => {1101 try {1102 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1103 } catch {1104 return undefined;1105 }1106 });1107 expect(sentTypes).toContain("ADOPT_SNAPSHOT_APPLIED");1108 expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1109 });1110});11111112function decodeInvitation(encoded: string): { vault?: string } | null {1113 1114 1115 const at = encoded.indexOf(":");1116 if (at < 0) return null;1117 const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");1118 try {1119 const json = Buffer.from(b64, "base64").toString("utf8");1120 return JSON.parse(json) as { vault?: string };1121 } catch {1122 return null;1123 }1124}