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
3 files scripts/generate-kat.mjs+35 −0src/background/self-test.test.ts+21 −1src/background/self-test.ts+26 −0
scripts/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"));

view file

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 });

view file

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 },

view file