1import { beforeEach, describe, expect, it, vi } from "vitest";23const mocks = vi.hoisted(() => ({4 openSocket: vi.fn(),5 getLocalIdentity: vi.fn(),6 getDeviceName: vi.fn(),7 getRootKeyHex: vi.fn(),8 upsert: vi.fn(),9 pushes: [] as Array<{ type: string; payload: unknown }>,10 triggerSync: vi.fn(),11 createVault: vi.fn(),12 activateLicense: vi.fn(),13 localIdentity: vi.fn(),14 completePairing: vi.fn(),15 sasDecisions: [] as Array<{ id: string; ok: boolean }>,16}));1718vi.mock("../shared/pairing-dial", () => ({19 openRelayPairingSocket: mocks.openSocket,20}));2122vi.mock("../shared/noise-js", () => ({23 noiseNKResponder: vi.fn(async () => ({24 handshakeHash: new Uint8Array(32).fill(7),25 encrypt: (bytes: Uint8Array) => bytes,26 decrypt: (bytes: Uint8Array) => bytes,27 close: vi.fn(),28 })),29}));3031vi.mock("../shared/rendezvous", async (importOriginal) => {32 const actual = await importOriginal<typeof import("../shared/rendezvous")>();33 return {34 ...actual,35 generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",36 };37});383940vi.mock("./device-identity", () => ({41 getLocalPairingIdentity: async () => ({42 identity: {43 deviceId: "test-device",44 deviceName: "Test",45 deviceType: "extension",46 longTermPublicKeyHex: "aa".repeat(32),47 vaultId: "bb".repeat(32),48 },49 privateKey: new Uint8Array(32).fill(7),50 publicKey: new Uint8Array(32).fill(8),51 }),52 getDeviceName: mocks.getDeviceName,53}));5455vi.mock("./sync-settings", async (importOriginal) => ({56 ...(await importOriginal<typeof import("./sync-settings")>()),57 getSyncSettings: async () => ({58 mode: "public" as const,59 relayUrl: "wss://relay.peachpasswords.com",60 }),61}));6263vi.mock("./license", () => ({64 isProUser: vi.fn(async () => false),65 getLicenseKey: vi.fn(async () => undefined),66}));6768vi.mock("./vault", () => ({69 isUnlocked: vi.fn(() => true),70 getRootKeyHex: mocks.getRootKeyHex,71}));7273vi.mock("../shared/sync-identity", () => ({74 computeVaultId: vi.fn(async () => "b".repeat(64)),75}));7677vi.mock("./paired-devices", () => ({78 upsertPairedDevice: mocks.upsert,79}));8081vi.mock("./sync-trigger", () => ({82 triggerSyncIfReachable: mocks.triggerSync,83}));8485import {86 cancelRelayAdoptionSource,87 startRelayAdoptionSource,88} from "./relay-adoption-source-bg";8990const encode = (value: unknown) =>91 new TextEncoder().encode(JSON.stringify(value));9293function socketWithFrames(frames: unknown[]) {94 const queue = frames.map(encode);95 return {96 send: vi.fn(),97 receive: vi.fn(async () => {98 const next = queue.shift();99 if (!next) return new Promise<Uint8Array>(() => undefined);100 return next;101 }),102 close: vi.fn(),103 closedBeforeFrame: vi.fn(() => false),104 };105}106107async function waitForPush(type: string): Promise<void> {108 await vi.waitFor(() => {109 expect(mocks.pushes.some((message) => message.type === type)).toBe(true);110 });111}112113beforeEach(() => {114 mocks.pushes.length = 0;115 mocks.openSocket.mockReset();116 mocks.getLocalIdentity.mockReset();117 mocks.getRootKeyHex.mockReset();118 mocks.upsert.mockReset();119 mocks.triggerSync.mockReset();120 mocks.triggerSync.mockResolvedValue(undefined);121 mocks.getLocalIdentity.mockResolvedValue({122 identity: {123 deviceId: "browser-source",124 deviceName: "June's Browser",125 deviceType: "extension",126 longTermPublicKeyHex: "a".repeat(64),127 vaultId: "b".repeat(64),128 },129 privateKey: new Uint8Array(32).fill(1),130 publicKey: new Uint8Array(32).fill(2),131 });132 mocks.getRootKeyHex.mockReturnValue("c".repeat(64));133 mocks.upsert.mockResolvedValue(undefined);134 Object.defineProperty(globalThis, "chrome", {135 configurable: true,136 value: {137 runtime: {138 sendMessage(message: { type: string; payload: unknown }) {139 mocks.pushes.push(message);140 return Promise.resolve();141 },142 },143 },144 });145});146147describe("relay adoption source background boundary", () => {148 it("uses the receiver SAS confirmation as the only root-key commit gate", async () => {149 const socket = socketWithFrames([150 {151 type: "PEACH_HELLO",152 token: "AAECAwQFBgcICQoLDA0ODw",153 },154 {155 type: "ADOPT_REQUEST",156 device_id: "request-only-label",157 device_name: "June's phone",158 device_type: "android",159 },160 { type: "ADOPT_CONFIRM", confirmed: true },161 {162 type: "ADOPT_IDENTITY",163 device_id: "android-receiver",164 device_name: "June's phone",165 device_type: "android",166 long_term_public_key_hex: "d".repeat(64),167 },168 { type: "ADOPT_APPLIED" },169 ]);170 mocks.openSocket.mockResolvedValue(socket);171172 const started = await startRelayAdoptionSource();173 expect(started.invitation).toMatch(/^peach-pair-v1:/);174 await waitForPush("ADOPTION_SOURCE_SAS");175 await waitForPush("ADOPTION_SOURCE_RESULT");176177 expect(mocks.getRootKeyHex).toHaveBeenCalled();178 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({179 deviceId: "android-receiver",180 relayUrl: "wss://relay.peachpasswords.com",181 }));182 expect(mocks.pushes).toContainEqual(expect.objectContaining({183 type: "ADOPTION_SOURCE_RESULT",184 payload: expect.objectContaining({ status: "complete" }),185 }));186 expect(mocks.triggerSync).toHaveBeenCalledWith({187 reason: "manual",188 force: true,189 });190 const sentTypes = socket.send.mock.calls191 .map(([bytes]) => {192 try {193 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;194 } catch {195 return undefined;196 }197 });198 expect(sentTypes).toContain("ADOPT_COMPLETE");199 });200201 it("fails closed when the scanning device rejects the SAS", async () => {202 const socket = socketWithFrames([203 {204 type: "PEACH_HELLO",205 token: "AAECAwQFBgcICQoLDA0ODw",206 },207 {208 type: "ADOPT_REQUEST",209 device_id: "request-only-label",210 device_name: "New phone",211 device_type: "android",212 },213 { type: "ADOPT_CONFIRM", confirmed: false },214 ]);215 mocks.openSocket.mockResolvedValue(socket);216217 await startRelayAdoptionSource();218 await waitForPush("ADOPTION_SOURCE_SAS");219 await waitForPush("ADOPTION_SOURCE_RESULT");220221 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();222 expect(mocks.upsert).not.toHaveBeenCalled();223 expect(mocks.pushes).toContainEqual({224 type: "ADOPTION_SOURCE_RESULT",225 payload: { status: "error", error: "adoption_rejected" },226 });227 });228229 it("cancelling the source screen closes the room without reading the root", async () => {230 const socket = socketWithFrames([]);231 mocks.openSocket.mockResolvedValue(socket);232233 await startRelayAdoptionSource();234 cancelRelayAdoptionSource();235236 expect(socket.close).toHaveBeenCalled();237 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();238 expect(mocks.upsert).not.toHaveBeenCalled();239 });240});241242describe("relay adoption receiver background", () => {243 beforeEach(async () => {244 245 246 247 248 const { cancelRelayAdoptionSource } = await import("./relay-adoption-source-bg");249 cancelRelayAdoptionSource();250 mocks.pushes.length = 0;251 mocks.sasDecisions.length = 0;252 mocks.openSocket.mockReset();253 mocks.getDeviceName.mockReset();254 mocks.getDeviceName.mockResolvedValue("Receiver Browser");255 mocks.createVault.mockReset();256 mocks.createVault.mockResolvedValue({ ok: true, data: { vaultId: "ee".repeat(32) } });257 mocks.activateLicense.mockReset();258 mocks.activateLicense.mockResolvedValue({ ok: true });259 mocks.localIdentity.mockReset();260 mocks.localIdentity.mockResolvedValue({261 ok: true,262 data: {263 deviceId: "receiver-id",264 deviceName: "Receiver Browser",265 deviceType: "extension",266 longTermPublicKeyHex: "11".repeat(32),267 signingPublicKeyHex: "22".repeat(32),268 },269 });270 mocks.completePairing.mockReset();271 mocks.completePairing.mockResolvedValue({ ok: true });272 mocks.upsert.mockReset();273 mocks.upsert.mockResolvedValue(undefined);274 mocks.triggerSync.mockReset();275 mocks.triggerSync.mockResolvedValue(undefined);276 mocks.openSocket.mockResolvedValue(socketWithFrames([]));277 Object.defineProperty(globalThis, "chrome", {278 configurable: true,279 value: {280 runtime: {281 sendMessage(message: { type: string; payload?: unknown }) {282 283 284 if (message.type === "RECEIVER_SAS_DECISION") {285 const p = message.payload as { id?: string; ok?: boolean };286 if (typeof p?.id === "string" && typeof p?.ok === "boolean") {287 mocks.sasDecisions.push({ id: p.id, ok: p.ok });288 }289 }290 if (message.type === "CREATE_VAULT_FROM_ROOT_KEY") {291 return Promise.resolve(mocks.createVault(message.payload));292 }293 if (message.type === "ACTIVATE_LICENSE") {294 return Promise.resolve(mocks.activateLicense(message.payload));295 }296 if (message.type === "GET_LOCAL_IDENTITY") {297 return Promise.resolve(mocks.localIdentity());298 }299 if (message.type === "COMPLETE_PAIRING") {300 return Promise.resolve(mocks.completePairing(message.payload));301 }302 if (message.type === "TRIGGER_SYNC") {303 return Promise.resolve({ ok: true });304 }305 mocks.pushes.push({ type: message.type, payload: message.payload });306 return Promise.resolve({ ok: true });307 },308 onMessage: {309 addListener() { },310 removeListener() { },311 },312 },313 },314 });315 });316317 it("opens a relay socket and returns an empty invitation", async () => {318 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");319 const started = await startRelayAdoptionReceiver("receiver-password");320 expect(started.invitation).toMatch(/^peach-pair-v1:/);321 322 const decoded = decodeInvitation(started.invitation);323 expect(decoded?.vault).toBe("empty");324 expect(mocks.openSocket).toHaveBeenCalled();325 });326327 it("rejects an empty password", async () => {328 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");329 await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");330 });331332 it("the receiver wires requestAdoption, not grantAdoption", async () => {333 334 335 336 337 338 const rootKeyHex = "f0".repeat(32);339 const vaultId = "b".repeat(64);340 const socket = socketWithFrames([341 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },342 {343 type: "ADOPT_GRANT",344 root_key_hex: rootKeyHex,345 vault_id: vaultId,346 source: {347 device_id: "phone-source-id",348 device_name: "June's phone",349 device_type: "android",350 long_term_public_key_hex: "99".repeat(32),351 },352 },353 { type: "ADOPT_COMPLETE" },354 ]);355 mocks.openSocket.mockResolvedValue(socket);356357 const { startRelayAdoptionReceiver, resolveReceiverSasConfirmation } =358 await import("./relay-adoption-source-bg");359 Object.defineProperty(globalThis, "chrome", {360 configurable: true,361 value: {362 runtime: {363 sendMessage(message: { type: string; payload?: unknown }) {364 if (message.type === "RECEIVER_SAS_DECISION") return Promise.resolve({ ok: true });365 if (message.type === "CREATE_VAULT_FROM_ROOT_KEY") {366 return Promise.resolve({ ok: true, data: { vaultId } });367 }368 if (message.type === "GET_LOCAL_IDENTITY") {369 return Promise.resolve({370 ok: true,371 data: {372 deviceId: "receiver-id",373 deviceName: "Receiver Browser",374 deviceType: "extension",375 longTermPublicKeyHex: "11".repeat(32),376 },377 });378 }379 if (message.type === "COMPLETE_PAIRING") {380 return Promise.resolve({ ok: true });381 }382 if (message.type === "TRIGGER_SYNC") {383 return Promise.resolve({ ok: true });384 }385 mocks.pushes.push({ type: message.type, payload: message.payload });386 if (message.type === "ADOPTION_SOURCE_SAS") {387 const p = message.payload as { id?: string };388 if (typeof p?.id === "string") resolveReceiverSasConfirmation(p.id, true);389 }390 return Promise.resolve({ ok: true });391 },392 onMessage: { addListener: () => undefined, removeListener: () => undefined },393 },394 },395 });396 await startRelayAdoptionReceiver("receiver-password");397 await waitForPush("ADOPTION_SOURCE_RESULT");398399 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })400 .mock.calls401 .map(([bytes]) => {402 try {403 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;404 } catch {405 return undefined;406 }407 });408 409 410 expect(sentTypes).toContain("ADOPT_REQUEST");411 expect(sentTypes).not.toContain("ADOPT_GRANT");412 });413414 it("completes the handshake end-to-end: SAS confirm → vault created → paired", async () => {415 416 417 418 419 420 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-source-bg");421 const rootKeyHex = "f0".repeat(32);422 423 424 const vaultId = "b".repeat(64);425 const frames = [426 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },427 428 429 {430 type: "ADOPT_GRANT",431 root_key_hex: rootKeyHex,432 vault_id: vaultId,433 license: "PPK-test",434 source: {435 device_id: "phone-source-id",436 device_name: "June's phone",437 device_type: "android",438 long_term_public_key_hex: "99".repeat(32),439 signing_public_key_hex: "aa".repeat(32),440 relay_url: "wss://relay.peachpasswords.com",441 },442 },443 { type: "ADOPT_COMPLETE" },444 ];445 mocks.createVault.mockResolvedValue({ ok: true, data: { vaultId } });446 mocks.openSocket.mockResolvedValue(socketWithFrames(frames));447448 449 450 Object.defineProperty(globalThis, "chrome", {451 configurable: true,452 value: {453 runtime: {454 sendMessage(message: { type: string; payload?: unknown }) {455 if (message.type === "RECEIVER_SAS_DECISION") {456 const p = message.payload as { id?: string; ok?: boolean };457 if (typeof p?.id === "string" && typeof p?.ok === "boolean") {458 mocks.sasDecisions.push({ id: p.id, ok: p.ok });459 }460 return Promise.resolve({ ok: true });461 }462 if (message.type === "CREATE_VAULT_FROM_ROOT_KEY") {463 return Promise.resolve(mocks.createVault(message.payload));464 }465 if (message.type === "ACTIVATE_LICENSE") {466 return Promise.resolve(mocks.activateLicense(message.payload));467 }468 if (message.type === "GET_LOCAL_IDENTITY") {469 return Promise.resolve(mocks.localIdentity());470 }471 if (message.type === "COMPLETE_PAIRING") {472 return Promise.resolve(mocks.completePairing(message.payload));473 }474 if (message.type === "TRIGGER_SYNC") {475 return Promise.resolve({ ok: true });476 }477 mocks.pushes.push({ type: message.type, payload: message.payload });478 479 480 481 if (message.type === "ADOPTION_SOURCE_SAS") {482 const p = message.payload as { id?: string };483 if (typeof p?.id === "string") {484 mocks.sasDecisions.push({ id: p.id, ok: true });485 resolveReceiverSasConfirmation(p.id, true);486 }487 }488 return Promise.resolve({ ok: true });489 },490 onMessage: {491 addListener: () => undefined,492 removeListener: () => undefined,493 },494 },495 },496 });497498 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");499 await startRelayAdoptionReceiver("receiver-password");500501 await waitForPush("ADOPTION_SOURCE_RESULT");502503 504 expect(mocks.pushes.some((p) => p.type === "ADOPTION_SOURCE_SAS")).toBe(true);505 expect(mocks.sasDecisions.some((d) => d.ok === true)).toBe(true);506507 508 509 expect(mocks.createVault).toHaveBeenCalledWith(expect.objectContaining({510 password: "receiver-password",511 rootKeyHex,512 }));513514 515 516 expect(mocks.activateLicense).toHaveBeenCalledWith({ key: "PPK-test" });517 expect(mocks.localIdentity).toHaveBeenCalled();518 expect(mocks.completePairing).toHaveBeenCalled();519 const pairedCall = mocks.completePairing.mock.calls[0]?.[0] as520 { paired?: { deviceId?: string; deviceName?: string } };521 expect(pairedCall.paired?.deviceId).toBe("phone-source-id");522523 524 expect(mocks.pushes).toContainEqual(expect.objectContaining({525 type: "ADOPTION_SOURCE_RESULT",526 payload: expect.objectContaining({ status: "complete" }),527 }));528 });529});530531function decodeInvitation(encoded: string): { vault?: string } | null {532 533 534 const at = encoded.indexOf(":");535 if (at < 0) return null;536 const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");537 try {538 const json = Buffer.from(b64, "base64").toString("utf8");539 return JSON.parse(json) as { vault?: string };540 } catch {541 return null;542 }543}