vault.corruption.test.ts

9.0 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";2122// ---------------------------------------------------------------------------23// Helpers: build StoredVault objects for specific scenarios24// ---------------------------------------------------------------------------2526// We can't import deriveKey+encrypt from vault.ts (they are not exported).27// Instead, build StoredVaults by hand using the same AES-256-GCM schema so28// we can control exactly what decrypts to what.2930function toHex(b: Uint8Array): string {31  return Array.from(b).map(v => v.toString(16).padStart(2, "0")).join("");32}3334async function aesGcmEncrypt(35  key: CryptoKey,36  nonce: Uint8Array<ArrayBuffer>,37  plaintext: string,38  aadTag = "slot_a_v1",39): Promise<{ ciphertext: string; tag: string }> {40  const enc = new TextEncoder();41  const combined = await crypto.subtle.encrypt(42    {43      name: "AES-GCM",44      iv: nonce,45      additionalData: enc.encode(aadTag),46    },47    key,48    enc.encode(plaintext),49  );50  const combined8 = new Uint8Array(combined);51  const ciphertext = combined8.slice(0, -16);52  const tag = combined8.slice(-16);53  // StoredVault fields are hex-encoded (aes.ts uses hex throughout)54  return { ciphertext: toHex(ciphertext), tag: toHex(tag) };55}5657// ---------------------------------------------------------------------------58// Tests59// ---------------------------------------------------------------------------6061describe("T-018: vault corruption vs wrong-password discrimination", () => {62  let tryDecryptSlotResult: typeof import("./vault")["tryDecryptSlotResult"];6364  // vault.ts imports alarms.ts which calls chrome.alarms at module load time.65  // Set up a minimal chrome stub before the dynamic import so the module loads.66  beforeAll(async () => {67    vi.resetModules();68    const chromeMock = {69      storage: {70        local: {71          get: vi.fn(async () => ({})),72          set: vi.fn(async () => {}),73          remove: vi.fn(async () => {}),74          clear: vi.fn(async () => {}),75        },76        session: {77          get: vi.fn(async () => ({})),78          set: vi.fn(async () => {}),79          remove: vi.fn(async () => {}),80        },81      },82      alarms: {83        clear: vi.fn(),84        create: vi.fn(),85        onAlarm: { addListener: vi.fn() },86      },87      idle: {88        setDetectionInterval: vi.fn(),89        onStateChanged: { addListener: vi.fn() },90      },91    } as unknown as typeof chrome;92    (globalThis as Record<string, unknown>).chrome = chromeMock;93    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); }));9495    const vault = await import("./vault");96    tryDecryptSlotResult = vault.tryDecryptSlotResult;97  });9899  // T-018a: correct key + valid JSON schema → status "ok".100  // Baseline: a well-formed vault slot decrypts correctly.101  it("T-018a: correct key + valid JSON → status ok", async () => {102    const key = await crypto.subtle.generateKey(103      { name: "AES-GCM", length: 256 },104      true,105      ["encrypt", "decrypt"],106    );107108    const validPayload = JSON.stringify({ entries: [], version: 1 });109    const nonce = crypto.getRandomValues(new Uint8Array(12));110    const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, validPayload);111    const slot = {112      version: 1,113      salt: toHex(crypto.getRandomValues(new Uint8Array(16))),114      nonce: toHex(nonce),115      ciphertext,116      tag,117    } as Parameters<typeof tryDecryptSlotResult>[0];118    const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key));119120    const result = await tryDecryptSlotResult(slot, rawKey, "slot_a_v1");121    expect(result.status).toBe("ok");122  });123124  // T-018b: wrong key → status "wrong_key".125  // AES-GCM tag verification fails — this is the standard "wrong password" path.126  it("T-018b: wrong key → status wrong_key", async () => {127    const correctKey = await crypto.subtle.generateKey(128      { name: "AES-GCM", length: 256 },129      true,130      ["encrypt", "decrypt"],131    );132    const wrongKey = await crypto.subtle.generateKey(133      { name: "AES-GCM", length: 256 },134      true,135      ["encrypt", "decrypt"],136    );137138    const nonce = crypto.getRandomValues(new Uint8Array(12));139    const { ciphertext, tag } = await aesGcmEncrypt(correctKey, nonce, JSON.stringify({ entries: [] }));140    const slot = {141      version: 1,142      salt: toHex(crypto.getRandomValues(new Uint8Array(16))),143      nonce: toHex(nonce),144      ciphertext,145      tag,146    } as Parameters<typeof tryDecryptSlotResult>[0];147    const rawWrongKey = new Uint8Array(await crypto.subtle.exportKey("raw", wrongKey));148149    const result = await tryDecryptSlotResult(slot, rawWrongKey, "slot_a_v1");150    expect(result.status).toBe("wrong_key");151  });152153  // T-018c: correct key + valid AES-GCM tag but JSON does not parse → "corrupted".154  // This simulates post-encryption corruption where the auth tag is intact but155  // the plaintext is garbled.156  //157  // Bug this catches: collapsing the JSON-parse error into "wrong_key" would158  // make the popup show "wrong password" to a user with a corrupted vault.159  it("T-018c: correct key + non-JSON plaintext → status corrupted, reason invalid_payload_json", async () => {160    const key = await crypto.subtle.generateKey(161      { name: "AES-GCM", length: 256 },162      true,163      ["encrypt", "decrypt"],164    );165    const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key));166167    const nonce = crypto.getRandomValues(new Uint8Array(12));168    const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, "THIS IS NOT JSON {{{");169    const slot = {170      version: 1,171      salt: toHex(crypto.getRandomValues(new Uint8Array(16))),172      nonce: toHex(nonce),173      ciphertext,174      tag,175    } as Parameters<typeof tryDecryptSlotResult>[0];176177    const result = await tryDecryptSlotResult(slot, rawKey, "slot_a_v1");178    expect(result.status).toBe("corrupted");179    if (result.status === "corrupted") {180      expect(result.reason).toBe("invalid_payload_json");181    }182  });183184  // T-018d: correct key + valid JSON but invalid schema (non-array entries field)185  // → status "corrupted", reason "invalid_payload_shape".186  it("T-018d: correct key + valid JSON but non-array entries → status corrupted, reason invalid_payload_shape", async () => {187    const key = await crypto.subtle.generateKey(188      { name: "AES-GCM", length: 256 },189      true,190      ["encrypt", "decrypt"],191    );192    const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key));193194    const nonce = crypto.getRandomValues(new Uint8Array(12));195    const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, JSON.stringify({ entries: "NOT_AN_ARRAY" }));196    const slot = {197      version: 1,198      salt: toHex(crypto.getRandomValues(new Uint8Array(16))),199      nonce: toHex(nonce),200      ciphertext,201      tag,202    } as Parameters<typeof tryDecryptSlotResult>[0];203204    const result = await tryDecryptSlotResult(slot, rawKey, "slot_a_v1");205    expect(result.status).toBe("corrupted");206    if (result.status === "corrupted") {207      expect(result.reason).toBe("invalid_payload_shape");208    }209  });210211  // T-018e: StoredVault with missing required field (no `tag` field) → "corrupted"212  // status from `hasValidVaultSchema`. This fires BEFORE any decryption attempt.213  //214  // Bug this catches: a schema check that skips missing-field validation would215  // pass this slot to AES-GCM decrypt, which would throw — and the error handler216  // would (incorrectly) report "wrong_key" instead of "corrupted".217  it("T-018e: StoredVault missing required field → status corrupted, reason invalid_slot_schema", async () => {218    const badSlot = {219      version: 1,220      salt: "aabbccdd",221      nonce: "aabbccdd",222      ciphertext: "aabbccdd",223      // tag: deliberately missing — triggers invalid_slot_schema224    } as Parameters<typeof tryDecryptSlotResult>[0];225226    const key = new Uint8Array(32).fill(0x42);227    const result = await tryDecryptSlotResult(badSlot, key, "slot_a_v1");228    expect(result.status).toBe("corrupted");229    if (result.status === "corrupted") {230      expect(result.reason).toBe("invalid_slot_schema");231    }232  });233});