vault.corruption.test.ts

8.2 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 { describe, expect, it } from "vitest";21import {22  tryDecryptSlotResult,23  type TryDecryptResult,24} from "../../background/vault";25import type { StoredVault } from "../../background/vault";2627// ---------------------------------------------------------------------------28// Helpers: build StoredVault objects for specific scenarios29// ---------------------------------------------------------------------------3031// We can't import deriveKey+encrypt from vault.ts (they are not exported).32// Instead, build StoredVaults by hand using the same AES-256-GCM schema so33// we can control exactly what decrypts to what.3435async function aesGcmEncrypt(36  key: CryptoKey,37  nonce: Uint8Array,38  plaintext: string,39  aad?: string,40): Promise<{ ciphertext: string; tag: string }> {41  const enc = new TextEncoder();42  const combined = await crypto.subtle.encrypt(43    {44      name: "AES-GCM",45      iv: nonce,46      additionalData: aad ? enc.encode(aad) : undefined,47    },48    key,49    enc.encode(plaintext),50  );51  // Last 16 bytes are the GCM tag; the rest is ciphertext.52  const combined8 = new Uint8Array(combined);53  const ciphertext = combined8.slice(0, -16);54  const tag = combined8.slice(-16);55  return {56    ciphertext: btoa(String.fromCharCode(...ciphertext)),57    tag: btoa(String.fromCharCode(...tag)),58  };59}6061function bytesToB64(b: Uint8Array): string {62  return btoa(String.fromCharCode(...b));63}6465async function buildValidSlot(66  key: CryptoKey,67  json: string,68  aadTag = "slot_a_v1",69): Promise<StoredVault> {70  const nonce = crypto.getRandomValues(new Uint8Array(12));71  const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, json, aadTag);72  return {73    version: 1,74    salt: bytesToB64(crypto.getRandomValues(new Uint8Array(16))),75    nonce: bytesToB64(nonce),76    ciphertext,77    tag,78  } as unknown as StoredVault;79}8081// ---------------------------------------------------------------------------82// Tests83// ---------------------------------------------------------------------------8485describe("T-018: vault corruption vs wrong-password discrimination", () => {86  // T-018a: correct key + valid JSON schema → status "ok".87  // Baseline: a well-formed vault slot decrypts correctly.88  it("T-018a: correct key + valid JSON → status ok", async () => {89    const key = await crypto.subtle.generateKey(90      { name: "AES-GCM", length: 256 },91      false,92      ["encrypt", "decrypt"],93    );9495    const validPayload = JSON.stringify({96      entries: [],97      version: 1,98      // Minimal fields that satisfy decodePayload's shape check99    });100    const slot = await buildValidSlot(key, validPayload);101    const rawKey = new Uint8Array(102      await crypto.subtle.exportKey("raw", key),103    );104105    const result = await tryDecryptSlotResult(slot, rawKey, "slot_a_v1");106    expect(result.status).toBe("ok");107  });108109  // T-018b: wrong key → status "wrong_key".110  // AES-GCM tag verification fails — this is the standard "wrong password" path.111  it("T-018b: wrong key → status wrong_key", async () => {112    const correctKey = await crypto.subtle.generateKey(113      { name: "AES-GCM", length: 256 },114      false,115      ["encrypt", "decrypt"],116    );117    const wrongKey = await crypto.subtle.generateKey(118      { name: "AES-GCM", length: 256 },119      true,120      ["encrypt", "decrypt"],121    );122123    const slot = await buildValidSlot(correctKey, JSON.stringify({ entries: [] }));124    const rawWrongKey = new Uint8Array(await crypto.subtle.exportKey("raw", wrongKey));125126    const result = await tryDecryptSlotResult(slot, rawWrongKey, "slot_a_v1");127    expect(result.status).toBe("wrong_key");128  });129130  // T-018c: correct key + valid AES-GCM tag but JSON does not parse → "corrupted".131  // This simulates post-encryption corruption where the auth tag is intact but132  // the plaintext is garbled (e.g., a filesystem bitflip that scrambled the133  // plaintext BEFORE encryption in an unusual scenario, or a developer bug that134  // stored non-JSON in the slot).135  //136  // Bug this catches: collapsing the JSON-parse error into "wrong_key" would137  // make the popup show "wrong password" to a user with a corrupted vault.138  it("T-018c: correct key + non-JSON plaintext → status corrupted, reason invalid_payload_json", async () => {139    const key = await crypto.subtle.generateKey(140      { name: "AES-GCM", length: 256 },141      true,142      ["encrypt", "decrypt"],143    );144    const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", key));145146    const slot = await buildValidSlot(key, "THIS IS NOT JSON {{{");147148    const result = await tryDecryptSlotResult(slot, rawKey, "slot_a_v1");149    expect(result.status).toBe("corrupted");150    if (result.status === "corrupted") {151      expect(result.reason).toBe("invalid_payload_json");152    }153  });154155  // T-018d: correct key + valid JSON but invalid schema (non-array entries field)156  // → status "corrupted", reason "invalid_payload_shape".157  // This covers the COR-007 scenario: the vault data decrypts but the entries158  // field has been replaced with a non-array (e.g., null, an object, or a number).159  it("T-018d: correct key + valid JSON but non-array entries → status corrupted, reason invalid_payload_shape", 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 slot = await buildValidSlot(key, JSON.stringify({ entries: "NOT_AN_ARRAY" }));168169    const result = await tryDecryptSlotResult(slot, rawKey, "slot_a_v1");170    expect(result.status).toBe("corrupted");171    if (result.status === "corrupted") {172      expect(result.reason).toBe("invalid_payload_shape");173    }174  });175176  // T-018e: StoredVault with missing required field (no `tag` field) → "corrupted"177  // status from `hasValidVaultSchema`. This fires BEFORE any decryption attempt.178  //179  // Bug this catches: a schema check that skips missing-field validation would180  // pass this slot to AES-GCM decrypt, which would throw — and the error handler181  // would (incorrectly) report "wrong_key" instead of "corrupted".182  it("T-018e: StoredVault missing required field → status corrupted, reason invalid_slot_schema", async () => {183    const badSlot = {184      version: 1,185      salt: "dGVzdA==",186      nonce: "dGVzdA==",187      ciphertext: "dGVzdA==",188      // tag: deliberately missing189    } as unknown as StoredVault;190191    const key = new Uint8Array(32).fill(0x42);192    const result = await tryDecryptSlotResult(badSlot, key, "slot_a_v1");193    expect(result.status).toBe("corrupted");194    if (result.status === "corrupted") {195      expect(result.reason).toBe("invalid_slot_schema");196    }197  });198199  // T-018f: consumeLastUnlockFailureReason returns non-null after a corrupted200  // vault unlock attempt and is cleared on the next call.201  // This is the integration path that messages.ts uses to surface vault_corrupted.202  //203  // Bug this catches: forgetting to set `lastUnlockFailureReason` in the204  // corrupted branch of unlock() means messages.ts always sees null and reports205  // "wrong_password" to the user even when the vault is structurally corrupted.206  it.todo(207    "T-018f: consumeLastUnlockFailureReason non-null after corrupted unlock, null after consumed (requires chrome mock + vault setup)",208  );209});