750fbd091ff3
fix: PCH-01-020 pin expected ciphertext so unlock self-test is a real KAT — add scripts/generate-kat.mjs + constant-time compare against committed vector in self-test.ts (v3.8.1)
- time
- 2026-06-10 20:17 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- pch-01 fix integration
- parent
- da4b30abc2c9
↓ peach-browser-750fbd09.tar.gzatlas at this save
files changed3 files
scripts/generate-kat.mjs+35 −0src/background/self-test.test.ts+21 −1src/background/self-test.ts+26 −0scripts/generate-kat.mjs+35 −0
@@ -0,0 +1,35 @@1+// generate-kat.mjs — recompute the pinned ciphertext for self-test.ts (PCH-01-020).2+//3+// Run: node scripts/generate-kat.mjs4+//5+// Uses the SAME parameters as deriveKatKey/runSelfTest in6+// src/background/self-test.ts (PBKDF2 1000 iters SHA-256 → AES-256-GCM). Paste7+// the printed hex into KAT_EXPECTED_CIPHERTEXT_HEX. It MUST NOT change once8+// committed — a different value means WebCrypto changed under us.9+10+const KAT_PLAINTEXT = "peach self-test vector v1";11+const KAT_PASSWORD = "kat-fixed-password-do-not-use";12+const SALT = Buffer.from("1719d355fb0e9b33cc95bca2298a00c9", "hex");13+const NONCE = Buffer.from("6b65792d6e6f6e63652d3031", "hex");14+15+const km = await crypto.subtle.importKey(16+ "raw",17+ new TextEncoder().encode(KAT_PASSWORD),18+ "PBKDF2",19+ false,20+ ["deriveKey"],21+);22+const key = await crypto.subtle.deriveKey(23+ { name: "PBKDF2", salt: SALT, iterations: 1000, hash: "SHA-256" },24+ km,25+ { name: "AES-GCM", length: 256 },26+ false,27+ ["encrypt"],28+);29+const ct = await crypto.subtle.encrypt(30+ { name: "AES-GCM", iv: NONCE },31+ key,32+ new TextEncoder().encode(KAT_PLAINTEXT),33+);34+35+console.log(Buffer.from(ct).toString("hex"));src/background/self-test.test.ts+21 −1
@@ -1,4 +1,4 @@1-import { describe, expect, test } from "vitest";1+import { describe, expect, test, vi } from "vitest";22 import { runSelfTest } from "./self-test";33 44 describe("Peach crypto KAT (self-test)", () => {@@ -6,5 +6,25 @@66 const result = await runSelfTest();77 expect(result.ok).toBe(true);88 expect(result.error).toBeUndefined();9+ });10+11+ // PCH-01-020: a real KAT must reject a ciphertext that diverges from the12+ // pinned vector, not merely a failed round-trip. Simulate a WebCrypto whose13+ // AES-GCM output differs from the committed expectation.14+ test("fails when the produced ciphertext diverges from the pinned vector", async () => {15+ const realEncrypt = crypto.subtle.encrypt.bind(crypto.subtle);16+ const spy = vi17+ .spyOn(crypto.subtle, "encrypt")18+ .mockImplementation(async (...args: Parameters<typeof realEncrypt>) => {19+ const ct = new Uint8Array(await realEncrypt(...args));20+ ct[0] ^= 0xff; // diverge from the pinned vector21+ return ct.buffer;22+ });23+24+ const result = await runSelfTest();25+ expect(result.ok).toBe(false);26+ expect(result.error).toContain("does not match the pinned test vector");27+28+ spy.mockRestore();929 });1030 });src/background/self-test.ts+26 −0
@@ -20,6 +20,13 @@2020 const KAT_SALT_HEX = "1719d355fb0e9b33cc95bca2298a00c9";2121 // 12-byte nonce as hex ("key-nonce-01")2222 const 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.28+const KAT_EXPECTED_CIPHERTEXT_HEX =29+ "fb79bd9b46fbdc39e359ec9cac91d054b89d393e36801f9e0511adbe07b06a06273b45a8135a59a839";2330 2431 function hexToBytes(hex: string): Uint8Array<ArrayBuffer> {2532 const buf = new ArrayBuffer(hex.length / 2);@@ -30,6 +37,13 @@3037 return out;3138 }3239 40+function 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+}46+3347 async function deriveKatKey(password: string, salt: Uint8Array<ArrayBuffer>): Promise<CryptoKey> {3448 const enc = new TextEncoder();3549 const keyMaterial = await crypto.subtle.importKey(@@ -62,6 +76,18 @@6276 key,6377 plaintext6478 );79+80+ // 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+ }6591 6692 const decrypted = await crypto.subtle.decrypt(6793 { name: "AES-GCM", iv: nonce },