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 const r = await dispatch({ type: "GET_RECOVERY_WORDS" });137 expect(r.ok).toBe(true);138 return (r as { data: string[] }).data;139 }140141 142143 describe("VALIDATE_RECOVERY_WORDS edge cases", () => {144 beforeEach(setupUnlockedVault);145146 it("words with leading/trailing whitespace are accepted", async () => {147 const words = await getWords();148 const padded = words.map((w) => " " + w + " ");149 const r = await dispatch({150 type: "VALIDATE_RECOVERY_WORDS",151 payload: { words: padded },152 });153 const data = (r as { data: { invalid: unknown[] } }).data;154 expect(data.invalid).toEqual([]);155 });156157 it("words in all uppercase are accepted (case-insensitive)", async () => {158 const words = await getWords();159 const r = await dispatch({160 type: "VALIDATE_RECOVERY_WORDS",161 payload: { words: words.map((w) => w.toUpperCase()) },162 });163 expect((r as { data: { invalid: unknown[] } }).data.invalid).toEqual([]);164 });165166 it("empty array yields ok response", async () => {167 const r = await dispatch({168 type: "VALIDATE_RECOVERY_WORDS",169 payload: { words: [] },170 });171 expect(r.ok).toBe(true);172 });173174 const invalidWordCases = [175 { name: "single garbage word", words: ["zzzzzz"] },176 { name: "many garbage words", words: ["aaa", "bbb", "ccc"] },177 { name: "empty string elements", words: ["", "", ""] },178 { name: "tabs only", words: ["\t", "\t\t"] },179 { name: "newlines only", words: ["\n", "\n\n"] },180 { name: "numeric strings", words: ["123", "456"] },181 { name: "special chars", words: ["@#$", "%^&"] },182 { name: "mixed valid+invalid", words: ["abandon", "notaword"] },183 ];184 for (const { name, words } of invalidWordCases) {185 it(`returns invalid markers for ${name}`, async () => {186 const r = await dispatch({187 type: "VALIDATE_RECOVERY_WORDS",188 payload: { words },189 });190 expect(r.ok).toBe(true);191 const data = (r as {192 data: { invalid: Array<{ index: number; word: string }> };193 }).data;194 195 const expected = words.filter(196 (w) => !WORD_LIST.includes((w ?? "").toLowerCase().trim())197 );198 expect(data.invalid.length).toBe(expected.length);199 });200 }201202 it("13 words: extra word is flagged as invalid", async () => {203 const words = [...(await getWords()), "extra"];204 const r = await dispatch({205 type: "VALIDATE_RECOVERY_WORDS",206 payload: { words },207 });208 const data = (r as {209 data: { invalid: Array<{ index: number; word: string }> };210 }).data;211 expect(data.invalid.some((x) => x.word === "extra")).toBe(true);212 });213214 it("11 words (missing): validator returns ok", async () => {215 const words = (await getWords()).slice(0, 11);216 const r = await dispatch({217 type: "VALIDATE_RECOVERY_WORDS",218 payload: { words },219 });220 221 expect(r.ok).toBe(true);222 });223224 225 for (let i = 0; i < 30; i++) {226 const sample = WORD_LIST[i * 50]!;227 it(`recognised single word ${sample}`, async () => {228 const r = await dispatch({229 type: "VALIDATE_RECOVERY_WORDS",230 payload: { words: [sample] },231 });232 expect((r as { data: { invalid: unknown[] } }).data.invalid).toEqual(233 []234 );235 });236 }237238 239 const adversarial = [240 null,241 undefined,242 "not-an-array",243 42,244 true,245 { words: "x" },246 [null, null],247 [{ word: "x" }, { word: "y" }],248 ["abandon", null as unknown as string, "abandon"],249 ];250 for (const [i, payload] of adversarial.entries()) {251 it(`adversarial payload #${i} does not crash`, async () => {252 const r = await dispatch({253 type: "VALIDATE_RECOVERY_WORDS",254 payload: payload as unknown as { words: string[] },255 });256 expect(r).toBeDefined();257 });258 }259 });260261 262263 describe("RECOVER_WITH_WORDS adversarial", () => {264 it("correct words in wrong order fails", async () => {265 await setupUnlockedVault();266 const words = await getWords();267 const vault = await import("./vault");268 vault.lock();269 const reversed = [...words].reverse();270 const r = await dispatch({271 type: "RECOVER_WITH_WORDS",272 payload: { words: reversed, newPassword: "new-pass" },273 });274 expect(r.ok).toBe(false);275 });276277 for (const pos of [0, 1, 2, 5, 7, 11]) {278 it(`one wrong word at position ${pos}: recovery fails`, async () => {279 await setupUnlockedVault();280 const words = await getWords();281 const vault = await import("./vault");282 vault.lock();283 const tampered = [...words];284 285 const alternative = WORD_LIST.find((w) => w !== words[pos])!;286 tampered[pos] = alternative;287 const r = await dispatch({288 type: "RECOVER_WITH_WORDS",289 payload: { words: tampered, newPassword: "recovered-pass" },290 });291 expect(r.ok).toBe(false);292 });293 }294295 it("all garbage words: recovery fails", async () => {296 await setupUnlockedVault();297 const vault = await import("./vault");298 vault.lock();299 const r = await dispatch({300 type: "RECOVER_WITH_WORDS",301 payload: {302 words: Array.from({ length: 12 }, () => "zzzzz"),303 newPassword: "recovered-pass",304 },305 });306 expect(r.ok).toBe(false);307 });308309 it("empty words array: recovery fails", async () => {310 await setupUnlockedVault();311 const vault = await import("./vault");312 vault.lock();313 const r = await dispatch({314 type: "RECOVER_WITH_WORDS",315 payload: { words: [], newPassword: "recovered-pass" },316 });317 expect(r.ok).toBe(false);318 });319320 it("correct words: recovery succeeds, new password works", async () => {321 await setupUnlockedVault();322 const words = await getWords();323 const vault = await import("./vault");324 vault.lock();325 const r = await dispatch({326 type: "RECOVER_WITH_WORDS",327 payload: { words, newPassword: "fresh-recovered-pass" },328 });329 expect(r.ok).toBe(true);330 vault.lock();331 const unlock = await dispatch({332 type: "UNLOCK",333 payload: { password: "fresh-recovered-pass" },334 });335 expect(unlock.ok).toBe(true);336 });337338 const newPasswordCases = [339 { name: "short", pw: "a" },340 { name: "long", pw: "x".repeat(500) },341 { name: "unicode", pw: "新パスワード🔑" },342 { name: "emoji", pw: "🔑🔒🌸" },343 { name: "spaces", pw: " " },344 { name: "control chars", pw: "a\t\n\rb" },345 { name: "RTL", pw: "كلمة المرور" },346 { name: "punctuation", pw: "!@#$%^&*()" },347 ];348 for (const { name, pw } of newPasswordCases) {349 it(`recovery sets ${name} new password; unlock works`, async () => {350 await setupUnlockedVault();351 const words = await getWords();352 const vault = await import("./vault");353 vault.lock();354 const r = await dispatch({355 type: "RECOVER_WITH_WORDS",356 payload: { words, newPassword: pw },357 });358 expect(r.ok).toBe(true);359 vault.lock();360 const u = await dispatch({361 type: "UNLOCK",362 payload: { password: pw },363 });364 expect(u.ok).toBe(true);365 });366 }367368 it("recovery, then old password fails", async () => {369 await setupUnlockedVault();370 const words = await getWords();371 const vault = await import("./vault");372 vault.lock();373 await dispatch({374 type: "RECOVER_WITH_WORDS",375 payload: { words, newPassword: "fresh-pass-XYZ" },376 });377 vault.lock();378 const old = await dispatch({379 type: "UNLOCK",380 payload: { password: "test-pass-12345" },381 });382 expect(old.ok).toBe(false);383 });384385 it("when no vault exists, recovery returns no vault", async () => {386 const vault = await import("./vault");387 vault.lock();388 const r = await dispatch({389 type: "RECOVER_WITH_WORDS",390 payload: {391 words: Array.from({ length: 12 }, (_, i) => WORD_LIST[i]!),392 newPassword: "x",393 },394 });395 expect(r.ok).toBe(false);396 });397398 399 const badPayloads = [400 null,401 undefined,402 {},403 { words: null },404 { words: [], newPassword: null },405 { words: "not-an-array", newPassword: "x" },406 { words: [42, 43, 44], newPassword: "x" },407 ];408 for (const [i, payload] of badPayloads.entries()) {409 it(`adversarial recovery payload #${i} does not crash`, async () => {410 await setupUnlockedVault();411 const vault = await import("./vault");412 vault.lock();413 const r = await dispatch({414 type: "RECOVER_WITH_WORDS",415 payload: payload as unknown as {416 words: string[];417 newPassword: string;418 },419 });420 expect(r).toBeDefined();421 expect(r.ok).toBe(false);422 });423 }424 });425426 427428 describe("BIP39 membership sweep (parameterized)", () => {429 430 for (let i = 0; i < 100; i++) {431 const idx = (i * 20) % WORD_LIST.length;432 const word = WORD_LIST[idx]!;433 it(`word at index ${idx} (${word}) validates clean`, async () => {434 await setupUnlockedVault();435 const r = await dispatch({436 type: "VALIDATE_RECOVERY_WORDS",437 payload: { words: [word] },438 });439 expect((r as { data: { invalid: unknown[] } }).data.invalid).toEqual(440 []441 );442 });443 }444445 446 const garbageWords = [447 "zzz",448 "qqq",449 "xyz",450 "123",451 "abc",452 "yyy",453 "fakeword",454 "notinlist",455 "garbagestring",456 "definitelynotaword",457 ];458 for (const word of garbageWords) {459 it(`garbage word "${word}" is flagged`, async () => {460 await setupUnlockedVault();461 const r = await dispatch({462 type: "VALIDATE_RECOVERY_WORDS",463 payload: { words: [word] },464 });465 const data = (466 r as { data: { invalid: Array<{ word: string }> } }467 ).data;468 expect(data.invalid.length).toBe(1);469 });470 }471 });472473 474475 describe("tamper coverage at every position", () => {476 for (let pos = 0; pos < 12; pos++) {477 it(`replace position ${pos} with garbage: recovery fails`, async () => {478 await setupUnlockedVault();479 const words = await getWords();480 const vault = await import("./vault");481 vault.lock();482 const tampered = [...words];483 tampered[pos] = "zzz";484 const r = await dispatch({485 type: "RECOVER_WITH_WORDS",486 payload: { words: tampered, newPassword: "new" },487 });488 expect(r.ok).toBe(false);489 });490491 it(`replace position ${pos} with valid-but-wrong word: recovery fails`, async () => {492 await setupUnlockedVault();493 const words = await getWords();494 const vault = await import("./vault");495 vault.lock();496 const tampered = [...words];497 tampered[pos] = WORD_LIST.find((w) => w !== words[pos])!;498 const r = await dispatch({499 type: "RECOVER_WITH_WORDS",500 payload: { words: tampered, newPassword: "new" },501 });502 expect(r.ok).toBe(false);503 });504 }505 });506507 508509 describe("GET_RECOVERY_WORDS adversarial", () => {510 it("returns ok false when locked", async () => {511 await setupUnlockedVault();512 const vault = await import("./vault");513 vault.lock();514 const r = await dispatch({ type: "GET_RECOVERY_WORDS" });515 expect(r.ok).toBe(false);516 });517518 it("returns exactly 12 words when unlocked", async () => {519 await setupUnlockedVault();520 const words = await getWords();521 expect(words).toHaveLength(12);522 });523524 it("returned words are all in BIP39", async () => {525 await setupUnlockedVault();526 const words = await getWords();527 for (const w of words) {528 expect(WORD_LIST).toContain(w);529 }530 });531532 for (let i = 0; i < 5; i++) {533 it(`deterministic across repeated calls (round ${i})`, async () => {534 await setupUnlockedVault();535 const a = await getWords();536 const b = await getWords();537 expect(a).toEqual(b);538 });539 }540 });541542 543544 describe("recovery interaction with password change cycle", () => {545 546 547 548549 it("old recovery words are invalid after a password change (root key rotated)", async () => {550 await setupUnlockedVault();551 const oldWords = await getWords();552 const vault = await import("./vault");553 await vault.changePassword("test-pass-12345", "second-pass-XYZ");554 vault.lock();555 const r = await dispatch({556 type: "RECOVER_WITH_WORDS",557 payload: { words: oldWords, newPassword: "should-not-work" },558 });559 expect(r.ok).toBe(false);560 });561562 for (const cycles of [1, 2, 3]) {563 it(`latest recovery words work after ${cycles} password change(s)`, async () => {564 await setupUnlockedVault();565 const vault = await import("./vault");566 let cur = "test-pass-12345";567 for (let i = 0; i < cycles; i++) {568 const next = `pass-cycle-${i}`;569 await vault.changePassword(cur, next);570 cur = next;571 }572 573 const latest = await getWords();574 vault.lock();575 const r = await dispatch({576 type: "RECOVER_WITH_WORDS",577 payload: { words: latest, newPassword: "after-cycles" },578 });579 expect(r.ok).toBe(true);580 });581 }582 });583});