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 204 const words = [...(await getWords()), "qwerty"];205 const r = await dispatch({206 type: "VALIDATE_RECOVERY_WORDS",207 payload: { words },208 });209 const data = (r as {210 data: { invalid: Array<{ index: number; word: string }> };211 }).data;212 expect(data.invalid.some((x) => x.word === "qwerty")).toBe(true);213 });214215 it("11 words (missing): validator returns ok", async () => {216 const words = (await getWords()).slice(0, 11);217 const r = await dispatch({218 type: "VALIDATE_RECOVERY_WORDS",219 payload: { words },220 });221 222 expect(r.ok).toBe(true);223 });224225 226 for (let i = 0; i < 30; i++) {227 const sample = WORD_LIST[i * 50]!;228 it(`recognised single word ${sample}`, async () => {229 const r = await dispatch({230 type: "VALIDATE_RECOVERY_WORDS",231 payload: { words: [sample] },232 });233 expect((r as { data: { invalid: unknown[] } }).data.invalid).toEqual(234 []235 );236 });237 }238239 240 const adversarial = [241 null,242 undefined,243 "not-an-array",244 42,245 true,246 { words: "x" },247 [null, null],248 [{ word: "x" }, { word: "y" }],249 ["abandon", null as unknown as string, "abandon"],250 ];251 for (const [i, payload] of adversarial.entries()) {252 it(`adversarial payload #${i} does not crash`, async () => {253 const r = await dispatch({254 type: "VALIDATE_RECOVERY_WORDS",255 payload: payload as unknown as { words: string[] },256 });257 expect(r).toBeDefined();258 });259 }260 });261262 263264 describe("RECOVER_WITH_WORDS adversarial", () => {265 it("correct words in wrong order fails", async () => {266 await setupUnlockedVault();267 const words = await getWords();268 const vault = await import("./vault");269 vault.lock();270 const reversed = [...words].reverse();271 const r = await dispatch({272 type: "RECOVER_WITH_WORDS",273 payload: { words: reversed, newPassword: "new-pass" },274 });275 expect(r.ok).toBe(false);276 });277278 for (const pos of [0, 1, 2, 5, 7, 11]) {279 it(`one wrong word at position ${pos}: recovery fails`, async () => {280 await setupUnlockedVault();281 const words = await getWords();282 const vault = await import("./vault");283 vault.lock();284 const tampered = [...words];285 286 const alternative = WORD_LIST.find((w) => w !== words[pos])!;287 tampered[pos] = alternative;288 const r = await dispatch({289 type: "RECOVER_WITH_WORDS",290 payload: { words: tampered, newPassword: "recovered-pass" },291 });292 expect(r.ok).toBe(false);293 });294 }295296 it("all garbage words: recovery fails", async () => {297 await setupUnlockedVault();298 const vault = await import("./vault");299 vault.lock();300 const r = await dispatch({301 type: "RECOVER_WITH_WORDS",302 payload: {303 words: Array.from({ length: 12 }, () => "zzzzz"),304 newPassword: "recovered-pass",305 },306 });307 expect(r.ok).toBe(false);308 });309310 it("empty words array: recovery fails", async () => {311 await setupUnlockedVault();312 const vault = await import("./vault");313 vault.lock();314 const r = await dispatch({315 type: "RECOVER_WITH_WORDS",316 payload: { words: [], newPassword: "recovered-pass" },317 });318 expect(r.ok).toBe(false);319 });320321 it("correct words: recovery succeeds, new password works", async () => {322 await setupUnlockedVault();323 const words = await getWords();324 const vault = await import("./vault");325 vault.lock();326 const r = await dispatch({327 type: "RECOVER_WITH_WORDS",328 payload: { words, newPassword: "fresh-recovered-pass" },329 });330 expect(r.ok).toBe(true);331 vault.lock();332 const unlock = await dispatch({333 type: "UNLOCK",334 payload: { password: "fresh-recovered-pass" },335 });336 expect(unlock.ok).toBe(true);337 });338339 const newPasswordCases = [340 { name: "short", pw: "a" },341 { name: "long", pw: "x".repeat(500) },342 { name: "unicode", pw: "新パスワード🔑" },343 { name: "emoji", pw: "🔑🔒🌸" },344 { name: "spaces", pw: " " },345 { name: "control chars", pw: "a\t\n\rb" },346 { name: "RTL", pw: "كلمة المرور" },347 { name: "punctuation", pw: "!@#$%^&*()" },348 ];349 for (const { name, pw } of newPasswordCases) {350 it(`recovery sets ${name} new password; unlock works`, async () => {351 await setupUnlockedVault();352 const words = await getWords();353 const vault = await import("./vault");354 vault.lock();355 const r = await dispatch({356 type: "RECOVER_WITH_WORDS",357 payload: { words, newPassword: pw },358 });359 expect(r.ok).toBe(true);360 vault.lock();361 const u = await dispatch({362 type: "UNLOCK",363 payload: { password: pw },364 });365 expect(u.ok).toBe(true);366 });367 }368369 it("recovery, then old password fails", async () => {370 await setupUnlockedVault();371 const words = await getWords();372 const vault = await import("./vault");373 vault.lock();374 await dispatch({375 type: "RECOVER_WITH_WORDS",376 payload: { words, newPassword: "fresh-pass-XYZ" },377 });378 vault.lock();379 const old = await dispatch({380 type: "UNLOCK",381 payload: { password: "test-pass-12345" },382 });383 expect(old.ok).toBe(false);384 });385386 it("when no vault exists, recovery returns no vault", async () => {387 const vault = await import("./vault");388 vault.lock();389 const r = await dispatch({390 type: "RECOVER_WITH_WORDS",391 payload: {392 words: Array.from({ length: 12 }, (_, i) => WORD_LIST[i]!),393 newPassword: "x",394 },395 });396 expect(r.ok).toBe(false);397 });398399 400 const badPayloads = [401 null,402 undefined,403 {},404 { words: null },405 { words: [], newPassword: null },406 { words: "not-an-array", newPassword: "x" },407 { words: [42, 43, 44], newPassword: "x" },408 ];409 for (const [i, payload] of badPayloads.entries()) {410 it(`adversarial recovery payload #${i} does not crash`, async () => {411 await setupUnlockedVault();412 const vault = await import("./vault");413 vault.lock();414 const r = await dispatch({415 type: "RECOVER_WITH_WORDS",416 payload: payload as unknown as {417 words: string[];418 newPassword: string;419 },420 });421 expect(r).toBeDefined();422 expect(r.ok).toBe(false);423 });424 }425 });426427 428429 describe("BIP39 membership sweep (parameterized)", () => {430 431 for (let i = 0; i < 100; i++) {432 const idx = (i * 20) % WORD_LIST.length;433 const word = WORD_LIST[idx]!;434 it(`word at index ${idx} (${word}) validates clean`, async () => {435 await setupUnlockedVault();436 const r = await dispatch({437 type: "VALIDATE_RECOVERY_WORDS",438 payload: { words: [word] },439 });440 expect((r as { data: { invalid: unknown[] } }).data.invalid).toEqual(441 []442 );443 });444 }445446 447 const garbageWords = [448 "zzz",449 "qqq",450 "xyz",451 "123",452 "abc",453 "yyy",454 "fakeword",455 "notinlist",456 "garbagestring",457 "definitelynotaword",458 ];459 for (const word of garbageWords) {460 it(`garbage word "${word}" is flagged`, async () => {461 await setupUnlockedVault();462 const r = await dispatch({463 type: "VALIDATE_RECOVERY_WORDS",464 payload: { words: [word] },465 });466 const data = (467 r as { data: { invalid: Array<{ word: string }> } }468 ).data;469 expect(data.invalid.length).toBe(1);470 });471 }472 });473474 475476 describe("tamper coverage at every position", () => {477 for (let pos = 0; pos < 12; pos++) {478 it(`replace position ${pos} with garbage: recovery fails`, async () => {479 await setupUnlockedVault();480 const words = await getWords();481 const vault = await import("./vault");482 vault.lock();483 const tampered = [...words];484 tampered[pos] = "zzz";485 const r = await dispatch({486 type: "RECOVER_WITH_WORDS",487 payload: { words: tampered, newPassword: "new" },488 });489 expect(r.ok).toBe(false);490 });491492 it(`replace position ${pos} with valid-but-wrong word: recovery fails`, async () => {493 await setupUnlockedVault();494 const words = await getWords();495 const vault = await import("./vault");496 vault.lock();497 const tampered = [...words];498 tampered[pos] = WORD_LIST.find((w) => w !== words[pos])!;499 const r = await dispatch({500 type: "RECOVER_WITH_WORDS",501 payload: { words: tampered, newPassword: "new" },502 });503 expect(r.ok).toBe(false);504 });505 }506 });507508 509510 describe("GET_RECOVERY_WORDS adversarial", () => {511 it("returns ok false when locked", async () => {512 await setupUnlockedVault();513 const vault = await import("./vault");514 vault.lock();515 const r = await dispatch({ type: "GET_RECOVERY_WORDS" });516 expect(r.ok).toBe(false);517 });518519 it("returns exactly 12 words when unlocked", async () => {520 await setupUnlockedVault();521 const words = await getWords();522 expect(words).toHaveLength(12);523 });524525 it("returned words are all in BIP39", async () => {526 await setupUnlockedVault();527 const words = await getWords();528 for (const w of words) {529 expect(WORD_LIST).toContain(w);530 }531 });532533 for (let i = 0; i < 5; i++) {534 it(`deterministic across repeated calls (round ${i})`, async () => {535 await setupUnlockedVault();536 const a = await getWords();537 const b = await getWords();538 expect(a).toEqual(b);539 });540 }541 });542543 544545 describe("recovery interaction with password change cycle", () => {546 547 548 549550 it("original recovery words still work after a password change", async () => {551 await setupUnlockedVault();552 const oldWords = await getWords();553 const vault = await import("./vault");554 await vault.changePassword("test-pass-12345", "second-pass-XYZ");555 vault.lock();556 const r = await dispatch({557 type: "RECOVER_WITH_WORDS",558 payload: { words: oldWords, newPassword: "post-change-pass" },559 });560 expect(r.ok).toBe(true);561 });562563 for (const cycles of [1, 2, 3]) {564 it(`original recovery words still work after ${cycles} password change(s)`, async () => {565 await setupUnlockedVault();566 const originalWords = await getWords();567 const vault = await import("./vault");568 let cur = "test-pass-12345";569 for (let i = 0; i < cycles; i++) {570 const next = `pass-cycle-${i}`;571 await vault.changePassword(cur, next);572 cur = next;573 }574 vault.lock();575 const r = await dispatch({576 type: "RECOVER_WITH_WORDS",577 payload: { words: originalWords, newPassword: "after-cycles" },578 });579 expect(r.ok).toBe(true);580 });581 }582 });583});