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 onMessage: {43 addListener: vi.fn((fn: MessageListener) => {44 listener = fn;45 }),46 },47 sendMessage: vi.fn(async () => ({ ok: true })),48 },49 tabs: {50 query: vi.fn((_q: unknown, cb?: (tabs: unknown[]) => void) => {51 if (typeof cb === "function") {52 cb([{ id: 1 }]);53 return;54 }55 return Promise.resolve([{ id: 1 }]);56 }),57 sendMessage: vi.fn(async () => ({ ok: true })),58 },59 storage: {60 local: {61 get: vi.fn(async (key?: string | string[]) => {62 if (!key) return storage;63 if (typeof key === "string") return { [key]: storage[key] };64 return key.reduce<Record<string, unknown>>((acc, k) => {65 acc[k] = storage[k];66 return acc;67 }, {});68 }),69 set: vi.fn(async (next: Record<string, unknown>) => {70 Object.assign(storage, next);71 }),72 remove: vi.fn(async (keys: string | string[]) => {73 const list = Array.isArray(keys) ? keys : [keys];74 for (const key of list) delete storage[key];75 }),76 },77 session: {78 get: vi.fn(async (key?: string | string[]) => {79 if (!key) return sessionStorage;80 if (typeof key === "string") return { [key]: sessionStorage[key] };81 return key.reduce<Record<string, unknown>>((acc, k) => {82 acc[k] = sessionStorage[k];83 return acc;84 }, {});85 }),86 set: vi.fn(async (next: Record<string, unknown>) => {87 Object.assign(sessionStorage, next);88 }),89 remove: vi.fn(async (keys: string | string[]) => {90 const list = Array.isArray(keys) ? keys : [keys];91 for (const key of list) delete sessionStorage[key];92 }),93 clear: vi.fn(async () => {94 for (const k of Object.keys(sessionStorage)) delete sessionStorage[k];95 }),96 },97 },98 alarms: {99 clear: vi.fn(),100 create: vi.fn(),101 onAlarm: { addListener: vi.fn() },102 },103 idle: {104 setDetectionInterval: vi.fn(),105 onStateChanged: { addListener: vi.fn() },106 },107 scripting: { executeScript: vi.fn(async () => []) },108 } as unknown as typeof chrome;109110 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;111112 const messages = await import("./messages");113 messages.setupMessageHandler();114 const vault = await import("./vault");115 await vault.ensureSettingsLoaded();116 });117118 async function dispatch(message: Message): Promise<MessageResponse> {119 if (!listener) throw new Error("listener not registered");120 return new Promise<MessageResponse>((resolve) => {121 const keepOpen = listener!(122 message,123 { id: "peach-extension" } as chrome.runtime.MessageSender,124 resolve125 );126 expect(keepOpen).toBe(true);127 });128 }129130 async function setupUnlockedVault(password = "test-pass-12345") {131 const vault = await import("./vault");132 await vault.createVault(password);133 }134135 async function getWords(): Promise<string[]> {136 137 138 const r = await dispatch({139 type: "GET_RECOVERY_WORDS",140 payload: { password: "test-pass-12345" },141 });142 expect(r.ok).toBe(true);143 return (r as { data: string[] }).data;144 }145146 147148 describe("VALIDATE_RECOVERY_WORDS edge cases", () => {149 beforeEach(() => setupUnlockedVault());150151 it("words with leading/trailing whitespace are accepted", async () => {152 const words = await getWords();153 const padded = words.map((w) => " " + w + " ");154 const r = await dispatch({155 type: "VALIDATE_RECOVERY_WORDS",156 payload: { words: padded },157 });158 const data = (r as { data: { invalid: unknown[] } }).data;159 expect(data.invalid).toEqual([]);160 });161162 it("words in all uppercase are accepted (case-insensitive)", async () => {163 const words = await getWords();164 const r = await dispatch({165 type: "VALIDATE_RECOVERY_WORDS",166 payload: { words: words.map((w) => w.toUpperCase()) },167 });168 expect((r as { data: { invalid: unknown[] } }).data.invalid).toEqual([]);169 });170171 it("empty array yields ok response", async () => {172 const r = await dispatch({173 type: "VALIDATE_RECOVERY_WORDS",174 payload: { words: [] },175 });176 expect(r.ok).toBe(true);177 });178179 const invalidWordCases = [180 { name: "single garbage word", words: ["zzzzzz"] },181 { name: "many garbage words", words: ["aaa", "bbb", "ccc"] },182 { name: "empty string elements", words: ["", "", ""] },183 { name: "tabs only", words: ["\t", "\t\t"] },184 { name: "newlines only", words: ["\n", "\n\n"] },185 { name: "numeric strings", words: ["123", "456"] },186 { name: "special chars", words: ["@#$", "%^&"] },187 { name: "mixed valid+invalid", words: ["abandon", "notaword"] },188 ];189 for (const { name, words } of invalidWordCases) {190 it(`returns invalid markers for ${name}`, async () => {191 const r = await dispatch({192 type: "VALIDATE_RECOVERY_WORDS",193 payload: { words },194 });195 expect(r.ok).toBe(true);196 const data = (r as {197 data: { invalid: Array<{ index: number; word: string }> };198 }).data;199 200 const expected = words.filter(201 (w) => !WORD_LIST.includes((w ?? "").toLowerCase().trim())202 );203 expect(data.invalid.length).toBe(expected.length);204 });205 }206207 it("13 words: extra word is flagged as invalid", async () => {208 209 const words = [...(await getWords()), "qwerty"];210 const r = await dispatch({211 type: "VALIDATE_RECOVERY_WORDS",212 payload: { words },213 });214 const data = (r as {215 data: { invalid: Array<{ index: number; word: string }> };216 }).data;217 expect(data.invalid.some((x) => x.word === "qwerty")).toBe(true);218 });219220 it("11 words (missing): validator returns ok", async () => {221 const words = (await getWords()).slice(0, 11);222 const r = await dispatch({223 type: "VALIDATE_RECOVERY_WORDS",224 payload: { words },225 });226 227 expect(r.ok).toBe(true);228 });229230 231 for (let i = 0; i < 30; i++) {232 const sample = WORD_LIST[i * 50]!;233 it(`recognised single word ${sample}`, async () => {234 const r = await dispatch({235 type: "VALIDATE_RECOVERY_WORDS",236 payload: { words: [sample] },237 });238 expect((r as { data: { invalid: unknown[] } }).data.invalid).toEqual(239 []240 );241 });242 }243244 245 const adversarial = [246 null,247 undefined,248 "not-an-array",249 42,250 true,251 { words: "x" },252 [null, null],253 [{ word: "x" }, { word: "y" }],254 ["abandon", null as unknown as string, "abandon"],255 ];256 for (const [i, payload] of adversarial.entries()) {257 it(`adversarial payload #${i} does not crash`, async () => {258 const r = await dispatch({259 type: "VALIDATE_RECOVERY_WORDS",260 payload: payload as unknown as { words: string[] },261 });262 expect(r).toBeDefined();263 });264 }265 });266267 268269 describe("RECOVER_WITH_WORDS adversarial", () => {270 it("correct words in wrong order fails", async () => {271 await setupUnlockedVault();272 const words = await getWords();273 const vault = await import("./vault");274 vault.lock();275 const reversed = [...words].reverse();276 const r = await dispatch({277 type: "RECOVER_WITH_WORDS",278 payload: { words: reversed, newPassword: "new-pass" },279 });280 expect(r.ok).toBe(false);281 });282283 for (const pos of [0, 1, 2, 5, 7, 11]) {284 it(`one wrong word at position ${pos}: recovery fails`, async () => {285 await setupUnlockedVault();286 const words = await getWords();287 const vault = await import("./vault");288 vault.lock();289 const tampered = [...words];290 291 const alternative = WORD_LIST.find((w) => w !== words[pos])!;292 tampered[pos] = alternative;293 const r = await dispatch({294 type: "RECOVER_WITH_WORDS",295 payload: { words: tampered, newPassword: "recovered-pass" },296 });297 expect(r.ok).toBe(false);298 });299 }300301 it("all garbage words: recovery fails", async () => {302 await setupUnlockedVault();303 const vault = await import("./vault");304 vault.lock();305 const r = await dispatch({306 type: "RECOVER_WITH_WORDS",307 payload: {308 words: Array.from({ length: 12 }, () => "zzzzz"),309 newPassword: "recovered-pass",310 },311 });312 expect(r.ok).toBe(false);313 });314315 it("empty words array: recovery fails", async () => {316 await setupUnlockedVault();317 const vault = await import("./vault");318 vault.lock();319 const r = await dispatch({320 type: "RECOVER_WITH_WORDS",321 payload: { words: [], newPassword: "recovered-pass" },322 });323 expect(r.ok).toBe(false);324 });325326 it("correct words: recovery succeeds, new password works", async () => {327 await setupUnlockedVault();328 const words = await getWords();329 const vault = await import("./vault");330 vault.lock();331 const r = await dispatch({332 type: "RECOVER_WITH_WORDS",333 payload: { words, newPassword: "fresh-recovered-pass" },334 });335 expect(r.ok).toBe(true);336 vault.lock();337 const unlock = await dispatch({338 type: "UNLOCK",339 payload: { password: "fresh-recovered-pass" },340 });341 expect(unlock.ok).toBe(true);342 });343344 const newPasswordCases = [345 { name: "short", pw: "a" },346 { name: "long", pw: "x".repeat(500) },347 { name: "unicode", pw: "新パスワード🔑" },348 { name: "emoji", pw: "🔑🔒🌸" },349 { name: "spaces", pw: " " },350 { name: "control chars", pw: "a\t\n\rb" },351 { name: "RTL", pw: "كلمة المرور" },352 { name: "punctuation", pw: "!@#$%^&*()" },353 ];354 for (const { name, pw } of newPasswordCases) {355 it(`recovery sets ${name} new password; unlock works`, async () => {356 await setupUnlockedVault();357 const words = await getWords();358 const vault = await import("./vault");359 vault.lock();360 const r = await dispatch({361 type: "RECOVER_WITH_WORDS",362 payload: { words, newPassword: pw },363 });364 expect(r.ok).toBe(true);365 vault.lock();366 const u = await dispatch({367 type: "UNLOCK",368 payload: { password: pw },369 });370 expect(u.ok).toBe(true);371 });372 }373374 it("recovery, then old password fails", async () => {375 await setupUnlockedVault();376 const words = await getWords();377 const vault = await import("./vault");378 vault.lock();379 await dispatch({380 type: "RECOVER_WITH_WORDS",381 payload: { words, newPassword: "fresh-pass-XYZ" },382 });383 vault.lock();384 const old = await dispatch({385 type: "UNLOCK",386 payload: { password: "test-pass-12345" },387 });388 expect(old.ok).toBe(false);389 });390391 it("when no vault exists, recovery returns no vault", async () => {392 const vault = await import("./vault");393 vault.lock();394 const r = await dispatch({395 type: "RECOVER_WITH_WORDS",396 payload: {397 words: Array.from({ length: 12 }, (_, i) => WORD_LIST[i]!),398 newPassword: "x",399 },400 });401 expect(r.ok).toBe(false);402 });403404 405 const badPayloads = [406 null,407 undefined,408 {},409 { words: null },410 { words: [], newPassword: null },411 { words: "not-an-array", newPassword: "x" },412 { words: [42, 43, 44], newPassword: "x" },413 ];414 for (const [i, payload] of badPayloads.entries()) {415 it(`adversarial recovery payload #${i} does not crash`, async () => {416 await setupUnlockedVault();417 const vault = await import("./vault");418 vault.lock();419 const r = await dispatch({420 type: "RECOVER_WITH_WORDS",421 payload: payload as unknown as {422 words: string[];423 newPassword: string;424 },425 });426 expect(r).toBeDefined();427 expect(r.ok).toBe(false);428 });429 }430 });431432 433434 describe("BIP39 membership sweep (parameterized)", () => {435 436 for (let i = 0; i < 100; i++) {437 const idx = (i * 20) % WORD_LIST.length;438 const word = WORD_LIST[idx]!;439 it(`word at index ${idx} (${word}) validates clean`, async () => {440 await setupUnlockedVault();441 const r = await dispatch({442 type: "VALIDATE_RECOVERY_WORDS",443 payload: { words: [word] },444 });445 expect((r as { data: { invalid: unknown[] } }).data.invalid).toEqual(446 []447 );448 });449 }450451 452 const garbageWords = [453 "zzz",454 "qqq",455 "xyz",456 "123",457 "abc",458 "yyy",459 "fakeword",460 "notinlist",461 "garbagestring",462 "definitelynotaword",463 ];464 for (const word of garbageWords) {465 it(`garbage word "${word}" is flagged`, async () => {466 await setupUnlockedVault();467 const r = await dispatch({468 type: "VALIDATE_RECOVERY_WORDS",469 payload: { words: [word] },470 });471 const data = (472 r as { data: { invalid: Array<{ word: string }> } }473 ).data;474 expect(data.invalid.length).toBe(1);475 });476 }477 });478479 480481 describe("tamper coverage at every position", () => {482 for (let pos = 0; pos < 12; pos++) {483 it(`replace position ${pos} with garbage: recovery fails`, async () => {484 await setupUnlockedVault();485 const words = await getWords();486 const vault = await import("./vault");487 vault.lock();488 const tampered = [...words];489 tampered[pos] = "zzz";490 const r = await dispatch({491 type: "RECOVER_WITH_WORDS",492 payload: { words: tampered, newPassword: "new" },493 });494 expect(r.ok).toBe(false);495 });496497 it(`replace position ${pos} with valid-but-wrong word: recovery fails`, async () => {498 await setupUnlockedVault();499 const words = await getWords();500 const vault = await import("./vault");501 vault.lock();502 const tampered = [...words];503 tampered[pos] = WORD_LIST.find((w) => w !== words[pos])!;504 const r = await dispatch({505 type: "RECOVER_WITH_WORDS",506 payload: { words: tampered, newPassword: "new" },507 });508 expect(r.ok).toBe(false);509 });510 }511 });512513 514515 describe("GET_RECOVERY_WORDS adversarial", () => {516 it("returns ok false when locked", async () => {517 await setupUnlockedVault();518 const vault = await import("./vault");519 vault.lock();520 const r = await dispatch({ type: "GET_RECOVERY_WORDS" });521 expect(r.ok).toBe(false);522 });523524 it("returns exactly 12 words when unlocked", async () => {525 await setupUnlockedVault();526 const words = await getWords();527 expect(words).toHaveLength(12);528 });529530 it("returned words are all in BIP39", async () => {531 await setupUnlockedVault();532 const words = await getWords();533 for (const w of words) {534 expect(WORD_LIST).toContain(w);535 }536 });537538 for (let i = 0; i < 5; i++) {539 it(`deterministic across repeated calls (round ${i})`, async () => {540 await setupUnlockedVault();541 const a = await getWords();542 const b = await getWords();543 expect(a).toEqual(b);544 });545 }546 });547548 549550 describe("recovery interaction with password change cycle", () => {551 552 553 554555 it("original recovery words still work after a password change", async () => {556 await setupUnlockedVault();557 const oldWords = await getWords();558 const vault = await import("./vault");559 await vault.changePassword("test-pass-12345", "second-pass-XYZ");560 vault.lock();561 const r = await dispatch({562 type: "RECOVER_WITH_WORDS",563 payload: { words: oldWords, newPassword: "post-change-pass" },564 });565 expect(r.ok).toBe(true);566 });567568 for (const cycles of [1, 2, 3]) {569 it(`original recovery words still work after ${cycles} password change(s)`, async () => {570 await setupUnlockedVault();571 const originalWords = await getWords();572 const vault = await import("./vault");573 let cur = "test-pass-12345";574 for (let i = 0; i < cycles; i++) {575 const next = `pass-cycle-${i}`;576 await vault.changePassword(cur, next);577 cur = next;578 }579 vault.lock();580 const r = await dispatch({581 type: "RECOVER_WITH_WORDS",582 payload: { words: originalWords, newPassword: "after-cycles" },583 });584 expect(r.ok).toBe(true);585 });586 }587 });588});