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 upsertPairedDeviceAndClearRevocation: mocks.upsert,111 clearDeviceRevocation: mocks.clearRevocation,112 hasAdoptionIdentityConflict: mocks.hasIdentityConflict,113}));114115vi.mock("./pair-secrets", () => ({116 setPairSecret: mocks.setPairSecret,117 isValidPairSecret: (value: unknown): value is string =>118 typeof value === "string" && /^[0-9a-f]{64}$/.test(value),119}));120121vi.mock("./sync-trigger", () => ({122 triggerSyncIfReachable: mocks.triggerSync,123}));124125vi.mock("./alarms", () => ({126 deferAutoLockForOperation: mocks.deferAutoLock,127}));128129vi.mock("./sync-vault-adapter", () => ({130 createSyncVaultAdapter: mocks.createSyncAdapter,131}));132133import {134 cancelRelayAdoptionSource,135 startRelayAdoptionSource,136} from "./relay-adoption-bg";137138const encode = (value: unknown) =>139 new TextEncoder().encode(JSON.stringify(value));140141function socketWithFrames(142 frames: unknown[],143 options: { closeOnEmpty?: boolean } = {},144) {145 const queue = frames.map((frame) => {146 const encoded = encode(frame);147 148 return (frame as { type?: string }).type === "ADOPT_SNAPSHOT"149 ? padSyncFrame(encoded)150 : encoded;151 });152 return {153 send: vi.fn(),154 receive: vi.fn(async () => {155 const next = queue.shift();156 if (!next) {157 158 159 160 if (options.closeOnEmpty) throw new Error("socket_closed");161 return new Promise<Uint8Array>(() => undefined);162 }163 return next;164 }),165 close: vi.fn(),166 closedBeforeFrame: vi.fn(() => false),167 };168}169170async function waitForPush(type: string): Promise<void> {171 await vi.waitFor(() => {172 expect(mocks.pushes.some((message) => message.type === type)).toBe(true);173 });174}175176beforeEach(() => {177 mocks.pushes.length = 0;178 mocks.sasDecisions.length = 0;179 mocks.openSocket.mockReset();180 mocks.getLocalIdentity.mockReset();181 mocks.getRootKeyHex.mockReset();182 mocks.upsert.mockReset();183 mocks.clearRevocation.mockReset();184 mocks.clearRevocation.mockResolvedValue(undefined);185 mocks.hasIdentityConflict.mockReset();186 mocks.hasIdentityConflict.mockResolvedValue(false);187 mocks.rotateDeviceId.mockReset();188 mocks.rotateDeviceId.mockResolvedValue(undefined);189 mocks.setPairSecret.mockReset();190 mocks.triggerSync.mockReset();191 mocks.triggerSync.mockResolvedValue(undefined);192 mocks.releaseAutoLock.mockReset();193 mocks.deferAutoLock.mockReset();194 mocks.deferAutoLock.mockReturnValue(mocks.releaseAutoLock);195 mocks.isProUser.mockReset();196 mocks.isProUser.mockResolvedValue(true);197 mocks.getLicenseKey.mockReset();198 mocks.getLicenseKey.mockResolvedValue("PPK-source");199 mocks.getLocalIdentity.mockResolvedValue({200 identity: {201 deviceId: "browser-source",202 deviceName: "June's Browser",203 deviceType: "extension",204 longTermPublicKeyHex: "a".repeat(64),205 vaultId: "b".repeat(64),206 },207 privateKey: new Uint8Array(32).fill(1),208 publicKey: new Uint8Array(32).fill(2),209 });210 mocks.getRootKeyHex.mockReturnValue("c".repeat(64));211 mocks.upsert.mockResolvedValue(undefined);212 mocks.setPairSecret.mockResolvedValue(undefined);213 const applyIncomingDeviceManifest = vi.fn(async () => undefined);214 mocks.__applyIncomingDeviceManifest = applyIncomingDeviceManifest;215 mocks.createSyncAdapter.mockReturnValue({216 buildEntryManifest: vi.fn(async () => ({})),217 getEncryptedEntries: vi.fn(async () => ({})),218 buildTombstoneManifest: vi.fn(async () => ({})),219 applyEntries: vi.fn(async () => undefined),220 applyTombstones: vi.fn(async () => undefined),221 buildOutgoingDeviceManifest: vi.fn(async () => null),222 applyIncomingDeviceManifest,223 });224 Object.defineProperty(globalThis, "chrome", {225 configurable: true,226 value: {227 runtime: {228 async sendMessage(message: { type: string; payload: unknown }) {229 mocks.pushes.push(message);230 231 232 233 234 if (message.type === "ADOPTION_SOURCE_SAS") {235 const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };236 if (typeof p?.id === "string" && typeof p.sas === "string") {237 if (p.displayOnly === false) {238 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");239 resolveReceiverSasConfirmation(p.id, p.sas, true);240 } else {241 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");242 mocks.sasDecisions.push({ id: p.id, ok: true });243 resolveSourceSasConfirmation(p.id, true);244 }245 }246 }247 return Promise.resolve();248 },249 },250 },251 });252});253254describe("relay adoption source background boundary", () => {255 it("shows the SAS and waits for the popup's single confirmation", async () => {256 const socket = socketWithFrames([257 {258 type: "PEACH_HELLO",259 token: "AAECAwQFBgcICQoLDA0ODw",260 },261 {262 type: "ADOPT_REQUEST",263 device_id: "request-only-label",264 device_name: "June's phone",265 device_type: "android",266 },267 { type: "ADOPT_CONFIRM", confirmed: true },268 {269 type: "ADOPT_IDENTITY",270 device_id: "android-receiver",271 device_name: "June's phone",272 device_type: "android",273 long_term_public_key_hex: "d".repeat(64),274 },275 { type: "ADOPT_APPLIED" },276 ]);277 mocks.openSocket.mockResolvedValue(socket);278279 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");280 const sasIds: string[] = [];281 Object.defineProperty(globalThis, "chrome", {282 configurable: true,283 value: {284 runtime: {285 async sendMessage(message: { type: string; payload: unknown }) {286 mocks.pushes.push(message);287 if (message.type === "ADOPTION_SOURCE_SAS") {288 const p = message.payload as { id?: string };289 if (typeof p?.id === "string") {290 291 292 sasIds.push(p.id);293 }294 return Promise.resolve();295 }296 return Promise.resolve();297 },298 },299 },300 });301 void resolveSourceSasConfirmation; 302303 const started = await startRelayAdoptionSource();304 expect(started.invitation).toMatch(/^peach-pair-v2:/);305 expect(mocks.deferAutoLock).toHaveBeenCalledWith(5 * 60 * 1000);306 await waitForPush("ADOPTION_SOURCE_SAS");307 308 309 await vi.waitFor(() => {310 expect(mocks.sasDecisions.length).toBe(0);311 });312 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();313 expect(mocks.upsert).not.toHaveBeenCalled();314315 316 const id = sasIds[0];317 expect(typeof id).toBe("string");318 resolveSourceSasConfirmation(id, true);319 mocks.sasDecisions.push({ id, ok: true });320321 await waitForPush("ADOPTION_SOURCE_RESULT");322323 await vi.waitFor(() => expect(mocks.releaseAutoLock).toHaveBeenCalled());324325 expect(mocks.getRootKeyHex).toHaveBeenCalled();326 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({327 deviceId: "android-receiver",328 relayUrl: "wss://relay.peachpasswords.com",329 }));330 331 332 const sourceRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;333 expect(sourceRow.rendezvousSecretHex).toBeUndefined();334 expect(mocks.setPairSecret).toHaveBeenCalledWith(335 "android-receiver",336 expect.stringMatching(/^[0-9a-f]{64}$/),337 expect.any(Uint8Array),338 );339 expect(mocks.pushes).toContainEqual({340 type: "ADOPTION_SOURCE_RESULT",341 payload: { status: "complete" },342 });343 expect(mocks.triggerSync).toHaveBeenCalledWith({344 reason: "manual",345 force: true,346 });347 const sentTypes = socket.send.mock.calls348 .map(([bytes]) => {349 try {350 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;351 } catch {352 return undefined;353 }354 });355 expect(sentTypes).toContain("ADOPT_COMPLETE");356 });357358 it("fails adoption without sending snapshot frames when duress is oversized", async () => {359 const socket = socketWithFrames([360 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },361 {362 type: "ADOPT_REQUEST",363 device_id: "request-only-label",364 device_name: "June's phone",365 device_type: "android",366 snapshot_capable: true,367 },368 { type: "ADOPT_CONFIRM", confirmed: true },369 {370 type: "ADOPT_IDENTITY",371 device_id: "android-receiver",372 device_name: "June's phone",373 device_type: "android",374 long_term_public_key_hex: "d".repeat(64),375 },376 { type: "ADOPT_APPLIED" },377 ]);378 mocks.openSocket.mockResolvedValue(socket);379 const { exportDuressSlotForAdoption } = await import("./vault");380 vi.mocked(exportDuressSlotForAdoption).mockResolvedValueOnce(381 "x".repeat(768 * 1024),382 );383384 await startRelayAdoptionSource();385 await waitForPush("ADOPTION_SOURCE_RESULT");386387 expect(mocks.pushes).toContainEqual({388 type: "ADOPTION_SOURCE_RESULT",389 payload: {390 status: "error",391 error: "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE",392 },393 });394 expect(mocks.pushes).not.toContainEqual(expect.objectContaining({395 type: "ADOPTION_SOURCE_RESULT",396 payload: expect.objectContaining({ status: "complete" }),397 }));398 const sentTypes = socket.send.mock.calls.map(([bytes]) =>399 JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,400 );401 expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");402 expect(mocks.triggerSync).not.toHaveBeenCalled();403 });404405 it("sends no snapshot frames and completes via sync fallback when the vault exceeds the inline budget", async () => {406 407 408 409 const socket = socketWithFrames([410 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },411 {412 type: "ADOPT_REQUEST",413 device_id: "request-only-label",414 device_name: "June's phone",415 device_type: "android",416 snapshot_capable: true,417 },418 { type: "ADOPT_CONFIRM", confirmed: true },419 {420 type: "ADOPT_IDENTITY",421 device_id: "android-receiver",422 device_name: "June's phone",423 device_type: "android",424 long_term_public_key_hex: "d".repeat(64),425 },426 { type: "ADOPT_APPLIED" },427 428 429 430 { type: "ADOPT_SNAPSHOT_APPLIED", entries_received: 0, tombstones_received: 0 },431 ]);432 mocks.openSocket.mockResolvedValue(socket);433 434 const envelope = "c".repeat(690_000);435 const entries: Record<string, string> = {};436 for (let i = 0; i < 16; i++) entries[`big-${i}`] = envelope;437 mocks.createSyncAdapter.mockReturnValue({438 buildEntryManifest: vi.fn(async () => Object.fromEntries(439 Object.keys(entries).map((id) => [id, 1]),440 )),441 getEncryptedEntries: vi.fn(async () => entries),442 buildTombstoneManifest: vi.fn(async () => ({})),443 applyEntries: vi.fn(async () => undefined),444 applyTombstones: vi.fn(async () => undefined),445 buildOutgoingDeviceManifest: vi.fn(async () => null),446 applyIncomingDeviceManifest: vi.fn(async () => undefined),447 });448449 await startRelayAdoptionSource();450 await waitForPush("ADOPTION_SOURCE_RESULT");451452 expect(mocks.pushes).toContainEqual({453 type: "ADOPTION_SOURCE_RESULT",454 payload: expect.objectContaining({ status: "complete" }),455 });456 const sentTypes = socket.send.mock.calls.map(([bytes]) =>457 JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,458 );459 expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");460 461 expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });462 });463464 it("fails closed when the popup rejects the SAS", async () => {465 const socket = socketWithFrames([466 {467 type: "PEACH_HELLO",468 token: "AAECAwQFBgcICQoLDA0ODw",469 },470 {471 type: "ADOPT_REQUEST",472 device_id: "request-only-label",473 device_name: "New phone",474 device_type: "android",475 },476 { type: "ADOPT_CONFIRM", confirmed: false },477 ]);478 mocks.openSocket.mockResolvedValue(socket);479480 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");481 let pendingId: string | undefined;482 Object.defineProperty(globalThis, "chrome", {483 configurable: true,484 value: {485 runtime: {486 sendMessage(message: { type: string; payload: unknown }) {487 mocks.pushes.push(message);488 if (message.type === "ADOPTION_SOURCE_SAS") {489 const p = message.payload as { id?: string };490 if (typeof p?.id === "string") pendingId = p.id;491 }492 return Promise.resolve();493 },494 },495 },496 });497498 await startRelayAdoptionSource();499 await waitForPush("ADOPTION_SOURCE_SAS");500 expect(typeof pendingId).toBe("string");501 resolveSourceSasConfirmation(pendingId!, false);502 mocks.sasDecisions.push({ id: pendingId!, ok: false });503504 await waitForPush("ADOPTION_SOURCE_RESULT");505506 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();507 expect(mocks.upsert).not.toHaveBeenCalled();508 expect(mocks.pushes).toContainEqual({509 type: "ADOPTION_SOURCE_RESULT",510 payload: { status: "error", error: "adoption_rejected" },511 });512 });513514 it("rejects before reading the root key when neither peer has Pro", async () => {515 const socket = socketWithFrames([516 {517 type: "PEACH_HELLO",518 token: "AAECAwQFBgcICQoLDA0ODw",519 },520 {521 type: "ADOPT_REQUEST",522 device_id: "free-receiver",523 device_name: "Free phone",524 device_type: "android",525 },526 { type: "ADOPT_CONFIRM", confirmed: true },527 ]);528 mocks.isProUser.mockResolvedValue(false);529 mocks.getLicenseKey.mockResolvedValue(undefined);530 mocks.openSocket.mockResolvedValue(socket);531532 await startRelayAdoptionSource();533 await waitForPush("ADOPTION_SOURCE_RESULT");534535 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();536 expect(mocks.upsert).not.toHaveBeenCalled();537 expect(mocks.pushes).toContainEqual({538 type: "ADOPTION_SOURCE_RESULT",539 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },540 });541 const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {542 try {543 return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];544 } catch {545 return [];546 }547 });548 expect(sentFrames).toContainEqual({549 type: "ADOPT_REJECT",550 reason: "ADOPT_PRO_REQUIRED",551 });552 });553554 it("fails closed when the user never answers the SAS", async () => {555 556 557 558 559 const socket = socketWithFrames([]);560 mocks.openSocket.mockResolvedValue(socket);561562 Object.defineProperty(globalThis, "chrome", {563 configurable: true,564 value: {565 runtime: {566 sendMessage(message: { type: string; payload: unknown }) {567 mocks.pushes.push(message);568 return Promise.resolve();569 },570 },571 },572 });573574 await startRelayAdoptionSource();575 cancelRelayAdoptionSource();576577 expect(socket.close).toHaveBeenCalled();578 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();579 expect(mocks.upsert).not.toHaveBeenCalled();580 });581582 it("cancelling the source screen closes the room without reading the root", async () => {583 const socket = socketWithFrames([]);584 mocks.openSocket.mockResolvedValue(socket);585586 await startRelayAdoptionSource();587 cancelRelayAdoptionSource();588589 expect(socket.close).toHaveBeenCalled();590 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();591 expect(mocks.upsert).not.toHaveBeenCalled();592 });593});594595describe("relay adoption receiver background", () => {596 beforeEach(async () => {597 598 599 600 601 const { cancelRelayAdoptionSource } = await import("./relay-adoption-bg");602 cancelRelayAdoptionSource();603 mocks.pushes.length = 0;604 mocks.openSocket.mockReset();605 mocks.getDeviceName.mockReset();606 mocks.getDeviceName.mockResolvedValue("Receiver Browser");607 mocks.createVault.mockReset();608 mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });609 mocks.activateLicense.mockReset();610 mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });611 mocks.isProUser.mockReset();612 mocks.isProUser.mockResolvedValue(true);613 mocks.getLicenseKey.mockReset();614 mocks.getLicenseKey.mockResolvedValue(undefined);615 mocks.localIdentity.mockReset();616 mocks.localIdentity.mockResolvedValue({617 deviceId: "receiver-id",618 deviceName: "Receiver Browser",619 deviceType: "extension",620 longTermPublicKeyHex: "11".repeat(32),621 signingPublicKeyHex: "22".repeat(32),622 });623 mocks.rollbackVault.mockReset();624 mocks.rollbackVault.mockResolvedValue(undefined);625 mocks.storageSet.mockReset();626 mocks.storageSet.mockResolvedValue(undefined);627 mocks.upsert.mockReset();628 mocks.upsert.mockResolvedValue(undefined);629 mocks.setPairSecret.mockReset();630 mocks.setPairSecret.mockResolvedValue(undefined);631 mocks.triggerSync.mockReset();632 mocks.triggerSync.mockResolvedValue(undefined);633 mocks.openSocket.mockResolvedValue(socketWithFrames([]));634 Object.defineProperty(globalThis, "chrome", {635 configurable: true,636 value: {637 runtime: {638 async sendMessage(message: { type: string; payload?: unknown }) {639 mocks.pushes.push({ type: message.type, payload: message.payload });640 if (message.type === "ADOPTION_SOURCE_SAS") {641 const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };642 if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {643 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");644 resolveReceiverSasConfirmation(p.id, p.sas, true);645 }646 }647 return { ok: true };648 },649 onMessage: {650 addListener() { },651 removeListener() { },652 },653 },654 },655 });656 });657658 it("opens a relay socket and returns an empty invitation", async () => {659 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");660 const started = await startRelayAdoptionReceiver("receiver-password");661 expect(started.invitation).toMatch(/^peach-pair-v2:/);662 663 const decoded = decodeInvitation(started.invitation);664 expect(decoded?.vault).toBe("empty");665 expect(mocks.openSocket).toHaveBeenCalled();666 });667668 it("rejects an empty password", async () => {669 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");670 await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");671 });672673 it("surfaces the Pro-required rejection without creating a vault", async () => {674 mocks.openSocket.mockResolvedValue(socketWithFrames([675 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },676 { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },677 ]));678679 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");680 await startRelayAdoptionReceiver("receiver-password");681 await waitForPush("ADOPTION_SOURCE_RESULT");682683 expect(mocks.createVault).not.toHaveBeenCalled();684 expect(mocks.rollbackVault).not.toHaveBeenCalled();685 expect(mocks.pushes).toContainEqual({686 type: "ADOPTION_SOURCE_RESULT",687 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },688 });689 });690691 it("the receiver wires requestAdoption, not grantAdoption", async () => {692 693 694 695 696 697 const rootKeyHex = "f0".repeat(32);698 const vaultId = "b".repeat(64);699 const socket = socketWithFrames([700 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },701 {702 type: "ADOPT_GRANT",703 root_key_hex: rootKeyHex,704 vault_id: vaultId,705 license: "PPK-test",706 source: {707 device_id: "phone-source-id",708 device_name: "June's phone",709 device_type: "android",710 long_term_public_key_hex: "99".repeat(32),711 },712 },713 { type: "ADOPT_COMPLETE" },714 ], { closeOnEmpty: true });715 mocks.openSocket.mockResolvedValue(socket);716 mocks.createVault.mockResolvedValue({ vaultId });717718 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");719 Object.defineProperty(globalThis, "chrome", {720 configurable: true,721 value: {722 runtime: {723 async sendMessage(message: { type: string; payload?: unknown }) {724 mocks.pushes.push({ type: message.type, payload: message.payload });725 if (message.type === "ADOPTION_SOURCE_SAS") {726 const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };727 if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {728 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");729 resolveReceiverSasConfirmation(p.id, p.sas, true);730 }731 }732 return { ok: true };733 },734 onMessage: { addListener: () => undefined, removeListener: () => undefined },735 },736 },737 });738 await startRelayAdoptionReceiver("receiver-password");739 await waitForPush("ADOPTION_SOURCE_RESULT");740741 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })742 .mock.calls743 .map(([bytes]) => {744 try {745 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;746 } catch {747 return undefined;748 }749 });750 751 752 expect(sentTypes).toContain("ADOPT_REQUEST");753 expect(sentTypes).not.toContain("ADOPT_GRANT");754 });755756 it("completes the handshake end-to-end: vault created → paired", async () => {757 758 759 760 761 762 const rootKeyHex = "f0".repeat(32);763 764 765 const vaultId = "b".repeat(64);766 const frames = [767 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },768 769 770 {771 type: "ADOPT_GRANT",772 root_key_hex: rootKeyHex,773 vault_id: vaultId,774 license: "PPK-test",775 source: {776 device_id: "phone-source-id",777 device_name: "June's phone",778 device_type: "android",779 long_term_public_key_hex: "99".repeat(32),780 signing_public_key_hex: "aa".repeat(32),781 relay_url: "wss://relay.peachpasswords.com",782 },783 },784 { type: "ADOPT_COMPLETE" },785 ];786 mocks.createVault.mockResolvedValue({ vaultId });787 mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));788789 790 Object.defineProperty(globalThis, "chrome", {791 configurable: true,792 value: {793 runtime: {794 async sendMessage(message: { type: string; payload?: unknown }) {795 mocks.pushes.push({ type: message.type, payload: message.payload });796 if (message.type === "ADOPTION_SOURCE_SAS") {797 const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };798 if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {799 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");800 resolveReceiverSasConfirmation(p.id, p.sas, true);801 }802 }803 return { ok: true };804 },805 onMessage: {806 addListener: () => undefined,807 removeListener: () => undefined,808 },809 },810 },811 });812813 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");814 await startRelayAdoptionReceiver("receiver-password");815816 await waitForPush("ADOPTION_SOURCE_RESULT");817818 819 expect(mocks.pushes).toContainEqual({820 type: "ADOPTION_SOURCE_SAS",821 payload: expect.objectContaining({822 sas: expect.stringMatching(/^\d{8}$/),823 displayOnly: false,824 }),825 });826827 828 829 expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);830831 832 833 expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");834 expect(mocks.isProUser).toHaveBeenCalled();835 expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));836 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({837 deviceId: "phone-source-id",838 }));839 const receiverRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;840 expect(receiverRow.rendezvousSecretHex).toBeUndefined();841 expect(mocks.setPairSecret).toHaveBeenCalledWith(842 "phone-source-id",843 expect.stringMatching(/^[0-9a-f]{64}$/),844 expect.any(Uint8Array),845 );846 expect(mocks.storageSet).toHaveBeenCalledWith(847 "peach_initial_sync_pending_device_id",848 "phone-source-id",849 );850 expect(mocks.rollbackVault).not.toHaveBeenCalled();851852 853 expect(mocks.pushes).toContainEqual({854 type: "ADOPTION_SOURCE_RESULT",855 payload: { status: "complete" },856 });857 });858859 it("rolls back and refuses completion when a carried Pro license is not activated", async () => {860 const rootKeyHex = "f0".repeat(32);861 const vaultId = "b".repeat(64);862 mocks.createVault.mockResolvedValue({ vaultId });863 mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });864 mocks.isProUser.mockResolvedValue(false);865 mocks.openSocket.mockResolvedValue(socketWithFrames([866 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },867 {868 type: "ADOPT_GRANT",869 root_key_hex: rootKeyHex,870 vault_id: vaultId,871 license: "PPK-invalid",872 source: {873 device_id: "phone-source-id",874 device_name: "June's phone",875 device_type: "android",876 long_term_public_key_hex: "99".repeat(32),877 },878 },879 ]));880881 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");882 await startRelayAdoptionReceiver("receiver-password");883 await waitForPush("ADOPTION_SOURCE_RESULT");884885 expect(mocks.rollbackVault).toHaveBeenCalledOnce();886 expect(mocks.localIdentity).not.toHaveBeenCalled();887 expect(mocks.upsert).not.toHaveBeenCalled();888 expect(mocks.pushes).toContainEqual(expect.objectContaining({889 type: "ADOPTION_SOURCE_RESULT",890 payload: expect.objectContaining({891 status: "error",892 error: "license_transfer_failed",893 }),894 }));895 });896897 it("rolls back a newly created vault when a later adoption step fails", async () => {898 const rootKeyHex = "f0".repeat(32);899 const vaultId = "b".repeat(64);900 mocks.createVault.mockResolvedValue({ vaultId });901 mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));902 mocks.openSocket.mockResolvedValue(socketWithFrames([903 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },904 {905 type: "ADOPT_GRANT",906 root_key_hex: rootKeyHex,907 vault_id: vaultId,908 license: "PPK-test",909 source: {910 device_id: "phone-source-id",911 device_name: "June's phone",912 device_type: "android",913 long_term_public_key_hex: "99".repeat(32),914 },915 },916 ]));917918 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");919 await startRelayAdoptionReceiver("receiver-password");920 await waitForPush("ADOPTION_SOURCE_RESULT");921922 expect(mocks.rollbackVault).toHaveBeenCalledOnce();923 expect(mocks.upsert).not.toHaveBeenCalled();924 expect(mocks.pushes).toContainEqual(expect.objectContaining({925 type: "ADOPTION_SOURCE_RESULT",926 payload: expect.objectContaining({ status: "error", error: "identity_failed" }),927 }));928 });929930 it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {931 932 933 934 const rootKeyHex = "f0".repeat(32);935 const vaultId = "b".repeat(64);936 mocks.createVault.mockResolvedValue({ vaultId });937 mocks.openSocket.mockResolvedValue(socketWithFrames([938 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },939 {940 type: "ADOPT_GRANT",941 root_key_hex: rootKeyHex,942 vault_id: vaultId,943 license: "PPK-test",944 source: {945 device_id: "old-phone-id",946 device_name: "Old phone",947 device_type: "android",948 long_term_public_key_hex: "99".repeat(32),949 },950 951 },952 { type: "ADOPT_COMPLETE" },953 ], { closeOnEmpty: true }));954955 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");956 await startRelayAdoptionReceiver("receiver-password");957 await waitForPush("ADOPTION_SOURCE_RESULT");958959 960 expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });961 962 expect(mocks.pushes).toContainEqual(expect.objectContaining({963 type: "ADOPTION_SOURCE_RESULT",964 payload: expect.objectContaining({ status: "complete" }),965 }));966 967 expect(mocks.storageSet).toHaveBeenCalledWith(968 "peach_initial_sync_pending_device_id",969 "old-phone-id",970 );971 });972973 it("applies the source's device manifest after the snapshot, then acks", async () => {974 975 976 977 978 979 const rootKeyHex = "f0".repeat(32);980 const vaultId = "b".repeat(64);981 const longTermPubkeyHex = "99".repeat(32);982 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");983 const { deriveSyncSigningKeypair, pubkeyToHex } = await import(984 "../shared/sync-identity"985 );986 const issuerKp = await deriveSyncSigningKeypair(987 new Uint8Array(32).fill(0x55),988 "phone-source-id",989 );990 const manifest = await buildSignedDeviceManifest(991 "phone-source-id",992 [993 {994 deviceId: "peer-b",995 deviceName: "Pool B",996 deviceType: "extension",997 longTermPublicKeyHex: "11".repeat(32),998 signingPublicKeyHex: "22".repeat(32),999 wsPort: 49152,1000 relayUrl: "wss://relay.peachpasswords.com",1001 pairedAt: 1,1002 },1003 ],1004 issuerKp.privateKey,1005 );1006 const socket = socketWithFrames([1007 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1008 {1009 type: "ADOPT_GRANT",1010 root_key_hex: rootKeyHex,1011 vault_id: vaultId,1012 license: "PPK-test",1013 snapshot_capable: true,1014 source: {1015 device_id: "phone-source-id",1016 device_name: "June's phone",1017 device_type: "android",1018 long_term_public_key_hex: longTermPubkeyHex,1019 signing_public_key_hex: pubkeyToHex(issuerKp.publicKeyRaw),1020 relay_url: "wss://relay.peachpasswords.com",1021 },1022 },1023 1024 1025 { type: "ADOPT_COMPLETE" },1026 1027 1028 { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1029 { type: "ADOPT_DEVICE_MANIFEST", manifest },1030 ], { closeOnEmpty: true });1031 mocks.createVault.mockResolvedValue({ vaultId });1032 mocks.openSocket.mockResolvedValue(socket);1033 mocks.__applyIncomingDeviceManifest.mockClear();10341035 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1036 await startRelayAdoptionReceiver("receiver-password");1037 await waitForPush("ADOPTION_SOURCE_RESULT");10381039 expect(mocks.__applyIncomingDeviceManifest).toHaveBeenCalledTimes(1);1040 const [forwarded, pubkey] = mocks.__applyIncomingDeviceManifest.mock.calls[0];1041 expect(forwarded).toEqual(manifest);1042 expect(pubkey).toBeInstanceOf(Uint8Array);1043 expect(pubkey.length).toBe(32);1044 1045 1046 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1047 .mock.calls1048 .map(([bytes]) => {1049 try {1050 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1051 } catch {1052 return undefined;1053 }1054 });1055 expect(sentTypes).toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1056 1057 expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST");1058 });10591060 it("does not call applyIncomingDeviceManifest when the source omits the manifest frame", async () => {1061 1062 1063 1064 1065 const rootKeyHex = "f0".repeat(32);1066 const vaultId = "b".repeat(64);1067 const longTermPubkeyHex = "99".repeat(32);1068 const socket = socketWithFrames([1069 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1070 {1071 type: "ADOPT_GRANT",1072 root_key_hex: rootKeyHex,1073 vault_id: vaultId,1074 license: "PPK-test",1075 snapshot_capable: true,1076 source: {1077 device_id: "phone-source-id",1078 device_name: "June's phone",1079 device_type: "android",1080 long_term_public_key_hex: longTermPubkeyHex,1081 signing_public_key_hex: "aa".repeat(32),1082 relay_url: "wss://relay.peachpasswords.com",1083 },1084 },1085 { type: "ADOPT_COMPLETE" },1086 1087 1088 { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1089 ], { closeOnEmpty: true });1090 mocks.createVault.mockResolvedValue({ vaultId });1091 mocks.openSocket.mockResolvedValue(socket);1092 mocks.__applyIncomingDeviceManifest.mockClear();10931094 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1095 await startRelayAdoptionReceiver("receiver-password");1096 await waitForPush("ADOPTION_SOURCE_RESULT");10971098 expect(mocks.__applyIncomingDeviceManifest).not.toHaveBeenCalled();1099 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1100 .mock.calls1101 .map(([bytes]) => {1102 try {1103 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1104 } catch {1105 return undefined;1106 }1107 });1108 expect(sentTypes).toContain("ADOPT_SNAPSHOT_APPLIED");1109 expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1110 });1111});11121113function decodeInvitation(encoded: string): { vault?: string } | null {1114 1115 1116 const at = encoded.indexOf(":");1117 if (at < 0) return null;1118 const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");1119 try {1120 const json = Buffer.from(b64, "base64").toString("utf8");1121 return JSON.parse(json) as { vault?: string };1122 } catch {1123 return null;1124 }1125}