self-test.ts

3.8 KB · raw

1/**2 * self-test.ts — Known-Answer Test (KAT) for the Peach crypto stack.3 *4 * Runs on every vault unlock. Catches runtime environments where WebCrypto5 * has been silently broken (corrupted extension, browser bug, bad update).6 * Uses PBKDF2 + AES-GCM directly rather than Argon2id so it completes in7 * < 5 ms — the same encrypt/decrypt path the vault uses, verified with a8 * fixed test vector that must never change.9 *10 * If the KAT fails, the vault is locked and an error is surfaced. A user11 * who sees "self_test_failed" has a broken crypto environment and should12 * reinstall the extension.13 */1415// Fixed KAT vector — generated once, never changes.16// To regenerate: run `scripts/generate-kat.mjs` and replace these constants.17const KAT_PLAINTEXT = "peach self-test vector v1";18const KAT_PASSWORD = "kat-fixed-password-do-not-use";19// 16-byte salt as hex20const KAT_SALT_HEX = "1719d355fb0e9b33cc95bca2298a00c9";21// 12-byte nonce as hex ("key-nonce-01")22const KAT_NONCE_HEX = "6b65792d6e6f6e63652d3031";23// PCH-01-020: expected AES-GCM output (ciphertext ‖ 16-byte tag) as hex for the24// fixed (password, salt, nonce, plaintext) above. This is what makes the25// self-test a real Known-Answer Test: a broken-but-self-consistent WebCrypto26// produces a different ciphertext and fails here, where a pure round-trip would27// pass. Regenerate with `node scripts/generate-kat.mjs`. MUST NOT change.28const KAT_EXPECTED_CIPHERTEXT_HEX =29  "fb79bd9b46fbdc39e359ec9cac91d054b89d393e36801f9e0511adbe07b06a06273b45a8135a59a839";3031function hexToBytes(hex: string): Uint8Array<ArrayBuffer> {32  const buf = new ArrayBuffer(hex.length / 2);33  const out = new Uint8Array(buf);34  for (let i = 0; i < out.length; i++) {35    out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);36  }37  return out;38}3940function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {41  if (a.length !== b.length) return false;42  let diff = 0;43  for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;44  return diff === 0;45}4647async function deriveKatKey(password: string, salt: Uint8Array<ArrayBuffer>): Promise<CryptoKey> {48  const enc = new TextEncoder();49  const keyMaterial = await crypto.subtle.importKey(50    "raw",51    enc.encode(password),52    "PBKDF2",53    false,54    ["deriveKey"]55  );56  return crypto.subtle.deriveKey(57    { name: "PBKDF2", salt, iterations: 1000, hash: "SHA-256" },58    keyMaterial,59    { name: "AES-GCM", length: 256 },60    false,61    ["encrypt", "decrypt"]62  );63}6465export async function runSelfTest(): Promise<{ ok: boolean; error?: string }> {66  try {67    const salt = hexToBytes(KAT_SALT_HEX);68    const nonce = hexToBytes(KAT_NONCE_HEX);69    const enc = new TextEncoder();70    const plaintext = enc.encode(KAT_PLAINTEXT);7172    const key = await deriveKatKey(KAT_PASSWORD, salt);7374    const ciphertext = await crypto.subtle.encrypt(75      { name: "AES-GCM", iv: nonce },76      key,77      plaintext78    );7980    // KAT proper (PCH-01-020): the encryption output must equal the pinned81    // vector. This distinguishes a correct WebCrypto from one that is merely82    // self-consistent (would pass a pure decrypt(encrypt(x)) === x round-trip).83    const expected = hexToBytes(KAT_EXPECTED_CIPHERTEXT_HEX);84    const produced = new Uint8Array(ciphertext);85    if (!constantTimeEqual(produced, expected)) {86      return {87        ok: false,88        error: "KAT FAIL: ciphertext does not match the pinned test vector",89      };90    }9192    const decrypted = await crypto.subtle.decrypt(93      { name: "AES-GCM", iv: nonce },94      key,95      ciphertext96    );9798    const restored = new TextDecoder().decode(decrypted);99    if (restored !== KAT_PLAINTEXT) {100      return {101        ok: false,102        error: `KAT FAIL: expected "${KAT_PLAINTEXT}", got "${restored}"`,103      };104    }105106    return { ok: true };107  } catch (err) {108    return { ok: false, error: `KAT ERROR: ${String(err)}` };109  }110}