123456789101112131415import { beforeEach, describe, expect, it, vi } from "vitest";1617import type { Message, MessageResponse } from "../shared/types";1819type StorageShape = Record<string, unknown>;20type MessageListener = (21 msg: unknown,22 sender: chrome.runtime.MessageSender,23 sendResponse: (response: MessageResponse) => void24) => boolean;2526describe("sync adversarial", () => {27 let storage: StorageShape;28 let sessionStorage: StorageShape;29 let listener: MessageListener | undefined;3031 beforeEach(async () => {32 vi.resetModules();33 vi.useRealTimers();34 storage = { peach_settings: { breachMonitoring: false } };35 sessionStorage = {};36 listener = undefined;3738 vi.stubGlobal(39 "fetch",40 vi.fn(async () => ({ ok: true, status: 200, text: async () => "" }))41 );42 vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);4344 const chromeMock = {45 runtime: {46 id: "peach-extension",47 onMessage: {48 addListener: vi.fn((fn: MessageListener) => {49 listener = fn;50 }),51 },52 sendMessage: vi.fn(async () => ({ ok: true })),53 },54 tabs: {55 query: vi.fn((_q: unknown, cb?: (tabs: unknown[]) => void) => {56 if (typeof cb === "function") {57 cb([{ id: 1 }]);58 return;59 }60 return Promise.resolve([{ id: 1 }]);61 }),62 sendMessage: vi.fn(async () => ({ ok: true })),63 },64 storage: {65 local: {66 get: vi.fn(async (key?: string | string[]) => {67 if (!key) return storage;68 if (typeof key === "string") return { [key]: storage[key] };69 return key.reduce<Record<string, unknown>>((acc, k) => {70 acc[k] = storage[k];71 return acc;72 }, {});73 }),74 set: vi.fn(async (next: Record<string, unknown>) => {75 Object.assign(storage, next);76 }),77 remove: vi.fn(async (keys: string | string[]) => {78 const list = Array.isArray(keys) ? keys : [keys];79 for (const key of list) delete storage[key];80 }),81 },82 session: {83 get: vi.fn(async (key?: string | string[]) => {84 if (!key) return sessionStorage;85 if (typeof key === "string") return { [key]: sessionStorage[key] };86 return key.reduce<Record<string, unknown>>((acc, k) => {87 acc[k] = sessionStorage[k];88 return acc;89 }, {});90 }),91 set: vi.fn(async (next: Record<string, unknown>) => {92 Object.assign(sessionStorage, next);93 }),94 remove: vi.fn(async (keys: string | string[]) => {95 const list = Array.isArray(keys) ? keys : [keys];96 for (const key of list) delete sessionStorage[key];97 }),98 clear: vi.fn(async () => {99 for (const k of Object.keys(sessionStorage)) delete sessionStorage[k];100 }),101 },102 },103 alarms: {104 clear: vi.fn(),105 create: vi.fn(),106 onAlarm: { addListener: vi.fn() },107 },108 idle: {109 setDetectionInterval: vi.fn(),110 onStateChanged: { addListener: vi.fn() },111 },112 scripting: { executeScript: vi.fn(async () => []) },113 } as unknown as typeof chrome;114115 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;116117 const messages = await import("./messages");118 messages.setupMessageHandler();119 const vault = await import("./vault");120 await vault.ensureSettingsLoaded();121 });122123 async function dispatch(message: Message): Promise<MessageResponse> {124 if (!listener) throw new Error("listener not registered");125 return new Promise<MessageResponse>((resolve) => {126 const keepOpen = listener!(127 message,128 { id: "peach-extension" } as chrome.runtime.MessageSender,129 resolve130 );131 expect(keepOpen).toBe(true);132 });133 }134135 async function setupBlobs() {136 const vault = await import("./vault");137 await vault.createVault("test-pass-12345");138 139 storage.peach_last_modified_at = String(1_700_000_000_000);140 return vault;141 }142143 function makeIncomingBlob(slotsLike?: {144 a?: Record<string, unknown>;145 b?: Record<string, unknown>;146 }): string {147 const a = slotsLike?.a ?? storage.peach_vault_a;148 const b = slotsLike?.b ?? storage.peach_vault_b;149 return JSON.stringify({ a, b });150 }151152 153154 describe("applyIncomingVault timestamp adversarial", () => {155 const olderDeltas = [156 -1, -10, -1000, -60_000, -3_600_000, -86_400_000, -1_000_000_000,157 ];158 for (const delta of olderDeltas) {159 it(`incoming vault with timestamp delta ${delta}ms (older): applied=false`, async () => {160 const vault = await setupBlobs();161 const remoteTs = 1_700_000_000_000 + delta;162 const applied = await vault.applyIncomingVault(163 makeIncomingBlob(),164 remoteTs165 );166 expect(applied).toBe(false);167 });168 }169170 it("same timestamp: applied=false (not equal-overwrite)", async () => {171 const vault = await setupBlobs();172 const applied = await vault.applyIncomingVault(173 makeIncomingBlob(),174 1_700_000_000_000175 );176 expect(applied).toBe(false);177 });178179 const newerDeltas = [1, 10, 1_000, 60_000, 3_600_000, 1_000_000_000];180 for (const delta of newerDeltas) {181 it(`incoming vault delta +${delta}ms (newer): applied=true`, async () => {182 const vault = await setupBlobs();183 const applied = await vault.applyIncomingVault(184 makeIncomingBlob(),185 1_700_000_000_000 + delta186 );187 expect(applied).toBe(true);188 });189 }190191 it("far-future timestamp accepted (no upper bound on remote ts)", async () => {192 const vault = await setupBlobs();193 const applied = await vault.applyIncomingVault(194 makeIncomingBlob(),195 Date.now() * 100196 );197 expect(applied).toBe(true);198 });199 });200201 202203 describe("applyIncomingVault malformed blob handling", () => {204 const malformedBlobs = [205 "",206 "not json",207 "{",208 "[]",209 "null",210 "true",211 "42",212 '{"a": null}',213 '{"a": "not-an-object"}',214 ];215 for (const blob of malformedBlobs) {216 it(`malformed blob ${JSON.stringify(blob).slice(0, 30)} does not poison local vault`, async () => {217 const vault = await setupBlobs();218 const before = storage.peach_vault_a;219 let threw = false;220 try {221 await vault.applyIncomingVault(blob, Date.now() + 100_000);222 } catch {223 threw = true;224 }225 226 227 const reloaded = storage.peach_vault_a;228 if (!threw) {229 230 231 232 233 expect(reloaded).not.toBe(undefined);234 } else {235 expect(reloaded).toBe(before);236 }237 });238 }239 });240241 242243 describe("applyIncomingVault license handling", () => {244 it("existing license is not overwritten by incoming license", async () => {245 const vault = await setupBlobs();246 storage.peach_license = "my-existing-license";247 await vault.applyIncomingVault(248 makeIncomingBlob(),249 Date.now() + 100_000,250 "their-different-license"251 );252 expect(storage.peach_license).toBe("my-existing-license");253 });254255 it("no local license: incoming license is adopted", async () => {256 const vault = await setupBlobs();257 delete storage.peach_license;258 await vault.applyIncomingVault(259 makeIncomingBlob(),260 Date.now() + 100_000,261 "incoming-license"262 );263 expect(storage.peach_license).toBe("incoming-license");264 });265266 it("no incoming license: local license is unchanged", async () => {267 const vault = await setupBlobs();268 storage.peach_license = "local-pro";269 await vault.applyIncomingVault(270 makeIncomingBlob(),271 Date.now() + 100_000272 );273 expect(storage.peach_license).toBe("local-pro");274 });275276 it("empty-string incoming license is treated as no license", async () => {277 const vault = await setupBlobs();278 delete storage.peach_license;279 await vault.applyIncomingVault(280 makeIncomingBlob(),281 Date.now() + 100_000,282 ""283 );284 285 expect(storage.peach_license).toBeUndefined();286 });287 });288289 290291 describe("applyIncomingVault snapshot", () => {292 it("a snapshot is taken before applying a newer incoming vault", async () => {293 const vault = await setupBlobs();294 const historyBefore = await vault.getVaultHistory();295 await vault.applyIncomingVault(296 makeIncomingBlob(),297 Date.now() + 100_000298 );299 const historyAfter = await vault.getVaultHistory();300 expect(historyAfter.length).toBeGreaterThanOrEqual(historyBefore.length);301 });302 });303304 305306 describe("pairing token lifecycle", () => {307 it("START_PAIRING_SERVER yields a 6-digit token", async () => {308 const r = await dispatch({ type: "START_PAIRING_SERVER" });309 expect(r.ok).toBe(true);310 const data = (r as { data: { token: string; expiresAt: number } }).data;311 expect(data.token).toMatch(/^\d{6}$/);312 expect(data.expiresAt).toBeGreaterThan(Date.now());313 });314315 it("STOP_PAIRING_SERVER clears the token", async () => {316 await dispatch({ type: "START_PAIRING_SERVER" });317 const r = await dispatch({ type: "STOP_PAIRING_SERVER" });318 expect(r.ok).toBe(true);319 });320321 it("SCAN_PAIRING_QR returns android_only off mobile", async () => {322 const r = await dispatch({323 type: "SCAN_PAIRING_QR",324 payload: { token: "123456" },325 });326 expect(r.ok).toBe(false);327 expect(r.error).toBe("android_only");328 });329330 it("SCAN_PAIRING_QR with garbage token still returns android_only off mobile", async () => {331 const r = await dispatch({332 type: "SCAN_PAIRING_QR",333 payload: { token: "not-a-token" as unknown as string },334 });335 expect(r.ok).toBe(false);336 });337338 it("ACCEPT_PAIRING always returns ok (free tier)", async () => {339 const r = await dispatch({ type: "ACCEPT_PAIRING", payload: {} });340 expect(r.ok).toBe(true);341 });342343 for (let i = 0; i < 10; i++) {344 it(`generated token #${i} is a fresh 6-digit code`, async () => {345 const r = await dispatch({ type: "START_PAIRING_SERVER" });346 const data = (r as { data: { token: string } }).data;347 expect(data.token).toMatch(/^\d{6}$/);348 });349 }350 });351352 353354 describe("RESPOND_INCOMING_PAIRING adversarial", () => {355 it("no pending request: returns no_pending_request", async () => {356 const r = await dispatch({357 type: "RESPOND_INCOMING_PAIRING",358 payload: { accept: true },359 });360 expect(r.ok).toBe(false);361 expect(r.error).toBe("no_pending_request");362 });363364 it("expired request: getIncomingPairingRequest returns null", async () => {365 await dispatch({366 type: "DEBUG_INJECT_INCOMING_PAIRING",367 payload: {368 deviceName: "fake",369 publicKeyHex: "00".repeat(32),370 },371 });372 373 const sessionRaw = sessionStorage["peach_incoming_pair"] as374 | Record<string, unknown>375 | undefined;376 if (sessionRaw && typeof sessionRaw === "object") {377 (sessionRaw as Record<string, unknown>).expiresAt = Date.now() - 1000;378 }379 const r = await dispatch({380 type: "RESPOND_INCOMING_PAIRING",381 payload: { accept: true },382 });383 384 385 expect(r.ok).toBe(false);386 });387388 it("missing payload returns no_pending or error", async () => {389 const r = await dispatch({390 type: "RESPOND_INCOMING_PAIRING",391 payload: {} as { accept: boolean },392 });393 expect(r).toBeDefined();394 });395 });396397 398399 describe("DEBUG_INJECT_INCOMING_PAIRING adversarial", () => {400 it("missing fields returns missing_payload", async () => {401 const r = await dispatch({402 type: "DEBUG_INJECT_INCOMING_PAIRING",403 payload: {},404 });405 expect(r.ok).toBe(false);406 expect(r.error).toBe("missing_payload");407 });408409 const partials = [410 { deviceName: "x" },411 { publicKeyHex: "y" },412 { deviceName: "", publicKeyHex: "" },413 ];414 for (const [i, p] of partials.entries()) {415 it(`partial payload #${i} returns missing_payload`, async () => {416 const r = await dispatch({417 type: "DEBUG_INJECT_INCOMING_PAIRING",418 payload: p,419 });420 expect(r.ok).toBe(false);421 expect(r.error).toBe("missing_payload");422 });423 }424425 it("valid payload returns ok", async () => {426 const r = await dispatch({427 type: "DEBUG_INJECT_INCOMING_PAIRING",428 payload: {429 deviceName: "test-device",430 publicKeyHex: "ab".repeat(32),431 },432 });433 expect(r.ok).toBe(true);434 });435 });436437 438439 describe("GET_SYNC_STATUS shape", () => {440 it("never synced: status=never", async () => {441 delete storage.peach_last_sync_at;442 const r = await dispatch({ type: "GET_SYNC_STATUS" });443 const data = (r as { data: { status: string } }).data;444 expect(data.status).toBe("never");445 });446447 it("with last_sync_at: status=up_to_date", async () => {448 storage.peach_last_sync_at = String(Date.now());449 const r = await dispatch({ type: "GET_SYNC_STATUS" });450 const data = (r as { data: { status: string } }).data;451 expect(data.status).toBe("up_to_date");452 });453454 it("garbage last_sync_at: status=never", async () => {455 storage.peach_last_sync_at = "not-a-number";456 const r = await dispatch({ type: "GET_SYNC_STATUS" });457 const data = (r as { data: { status: string } }).data;458 expect(data.status).toBe("never");459 });460 });461462 463464 describe("REMOVE_PAIRED_DEVICE adversarial", () => {465 const badIds = ["", " ", "not-a-real-id", "\x00", "a".repeat(1000)];466 for (const deviceId of badIds) {467 it(`unknown deviceId ${JSON.stringify(deviceId.slice(0, 20))} succeeds (idempotent)`, async () => {468 const r = await dispatch({469 type: "REMOVE_PAIRED_DEVICE",470 payload: { deviceId },471 });472 473 expect(r.ok).toBe(true);474 });475 }476 });477478 479480 describe("timestamp boundary sweep", () => {481 for (const baseTs of [482 0,483 1,484 1_000,485 1_700_000_000_000,486 Number.MAX_SAFE_INTEGER - 10,487 ]) {488 it(`base ts ${baseTs}: applies when remote is strictly newer`, async () => {489 const vault = await import("./vault");490 await vault.createVault("test-pass-12345");491 storage.peach_last_modified_at = String(baseTs);492 const applied = await vault.applyIncomingVault(493 makeIncomingBlob(),494 baseTs + 1495 );496 expect(applied).toBe(true);497 });498499 it(`base ts ${baseTs}: rejects when remote is equal`, async () => {500 const vault = await import("./vault");501 await vault.createVault("test-pass-12345");502 storage.peach_last_modified_at = String(baseTs);503 const applied = await vault.applyIncomingVault(504 makeIncomingBlob(),505 baseTs506 );507 expect(applied).toBe(false);508 });509510 it(`base ts ${baseTs}: rejects when remote is strictly older`, async () => {511 const vault = await import("./vault");512 await vault.createVault("test-pass-12345");513 storage.peach_last_modified_at = String(baseTs);514 const applied = await vault.applyIncomingVault(515 makeIncomingBlob(),516 baseTs - 1517 );518 expect(applied).toBe(false);519 });520 }521 });522523 524525 describe("token format adversarial", () => {526 const badFormats = [527 "",528 "12345",529 "1234567",530 "abcdef",531 "12 34 56",532 "<script>",533 "null",534 "undefined",535 "-12345",536 "1.23456",537 "🔢🔢🔢🔢🔢🔢",538 ];539 for (const token of badFormats) {540 it(`SCAN_PAIRING_QR with token ${JSON.stringify(token)} returns android_only`, async () => {541 const r = await dispatch({542 type: "SCAN_PAIRING_QR",543 payload: { token },544 });545 expect(r.ok).toBe(false);546 });547 }548 });549550 551552 describe("runBlobSyncSession safety", () => {553 it("locked vault returns skipped status", async () => {554 const { runBlobSyncSession } = await import("./sync-coordinator");555 556 const result = await runBlobSyncSession(557 "initiator",558 {559 send: vi.fn(async () => {}),560 receive: vi.fn(async () => new Uint8Array()),561 close: vi.fn(async () => {}),562 } as unknown as Parameters<typeof runBlobSyncSession>[1],563 {564 getVaultBlobForSync: async () => null,565 applyIncomingVault: async () => false,566 isVaultUnlocked: () => false,567 }568 );569 expect(result.status).toBe("skipped");570 expect(result.applied).toBe(false);571 });572 });573574 575 576 577 578 describe("sync initiator authentication", () => {579 580 581 582 583 async function simulateResponderHandshake(opts: {584 initiatorKeypair: { secretKey: Uint8Array; publicKey: Uint8Array };585 pairedX25519Hex: string[];586 }): Promise<{ status: "completed" | "rejected"; vaultBlobSent: boolean }> {587 const { generateKeyPair, sharedKey } = await import("@stablelib/x25519");588 const { HMAC } = await import("@stablelib/hmac");589 const { SHA256 } = await import("@stablelib/sha256");590591 592 593 594 const responderKp = generateKeyPair();595 const b64 = (bytes: Uint8Array) => {596 let s = "";597 for (const b of bytes) s += String.fromCharCode(b);598 return btoa(s);599 };600 storage.peach_sync_identity = JSON.stringify({601 priv: b64(responderKp.secretKey),602 pub: b64(responderKp.publicKey),603 });604605 606 storage.peach_paired_devices = pairedX25519Hex(opts.pairedX25519Hex);607608 609 610 const sentFrames: string[] = [];611 (globalThis as unknown as { Capacitor: unknown }).Capacitor = {612 isNativePlatform: () => true,613 Plugins: {614 SyncPlugin: {615 addListener: () => {},616 sendToConnection: async ({ data }: { connectionId: string; data: string }) => {617 sentFrames.push(data);618 },619 },620 },621 };622623 624 vi.resetModules();625 const messages = await import("./messages");626 messages.setupMessageHandler();627 const vault = await import("./vault");628 await vault.ensureSettingsLoaded();629 await vault.createVault("test-pass-sync");630631 632 const sessionPromise = dispatch({633 type: "SYNC_SESSION_STARTED",634 payload: { connectionId: "test-conn" },635 });636637 638 const waitForFrame = async (): Promise<string> => {639 for (let i = 0; i < 200; i++) {640 if (sentFrames.length > 0) return sentFrames.shift()!;641 await new Promise(r => setTimeout(r, 5));642 }643 throw new Error("timeout waiting for responder frame");644 };645 const challengeB64 = await waitForFrame();646 const challengeBytes = base64ToU8(challengeB64);647 const challenge = JSON.parse(new TextDecoder().decode(challengeBytes)) as {648 type: string;649 nonce_hex: string;650 responder_public_key_hex: string;651 };652 if (challenge.type !== "CHALLENGE") throw new Error("expected CHALLENGE");653654 655 656 const responderPub = hexToBytes(challenge.responder_public_key_hex);657 const dh = sharedKey(opts.initiatorKeypair.secretKey, responderPub);658 const mac = new HMAC(SHA256, dh);659 mac.update(new TextEncoder().encode("peach-sync-v1-initiator-auth"));660 mac.update(opts.initiatorKeypair.publicKey);661 mac.update(responderPub);662 mac.update(hexToBytes(challenge.nonce_hex));663 const proof = mac.digest();664665 const proofFrame = JSON.stringify({666 type: "PROOF",667 initiator_public_key_hex: bytesToHexStr(opts.initiatorKeypair.publicKey),668 proof_hex: bytesToHexStr(proof),669 });670 671 const { deliverAndroidSyncFrame } = messages;672 deliverAndroidSyncFrame("test-conn", u8ToBase64(new TextEncoder().encode(proofFrame)));673674 675 676 677 const result = await Promise.race([678 sessionPromise.then(r => ({ kind: "resolved" as const, response: r })),679 new Promise<{ kind: "blob-stage" }>(resolve =>680 setTimeout(() => resolve({ kind: "blob-stage" }), 200)681 ),682 ]);683684 if (result.kind === "resolved") {685 const resp = result.response;686 if (resp.ok === false) {687 return { status: "rejected", vaultBlobSent: false };688 }689 690 691 return { status: "completed", vaultBlobSent: sentFrames.length > 0 };692 }693 694 return { status: "completed", vaultBlobSent: sentFrames.length > 0 };695 }696697 function pairedX25519Hex(keys: string[]): Array<Record<string, unknown>> {698 return keys.map((k, i) => ({699 deviceId: k,700 displayName: `Dev${i}`,701 x25519PublicKey: k,702 pairedAt: 1_700_000_000_000,703 lastSeenAt: 1_700_000_000_000,704 }));705 }706707 function hexToBytes(s: string): Uint8Array {708 const out = new Uint8Array(s.length / 2);709 for (let i = 0; i < s.length; i += 2) out[i / 2] = parseInt(s.slice(i, i + 2), 16);710 return out;711 }712 function bytesToHexStr(b: Uint8Array): string {713 return Array.from(b).map(x => x.toString(16).padStart(2, "0")).join("");714 }715 function base64ToU8(b64: string): Uint8Array {716 const bin = atob(b64);717 const out = new Uint8Array(bin.length);718 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);719 return out;720 }721 function u8ToBase64(u: Uint8Array): string {722 let s = "";723 for (const b of u) s += String.fromCharCode(b);724 return btoa(s);725 }726727 it("unpaired initiator is rejected — vault blob never sent", async () => {728 const { generateKeyPair } = await import("@stablelib/x25519");729 const initKp = generateKeyPair();730 const result = await simulateResponderHandshake({731 initiatorKeypair: initKp,732 pairedX25519Hex: [],733 });734 expect(result.status).toBe("rejected");735 expect(result.vaultBlobSent).toBe(false);736 });737738 it("paired initiator passes the auth gate", async () => {739 const { generateKeyPair } = await import("@stablelib/x25519");740 const initKp = generateKeyPair();741 const initHex = Array.from(initKp.publicKey)742 .map(x => x.toString(16).padStart(2, "0"))743 .join("");744 const result = await simulateResponderHandshake({745 initiatorKeypair: initKp,746 pairedX25519Hex: [initHex],747 });748 expect(result.status).toBe("completed");749 });750751 it("forged public key in PROOF frame is rejected (proof MAC fails)", async () => {752 const { generateKeyPair } = await import("@stablelib/x25519");753 const realKp = generateKeyPair();754 const realHex = Array.from(realKp.publicKey)755 .map(x => x.toString(16).padStart(2, "0"))756 .join("");757 758 759 760 761 const forgedKp = generateKeyPair();762 const result = await simulateResponderHandshake({763 initiatorKeypair: forgedKp,764 pairedX25519Hex: [realHex], 765 });766 expect(result.status).toBe("rejected");767 expect(result.vaultBlobSent).toBe(false);768 });769 });770});