123456789import { beforeEach, describe, expect, it, vi } from "vitest";1011import type { Message, MessageResponse } from "../shared/types";12import { WORD_LIST } from "../shared/recovery-word-list";1314type StorageShape = Record<string, unknown>;15type MessageListener = (16 msg: unknown,17 sender: chrome.runtime.MessageSender,18 sendResponse: (response: MessageResponse) => void19) => boolean;2021describe("recovery words adversarial", () => {22 let storage: StorageShape;23 let sessionStorage: StorageShape;24 let listener: MessageListener | undefined;2526 beforeEach(async () => {27 vi.resetModules();28 vi.useRealTimers();29 storage = { peach_settings: { breachMonitoring: false } };30 sessionStorage = {};31 listener = undefined;3233 vi.stubGlobal(34 "fetch",35 vi.fn(async () => ({ ok: true, status: 200, text: async () => "" }))36 );37 vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);3839 const chromeMock = {40 runtime: {41 id: "peach-extension",42 getURL: (path = "") => `chrome-extension://peach-extension/${path}`,43 onMessage: {44 addListener: vi.fn((fn: MessageListener) => {45 listener = fn;46 }),47 },48 sendMessage: vi.fn(async () => ({ ok: true })),49 },50 tabs: {51 query: vi.fn((_q: unknown, cb?: (tabs: unknown[]) => void) => {52 if (typeof cb === "function") {53 cb([{ id: 1 }]);54 return;55 }56 return Promise.resolve([{ id: 1 }]);57 }),58 sendMessage: vi.fn(async () => ({ ok: true })),59 },60 storage: {61 local: {62 get: vi.fn(async (key?: string | string[]) => {63 if (!key) return storage;64 if (typeof key === "string") return { [key]: storage[key] };65 return key.reduce<Record<string, unknown>>((acc, k) => {66 acc[k] = storage[k];67 return acc;68 }, {});69 }),70 set: vi.fn(async (next: Record<string, unknown>) => {71 Object.assign(storage, next);72 }),73 remove: vi.fn(async (keys: string | string[]) => {74 const list = Array.isArray(keys) ? keys : [keys];75 for (const key of list) delete storage[key];76 }),77 },78 session: {79 get: vi.fn(async (key?: string | string[]) => {80 if (!key) return sessionStorage;81 if (typeof key === "string") return { [key]: sessionStorage[key] };82 return key.reduce<Record<string, unknown>>((acc, k) => {83 acc[k] = sessionStorage[k];84 return acc;85 }, {});86 }),87 set: vi.fn(async (next: Record<string, unknown>) => {88 Object.assign(sessionStorage, next);89 }),90 remove: vi.fn(async (keys: string | string[]) => {91 const list = Array.isArray(keys) ? keys : [keys];92 for (const key of list) delete sessionStorage[key];93 }),94 clear: vi.fn(async () => {95 for (const k of Object.keys(sessionStorage)) delete sessionStorage[k];96 }),97 },98 },99 alarms: {100 clear: vi.fn(),101 create: vi.fn(),102 onAlarm: { addListener: vi.fn() },103 },104 idle: {105 setDetectionInterval: vi.fn(),106 onStateChanged: { addListener: vi.fn() },107 },108 scripting: { executeScript: vi.fn(async () => []) },109 } as unknown as typeof chrome;110111 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;112113 const messages = await import("./messages");114 messages.setupMessageHandler();115 const vault = await import("./vault");116 await vault.ensureSettingsLoaded();117 });118119 async function dispatch(message: Message): Promise<MessageResponse> {120 if (!listener) throw new Error("listener not registered");121 return new Promise<MessageResponse>((resolve) => {122 const keepOpen = listener!(123 message,124 {125 id: "peach-extension",126 url: "chrome-extension://peach-extension/popup.html",127 } as chrome.runtime.MessageSender,128 resolve129 );130 expect(keepOpen).toBe(true);131 });132 }133134 async function setupUnlockedVault(password = "test-pass-12345") {135 const vault = await import("./vault");136 await vault.createVault(password);137 }138139 async function getWords(): Promise<string[]> {140 141 142 const r = await dispatch({143 type: "GET_RECOVERY_WORDS",144 payload: { password: "test-pass-12345" },145 });146 expect(r.ok).toBe(true);147 return (r as { data: string[] }).data;148 }149150 151152 describe("VALIDATE_RECOVERY_WORDS edge cases", () => {153 beforeEach(() => setupUnlockedVault());154155 it("words with leading/trailing whitespace are accepted", async () => {156 const words = await getWords();157 const padded = words.map((w) => " " + w + " ");158 const r = await dispatch({159 type: "VALIDATE_RECOVERY_WORDS",160 payload: { words: padded },161 });162 const data = (r as { data: { invalid: unknown[] } }).data;163 expect(data.invalid).toEqual([]);164 });165166 it("words in all uppercase are accepted (case-insensitive)", async () => {167 const words = await getWords();168 const r = await dispatch({169 type: "VALIDATE_RECOVERY_WORDS",170 payload: { words: words.map((w) => w.toUpperCase()) },171 });172 expect((r as { data: { invalid: unknown[] } }).data.invalid).toEqual([]);173 });174175 it("empty array yields ok response", async () => {176 const r = await dispatch({177 type: "VALIDATE_RECOVERY_WORDS",178 payload: { words: [] },179 });180 expect(r.ok).toBe(true);181 });182183 const invalidWordCases = [184 { name: "single garbage word", words: ["zzzzzz"] },185 { name: "many garbage words", words: ["aaa", "bbb", "ccc"] },186 { name: "empty string elements", words: ["", "", ""] },187 { name: "tabs only", words: ["\t", "\t\t"] },188 { name: "newlines only", words: ["\n", "\n\n"] },189 { name: "numeric strings", words: ["123", "456"] },190 { name: "special chars", words: ["@#$", "%^&"] },191 { name: "mixed valid+invalid", words: ["abandon", "notaword"] },192 ];193 for (const { name, words } of invalidWordCases) {194 it(`returns invalid markers for ${name}`, async () => {195 const r = await dispatch({196 type: "VALIDATE_RECOVERY_WORDS",197 payload: { words },198 });199 expect(r.ok).toBe(true);200 const data = (r as {201 data: { invalid: Array<{ index: number; word: string }> };202 }).data;203 204 const expected = words.filter(205 (w) => !WORD_LIST.includes((w ?? "").toLowerCase().trim())206 );207 expect(data.invalid.length).toBe(expected.length);208 });209 }210211 it("13 words: extra word is flagged as invalid", async () => {212 213 const words = [...(await getWords()), "qwerty"];214 const r = await dispatch({215 type: "VALIDATE_RECOVERY_WORDS",216 payload: { words },217 });218 const data = (r as {219 data: { invalid: Array<{ index: number; word: string }> };220 }).data;221 expect(data.invalid.some((x) => x.word === "qwerty")).toBe(true);222 });223224 it("11 words (missing): validator returns ok", async () => {225 const words = (await getWords()).slice(0, 11);226 const r = await dispatch({227 type: "VALIDATE_RECOVERY_WORDS",228 payload: { words },229 });230 231 expect(r.ok).toBe(true);232 });233234 235 for (let i = 0; i < 30; i++) {236 const sample = WORD_LIST[i * 50]!;237 it(`recognised single word ${sample}`, async () => {238 const r = await dispatch({239 type: "VALIDATE_RECOVERY_WORDS",240 payload: { words: [sample] },241 });242 expect((r as { data: { invalid: unknown[] } }).data.invalid).toEqual(243 []244 );245 });246 }247248 249 const adversarial = [250 null,251 undefined,252 "not-an-array",253 42,254 true,255 { words: "x" },256 [null, null],257 [{ word: "x" }, { word: "y" }],258 ["abandon", null as unknown as string, "abandon"],259 ];260 for (const [i, payload] of adversarial.entries()) {261 it(`adversarial payload #${i} does not crash`, async () => {262 const r = await dispatch({263 type: "VALIDATE_RECOVERY_WORDS",264 payload: payload as unknown as { words: string[] },265 });266 expect(r).toBeDefined();267 });268 }269 });270271 272273 describe("RECOVER_WITH_WORDS adversarial", () => {274 it("correct words in wrong order fails", async () => {275 await setupUnlockedVault();276 const words = await getWords();277 const vault = await import("./vault");278 vault.lock();279 const reversed = [...words].reverse();280 const r = await dispatch({281 type: "RECOVER_WITH_WORDS",282 payload: { words: reversed, newPassword: "new-pass" },283 });284 expect(r.ok).toBe(false);285 });286287 for (const pos of [0, 1, 2, 5, 7, 11]) {288 it(`one wrong word at position ${pos}: recovery fails`, async () => {289 await setupUnlockedVault();290 const words = await getWords();291 const vault = await import("./vault");292 vault.lock();293 const tampered = [...words];294 295 const alternative = WORD_LIST.find((w) => w !== words[pos])!;296 tampered[pos] = alternative;297 const r = await dispatch({298 type: "RECOVER_WITH_WORDS",299 payload: { words: tampered, newPassword: "recovered-pass" },300 });301 expect(r.ok).toBe(false);302 });303 }304305 it("all garbage words: recovery fails", async () => {306 await setupUnlockedVault();307 const vault = await import("./vault");308 vault.lock();309 const r = await dispatch({310 type: "RECOVER_WITH_WORDS",311 payload: {312 words: Array.from({ length: 12 }, () => "zzzzz"),313 newPassword: "recovered-pass",314 },315 });316 expect(r.ok).toBe(false);317 });318319 it("empty words array: recovery fails", async () => {320 await setupUnlockedVault();321 const vault = await import("./vault");322 vault.lock();323 const r = await dispatch({324 type: "RECOVER_WITH_WORDS",325 payload: { words: [], newPassword: "recovered-pass" },326 });327 expect(r.ok).toBe(false);328 });329330 it("correct words: recovery succeeds, new password works", async () => {331 await setupUnlockedVault();332 const words = await getWords();333 const vault = await import("./vault");334 vault.lock();335 const r = await dispatch({336 type: "RECOVER_WITH_WORDS",337 payload: { words, newPassword: "fresh-recovered-pass" },338 });339 expect(r.ok).toBe(true);340 vault.lock();341 const unlock = await dispatch({342 type: "UNLOCK",343 payload: { password: "fresh-recovered-pass" },344 });345 expect(unlock.ok).toBe(true);346 });347348 const newPasswordCases = [349 { name: "short", pw: "a" },350 { name: "long", pw: "x".repeat(500) },351 { name: "unicode", pw: "新パスワード🔑" },352 { name: "emoji", pw: "🔑🔒🌸" },353 { name: "spaces", pw: " " },354 { name: "control chars", pw: "a\t\n\rb" },355 { name: "RTL", pw: "كلمة المرور" },356 { name: "punctuation", pw: "!@#$%^&*()" },357 ];358 for (const { name, pw } of newPasswordCases) {359 it(`recovery sets ${name} new password; unlock works`, async () => {360 await setupUnlockedVault();361 const words = await getWords();362 const vault = await import("./vault");363 vault.lock();364 const r = await dispatch({365 type: "RECOVER_WITH_WORDS",366 payload: { words, newPassword: pw },367 });368 expect(r.ok).toBe(true);369 vault.lock();370 const u = await dispatch({371 type: "UNLOCK",372 payload: { password: pw },373 });374 expect(u.ok).toBe(true);375 });376 }377378 it("recovery, then old password fails", async () => {379 await setupUnlockedVault();380 const words = await getWords();381 const vault = await import("./vault");382 vault.lock();383 await dispatch({384 type: "RECOVER_WITH_WORDS",385 payload: { words, newPassword: "fresh-pass-XYZ" },386 });387 vault.lock();388 const old = await dispatch({389 type: "UNLOCK",390 payload: { password: "test-pass-12345" },391 });392 expect(old.ok).toBe(false);393 });394395 it("when no vault exists, recovery returns no vault", async () => {396 const vault = await import("./vault");397 vault.lock();398 const r = await dispatch({399 type: "RECOVER_WITH_WORDS",400 payload: {401 words: Array.from({ length: 12 }, (_, i) => WORD_LIST[i]!),402 newPassword: "x",403 },404 });405 expect(r.ok).toBe(false);406 });407408 409 const badPayloads = [410 null,411 undefined,412 {},413 { words: null },414 { words: [], newPassword: null },415 { words: "not-an-array", newPassword: "x" },416 { words: [42, 43, 44], newPassword: "x" },417 ];418 for (const [i, payload] of badPayloads.entries()) {419 it(`adversarial recovery payload #${i} does not crash`, async () => {420 await setupUnlockedVault();421 const vault = await import("./vault");422 vault.lock();423 const r = await dispatch({424 type: "RECOVER_WITH_WORDS",425 payload: payload as unknown as {426 words: string[];427 newPassword: string;428 },429 });430 expect(r).toBeDefined();431 expect(r.ok).toBe(false);432 });433 }434 });435436 437438 describe("BIP39 membership sweep (parameterized)", () => {439 440 for (let i = 0; i < 100; i++) {441 const idx = (i * 20) % WORD_LIST.length;442 const word = WORD_LIST[idx]!;443 it(`word at index ${idx} (${word}) validates clean`, async () => {444 await setupUnlockedVault();445 const r = await dispatch({446 type: "VALIDATE_RECOVERY_WORDS",447 payload: { words: [word] },448 });449 expect((r as { data: { invalid: unknown[] } }).data.invalid).toEqual(450 []451 );452 });453 }454455 456 const garbageWords = [457 "zzz",458 "qqq",459 "xyz",460 "123",461 "abc",462 "yyy",463 "fakeword",464 "notinlist",465 "garbagestring",466 "definitelynotaword",467 ];468 for (const word of garbageWords) {469 it(`garbage word "${word}" is flagged`, async () => {470 await setupUnlockedVault();471 const r = await dispatch({472 type: "VALIDATE_RECOVERY_WORDS",473 payload: { words: [word] },474 });475 const data = (476 r as { data: { invalid: Array<{ word: string }> } }477 ).data;478 expect(data.invalid.length).toBe(1);479 });480 }481 });482483 484485 describe("tamper coverage at every position", () => {486 for (let pos = 0; pos < 12; pos++) {487 it(`replace position ${pos} with garbage: recovery fails`, async () => {488 await setupUnlockedVault();489 const words = await getWords();490 const vault = await import("./vault");491 vault.lock();492 const tampered = [...words];493 tampered[pos] = "zzz";494 const r = await dispatch({495 type: "RECOVER_WITH_WORDS",496 payload: { words: tampered, newPassword: "new" },497 });498 expect(r.ok).toBe(false);499 });500501 it(`replace position ${pos} with valid-but-wrong word: recovery fails`, async () => {502 await setupUnlockedVault();503 const words = await getWords();504 const vault = await import("./vault");505 vault.lock();506 const tampered = [...words];507 tampered[pos] = WORD_LIST.find((w) => w !== words[pos])!;508 const r = await dispatch({509 type: "RECOVER_WITH_WORDS",510 payload: { words: tampered, newPassword: "new" },511 });512 expect(r.ok).toBe(false);513 });514 }515 });516517 518519 describe("GET_RECOVERY_WORDS adversarial", () => {520 it("returns ok false when locked", async () => {521 await setupUnlockedVault();522 const vault = await import("./vault");523 vault.lock();524 const r = await dispatch({ type: "GET_RECOVERY_WORDS" });525 expect(r.ok).toBe(false);526 });527528 it("returns exactly 12 words when unlocked", async () => {529 await setupUnlockedVault();530 const words = await getWords();531 expect(words).toHaveLength(12);532 });533534 it("returned words are all in BIP39", async () => {535 await setupUnlockedVault();536 const words = await getWords();537 for (const w of words) {538 expect(WORD_LIST).toContain(w);539 }540 });541542 for (let i = 0; i < 5; i++) {543 it(`deterministic across repeated calls (round ${i})`, async () => {544 await setupUnlockedVault();545 const a = await getWords();546 const b = await getWords();547 expect(a).toEqual(b);548 });549 }550 });551552 553554 describe("recovery interaction with password change cycle", () => {555 556 557 558559 it("original recovery words still work after a password change", async () => {560 await setupUnlockedVault();561 const oldWords = await getWords();562 const vault = await import("./vault");563 await vault.changePassword("test-pass-12345", "second-pass-XYZ");564 vault.lock();565 const r = await dispatch({566 type: "RECOVER_WITH_WORDS",567 payload: { words: oldWords, newPassword: "post-change-pass" },568 });569 expect(r.ok).toBe(true);570 });571572 for (const cycles of [1, 2, 3]) {573 it(`original recovery words still work after ${cycles} password change(s)`, async () => {574 await setupUnlockedVault();575 const originalWords = await getWords();576 const vault = await import("./vault");577 let cur = "test-pass-12345";578 for (let i = 0; i < cycles; i++) {579 const next = `pass-cycle-${i}`;580 await vault.changePassword(cur, next);581 cur = next;582 }583 vault.lock();584 const r = await dispatch({585 type: "RECOVER_WITH_WORDS",586 payload: { words: originalWords, newPassword: "after-cycles" },587 });588 expect(r.ok).toBe(true);589 });590 }591 });592});