/** * self-test.ts — Known-Answer Test (KAT) for the Peach crypto stack. * * Runs on every vault unlock. Catches runtime environments where WebCrypto * has been silently broken (corrupted extension, browser bug, bad update). * Uses PBKDF2 + AES-GCM directly rather than Argon2id so it completes in * < 5 ms — the same encrypt/decrypt path the vault uses, verified with a * fixed test vector that must never change. * * If the KAT fails, the vault is locked and an error is surfaced. A user * who sees "self_test_failed" has a broken crypto environment and should * reinstall the extension. */ // Fixed KAT vector — generated once, never changes. // To regenerate: run `scripts/generate-kat.mjs` and replace these constants. const KAT_PLAINTEXT = "peach self-test vector v1"; const KAT_PASSWORD = "kat-fixed-password-do-not-use"; // 16-byte salt as hex const KAT_SALT_HEX = "1719d355fb0e9b33cc95bca2298a00c9"; // 12-byte nonce as hex ("key-nonce-01") const KAT_NONCE_HEX = "6b65792d6e6f6e63652d3031"; // PCH-01-020: expected AES-GCM output (ciphertext ‖ 16-byte tag) as hex for the // fixed (password, salt, nonce, plaintext) above. This is what makes the // self-test a real Known-Answer Test: a broken-but-self-consistent WebCrypto // produces a different ciphertext and fails here, where a pure round-trip would // pass. Regenerate with `node scripts/generate-kat.mjs`. MUST NOT change. const KAT_EXPECTED_CIPHERTEXT_HEX = "fb79bd9b46fbdc39e359ec9cac91d054b89d393e36801f9e0511adbe07b06a06273b45a8135a59a839"; function hexToBytes(hex: string): Uint8Array { const buf = new ArrayBuffer(hex.length / 2); const out = new Uint8Array(buf); for (let i = 0; i < out.length; i++) { out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); } return out; } function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!; return diff === 0; } async function deriveKatKey(password: string, salt: Uint8Array): Promise { const enc = new TextEncoder(); const keyMaterial = await crypto.subtle.importKey( "raw", enc.encode(password), "PBKDF2", false, ["deriveKey"] ); return crypto.subtle.deriveKey( { name: "PBKDF2", salt, iterations: 1000, hash: "SHA-256" }, keyMaterial, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"] ); } export async function runSelfTest(): Promise<{ ok: boolean; error?: string }> { try { const salt = hexToBytes(KAT_SALT_HEX); const nonce = hexToBytes(KAT_NONCE_HEX); const enc = new TextEncoder(); const plaintext = enc.encode(KAT_PLAINTEXT); const key = await deriveKatKey(KAT_PASSWORD, salt); const ciphertext = await crypto.subtle.encrypt( { name: "AES-GCM", iv: nonce }, key, plaintext ); // KAT proper (PCH-01-020): the encryption output must equal the pinned // vector. This distinguishes a correct WebCrypto from one that is merely // self-consistent (would pass a pure decrypt(encrypt(x)) === x round-trip). const expected = hexToBytes(KAT_EXPECTED_CIPHERTEXT_HEX); const produced = new Uint8Array(ciphertext); if (!constantTimeEqual(produced, expected)) { return { ok: false, error: "KAT FAIL: ciphertext does not match the pinned test vector", }; } const decrypted = await crypto.subtle.decrypt( { name: "AES-GCM", iv: nonce }, key, ciphertext ); const restored = new TextDecoder().decode(decrypted); if (restored !== KAT_PLAINTEXT) { return { ok: false, error: `KAT FAIL: expected "${KAT_PLAINTEXT}", got "${restored}"`, }; } return { ok: true }; } catch (err) { return { ok: false, error: `KAT ERROR: ${String(err)}` }; } }