vault.corruption.test.ts

9.7 KB · raw

1// T-018: Vault corruption vs wrong-password discrimination (M7/COR-007)2//3// Why this test exists: `unlock()` in vault.ts distinguishes two failure modes:4//   - "wrong_key":  AES-GCM tag verification failed — wrong password, or5//     ciphertext was truncated6//   - "corrupted":  AES-GCM tag passed (correct key), but the decrypted JSON7//     failed schema validation — the vault data was corrupted post-encryption8//9// Both paths return `false` from unlock(), but the corrupted path sets10// `lastUnlockFailureReason` (consumed by `consumeLastUnlockFailureReason()`).11// Messages.ts uses this to surface "vault_corrupted" instead of "wrong_password"12// so the popup can offer the history-restore flow (M7).13//14// No existing test exercises `tryDecryptSlotResult` with corrupted data or15// verifies that `consumeLastUnlockFailureReason` is non-null after a corrupted16// unlock. A regression that collapses both paths into "wrong_key" would cause17// the popup to tell a user with a corrupted vault that they "typed the wrong18// password" — a confusing UX and a blocker for recovery.1920import { beforeAll, describe, expect, it, vi } from "vitest";2122type StorageShape = Record<string, unknown>;2324// ---------------------------------------------------------------------------25// Helpers: build StoredVault objects for specific scenarios26// ---------------------------------------------------------------------------2728// We can't import deriveKey+encrypt from vault.ts (they are not exported).29// Instead, build StoredVaults by hand using the same AES-256-GCM schema so30// we can control exactly what decrypts to what.3132function toHex(b: Uint8Array): string {33  return Array.from(b).map(v => v.toString(16).padStart(2, "0")).join("");34}3536async function aesGcmEncrypt(37  key: CryptoKey,38  nonce: Uint8Array,39  plaintext: string,40  aadTag = "slot_a_v1",41): Promise<{ ciphertext: string; tag: string }> {42  const enc = new TextEncoder();43  const combined = await crypto.subtle.encrypt(44    {45      name: "AES-GCM",46      iv: nonce,47      additionalData: enc.encode(aadTag),48    },49    key,50    enc.encode(plaintext),51  );52  const combined8 = new Uint8Array(combined);53  const ciphertext = combined8.slice(0, -16);54  const tag = combined8.slice(-16);55  // StoredVault fields are hex-encoded (aes.ts uses hex throughout)56  return { ciphertext: toHex(ciphertext), tag: toHex(tag) };57}5859// ---------------------------------------------------------------------------60// Tests61// ---------------------------------------------------------------------------6263describe("T-018: vault corruption vs wrong-password discrimination", () => {64  let tryDecryptSlotResult: typeof import("./vault")["tryDecryptSlotResult"];6566  // vault.ts imports alarms.ts which calls chrome.alarms at module load time.67  // Set up a minimal chrome stub before the dynamic import so the module loads.68  beforeAll(async () => {69    vi.resetModules();70    const chromeMock = {71      storage: {72        local: {73          get: vi.fn(async () => ({})),74          set: vi.fn(async () => {}),75          remove: vi.fn(async () => {}),76          clear: vi.fn(async () => {}),77        },78        session: {79          get: vi.fn(async () => ({})),80          set: vi.fn(async () => {}),81          remove: vi.fn(async () => {}),82        },83      },84      alarms: {85        clear: vi.fn(),86        create: vi.fn(),87        onAlarm: { addListener: vi.fn() },88      },89      idle: {90        setDetectionInterval: vi.fn(),91        onStateChanged: { addListener: vi.fn() },92      },93    } as unknown as typeof chrome;94    (globalThis as Record<string, unknown>).chrome = chromeMock;95    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); }));9697    const vault = await import("./vault");98    tryDecryptSlotResult = vault.tryDecryptSlotResult;99  });100101  // T-018a: correct key + valid JSON schema → status "ok".102  // Baseline: a well-formed vault slot decrypts correctly.103  it("T-018a: correct key + valid JSON → status ok", async () => {104    const key = await crypto.subtle.generateKey(105      { name: "AES-GCM", length: 256 },106      true,107      ["encrypt", "decrypt"],108    );109110    const validPayload = JSON.stringify({ entries: [], version: 1 });111    const nonce = crypto.getRandomValues(new Uint8Array(12));112    const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, validPayload);113    const slot = {114      version: 1,115      salt: toHex(crypto.getRandomValues(new Uint8Array(16))),116      nonce: toHex(nonce),117      ciphertext,118      tag,119    } as Parameters<typeof tryDecryptSlotResult>[0];120    const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key));121122    const result = await tryDecryptSlotResult(slot, rawKey, "slot_a_v1");123    expect(result.status).toBe("ok");124  });125126  // T-018b: wrong key → status "wrong_key".127  // AES-GCM tag verification fails — this is the standard "wrong password" path.128  it("T-018b: wrong key → status wrong_key", async () => {129    const correctKey = await crypto.subtle.generateKey(130      { name: "AES-GCM", length: 256 },131      true,132      ["encrypt", "decrypt"],133    );134    const wrongKey = await crypto.subtle.generateKey(135      { name: "AES-GCM", length: 256 },136      true,137      ["encrypt", "decrypt"],138    );139140    const nonce = crypto.getRandomValues(new Uint8Array(12));141    const { ciphertext, tag } = await aesGcmEncrypt(correctKey, nonce, JSON.stringify({ entries: [] }));142    const slot = {143      version: 1,144      salt: toHex(crypto.getRandomValues(new Uint8Array(16))),145      nonce: toHex(nonce),146      ciphertext,147      tag,148    } as Parameters<typeof tryDecryptSlotResult>[0];149    const rawWrongKey = new Uint8Array(await crypto.subtle.exportKey("raw", wrongKey));150151    const result = await tryDecryptSlotResult(slot, rawWrongKey, "slot_a_v1");152    expect(result.status).toBe("wrong_key");153  });154155  // T-018c: correct key + valid AES-GCM tag but JSON does not parse → "corrupted".156  // This simulates post-encryption corruption where the auth tag is intact but157  // the plaintext is garbled.158  //159  // Bug this catches: collapsing the JSON-parse error into "wrong_key" would160  // make the popup show "wrong password" to a user with a corrupted vault.161  it("T-018c: correct key + non-JSON plaintext → status corrupted, reason invalid_payload_json", async () => {162    const key = await crypto.subtle.generateKey(163      { name: "AES-GCM", length: 256 },164      true,165      ["encrypt", "decrypt"],166    );167    const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key));168169    const nonce = crypto.getRandomValues(new Uint8Array(12));170    const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, "THIS IS NOT JSON {{{");171    const slot = {172      version: 1,173      salt: toHex(crypto.getRandomValues(new Uint8Array(16))),174      nonce: toHex(nonce),175      ciphertext,176      tag,177    } as Parameters<typeof tryDecryptSlotResult>[0];178179    const result = await tryDecryptSlotResult(slot, rawKey, "slot_a_v1");180    expect(result.status).toBe("corrupted");181    if (result.status === "corrupted") {182      expect(result.reason).toBe("invalid_payload_json");183    }184  });185186  // T-018d: correct key + valid JSON but invalid schema (non-array entries field)187  // → status "corrupted", reason "invalid_payload_shape".188  it("T-018d: correct key + valid JSON but non-array entries → status corrupted, reason invalid_payload_shape", async () => {189    const key = await crypto.subtle.generateKey(190      { name: "AES-GCM", length: 256 },191      true,192      ["encrypt", "decrypt"],193    );194    const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key));195196    const nonce = crypto.getRandomValues(new Uint8Array(12));197    const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, JSON.stringify({ entries: "NOT_AN_ARRAY" }));198    const slot = {199      version: 1,200      salt: toHex(crypto.getRandomValues(new Uint8Array(16))),201      nonce: toHex(nonce),202      ciphertext,203      tag,204    } as Parameters<typeof tryDecryptSlotResult>[0];205206    const result = await tryDecryptSlotResult(slot, rawKey, "slot_a_v1");207    expect(result.status).toBe("corrupted");208    if (result.status === "corrupted") {209      expect(result.reason).toBe("invalid_payload_shape");210    }211  });212213  // T-018e: StoredVault with missing required field (no `tag` field) → "corrupted"214  // status from `hasValidVaultSchema`. This fires BEFORE any decryption attempt.215  //216  // Bug this catches: a schema check that skips missing-field validation would217  // pass this slot to AES-GCM decrypt, which would throw — and the error handler218  // would (incorrectly) report "wrong_key" instead of "corrupted".219  it("T-018e: StoredVault missing required field → status corrupted, reason invalid_slot_schema", async () => {220    const badSlot = {221      version: 1,222      salt: "aabbccdd",223      nonce: "aabbccdd",224      ciphertext: "aabbccdd",225      // tag: deliberately missing — triggers invalid_slot_schema226    } as Parameters<typeof tryDecryptSlotResult>[0];227228    const key = new Uint8Array(32).fill(0x42);229    const result = await tryDecryptSlotResult(badSlot, key, "slot_a_v1");230    expect(result.status).toBe("corrupted");231    if (result.status === "corrupted") {232      expect(result.reason).toBe("invalid_slot_schema");233    }234  });235236  // T-018f: consumeLastUnlockFailureReason returns non-null after a corrupted237  // vault unlock attempt and is cleared on the next call.238  // This is the integration path that messages.ts uses to surface vault_corrupted.239  //240  // Bug this catches: forgetting to set `lastUnlockFailureReason` in the241  // corrupted branch of unlock() means messages.ts always sees null and reports242  // "wrong_password" to the user even when the vault is structurally corrupted.243  it.todo(244    "T-018f: consumeLastUnlockFailureReason non-null after corrupted unlock, null after consumed (requires chrome mock + vault setup)",245  );246});