aa736dabac67

AUD-004: extend Codex chunk hash and vault fingerprint to 128 bits with legacy 16-char fallback

time
2026-05-16 13:48 UTC · 5 months ago
machine
koh2
parent
8dda840a1a99
7 files src/popup/codex/codex.adversarial.test.ts+40 −0src/popup/codex/codex.test.ts+1 −1src/popup/codex/generate.test.ts+2 −2src/popup/codex/generate.ts+2 −1src/popup/codex/helpers.ts+13 −6src/popup/codex/restore.ts+6 −1src/shared/codex-types.ts+2 −2
src/popup/codex/codex.adversarial.test.ts+40 −0
@@ -534,3 +534,43 @@534534     });535535   }536536 });537+538+// ── AUD-004: 128-bit integrity prefixes ────────────────────────────────────539+540+describe("Codex integrity prefix length (AUD-004)", () => {541+  it("chunk hash is 32 hex chars (128 bits)", async () => {542+    const bytes = new Uint8Array([1, 2, 3, 4, 5]);543+    const env = await buildChunkEnvelope(bytes, 0, 10, false, "PX-TEST-1234");544+    expect(env.h).toHaveLength(32);545+    expect(env.h).toMatch(/^[0-9A-F]{32}$/);546+  });547+548+  it("verifyChunkIntegrity passes for current 32-char hash", async () => {549+    const bytes = new Uint8Array([1, 2, 3]);550+    const env = await buildChunkEnvelope(bytes, 0, 5, false, "PX-TEST-1234");551+    expect(env.h).toHaveLength(32);552+    expect(await verifyChunkIntegrity(env)).toBe(true);553+  });554+555+  it("verifyChunkIntegrity still passes for legacy 16-char hash", async () => {556+    const bytes = new Uint8Array([1, 2, 3]);557+    const fullHash = await sha256Hex(bytes);558+    const env = await buildChunkEnvelope(bytes, 0, 5, false, "PX-TEST-1234");559+    const legacy = { ...env, h: fullHash.slice(0, 16) };560+    expect(await verifyChunkIntegrity(legacy)).toBe(true);561+  });562+563+  it("verifyChunkIntegrity rejects tampered 32-char hash", async () => {564+    const bytes = new Uint8Array([1, 2, 3]);565+    const env = await buildChunkEnvelope(bytes, 0, 5, false, "PX-TEST-1234");566+    const tampered = { ...env, h: "X".repeat(32) };567+    expect(await verifyChunkIntegrity(tampered)).toBe(false);568+  });569+570+  it("verifyChunkIntegrity rejects tampered 16-char legacy hash", async () => {571+    const bytes = new Uint8Array([1, 2, 3]);572+    const env = await buildChunkEnvelope(bytes, 0, 5, false, "PX-TEST-1234");573+    const tampered = { ...env, h: "X".repeat(16) };574+    expect(await verifyChunkIntegrity(tampered)).toBe(false);575+  });576+});

view file

src/popup/codex/codex.test.ts+1 −1
@@ -142,7 +142,7 @@142142     expect(envelope.c).toBe('PX-TEST-1234');143143     expect(envelope.d).toBeTruthy();144144     expect(envelope.h).toBeTruthy();145-    expect(envelope.h.length).toBe(16);145+    expect(envelope.h.length).toBe(32); // AUD-004: 128-bit hash prefix146146   });147147 148148   it('integrity check passes for valid envelope', async () => {

view file

src/popup/codex/generate.test.ts+2 −2
@@ -119,7 +119,7 @@119119     expect(env.t).toBe(7);120120     expect(env.p).toBe(false);121121     expect(env.c).toBe("PX-AAAA-BBBB");122-    expect(env.h).toHaveLength(16);122+    expect(env.h).toHaveLength(32); // AUD-004: 128-bit hash prefix123123     expect(await verifyChunkIntegrity(env)).toBe(true);124124   });125125 @@ -216,7 +216,7 @@216216     expect(manifest.parityChunks).toBe(PARITY_SHARDS);217217     expect(manifest.totalChunks).toBe(manifest.dataChunks + manifest.parityChunks);218218     expect(manifest.pageCount).toBeGreaterThanOrEqual(1);219-    expect(manifest.vaultFingerprint).toMatch(/^[0-9A-F]{16}$/);219+    expect(manifest.vaultFingerprint).toMatch(/^[0-9A-F]{32}$/); // AUD-004220220   }, 60_000);221221 222222   it("rejects generation when the vault is locked", async () => {

view file

src/popup/codex/generate.ts+2 −1
@@ -234,7 +234,8 @@234234   const combined = JSON.stringify({ a: slotA, b: slotB ?? null });235235   const vaultBlob = bytesToBase64Url(new TextEncoder().encode(combined));236236 237-  const fingerprint = (await sha256Hex(new TextEncoder().encode(vaultBlob))).slice(0, 16);237+  // AUD-004: 32 hex chars = 128 bits (was 16 chars / 64 bits).238+  const fingerprint = (await sha256Hex(new TextEncoder().encode(vaultBlob))).slice(0, 32);238239 239240   const timestamp = lastModified ? Number.parseInt(lastModified, 10) : Date.now();240241   const liveEntryCount = entries.filter((e) => !e.deletedAt).length;

view file

src/popup/codex/helpers.ts+13 −6
@@ -52,7 +52,8 @@5252 5353 /**5454  * Return uppercase hex of SHA-256(bytes). Used for both the per-chunk integrity55- * hash (8-char prefix) and the vault fingerprint (16-char prefix).55+ * hash (32-char prefix, 128 bits) and the vault fingerprint (32-char prefix,56+ * 128 bits). AUD-004: prefixes were extended from 64 to 128 bits.5657  */5758 export async function sha256Hex(bytes: Uint8Array): Promise<string> {5859   // Copy into a fresh ArrayBuffer so the input never reaches SubtleCrypto as@@ -73,9 +74,9 @@7374 7475 /**7576  * Build a ChunkEnvelope for one data or parity chunk. The envelope's `h`76- * field is the first 8 hex chars of SHA-256(rawChunkBytes), so per-chunk77- * corruption introduced after generation (smudge, scan glitch) is detectable78- * at scan time.77+ * field is the first 32 hex chars (128 bits) of SHA-256(rawChunkBytes), so78+ * per-chunk corruption introduced after generation (smudge, scan glitch) is79+ * detectable at scan time. AUD-004 raised the prefix from 64 to 128 bits.7980  */8081 export async function buildChunkEnvelope(8182   chunkBytes: Uint8Array,@@ -84,7 +85,7 @@8485   isParity: boolean,8586   verifyCode: string,8687 ): Promise<ChunkEnvelope> {87-  const hash = (await sha256Hex(chunkBytes)).slice(0, 16);88+  const hash = (await sha256Hex(chunkBytes)).slice(0, 32);8889   return {8990     v: 2,9091     c: verifyCode,@@ -102,8 +103,14 @@102103  * surface a CodexError.103104  */104105 export async function verifyChunkIntegrity(envelope: ChunkEnvelope): Promise<boolean> {106+  // AUD-004: accept only the documented prefix lengths — 16 (legacy v1) and107+  // 32 (current v2). An empty or truncated hash must not verify true via a108+  // zero-length prefix match, and an oversize hash cannot be a valid SHA-256109+  // prefix at all.110+  if (envelope.h.length !== 16 && envelope.h.length !== 32) return false;105111   const bytes = base64UrlToBytes(envelope.d);106-  const expected = (await sha256Hex(bytes)).slice(0, 16);112+  const fullHash = await sha256Hex(bytes);113+  const expected = fullHash.slice(0, envelope.h.length);107114   return expected === envelope.h;108115 }109116 

view file

src/popup/codex/restore.ts+6 −1
@@ -206,8 +206,13 @@206206   // bytes into a parity-reconstructed slot. `sha256Hex` already returns207207   // uppercase hex (see helpers.ts), so the `.toUpperCase()` is belt-and-208208   // suspenders against a future helpers.ts change.209+  //210+  // AUD-004: legacy Codexes printed before v2.50.2 carry a 16-char (64-bit)211+  // fingerprint; current Codexes carry 32 chars (128 bits). Slice by the212+  // stored length so both verify without an envelope-version bump.213+  const fingerprintLen = payload.vaultFingerprint.length;209214   const fingerprint = (await sha256Hex(new TextEncoder().encode(payload.vaultBlob)))210-    .slice(0, 16)215+    .slice(0, fingerprintLen)211216     .toUpperCase();212217   if (fingerprint !== payload.vaultFingerprint) {213218     throw new CodexError(

view file

src/shared/codex-types.ts+2 −2
@@ -31,7 +31,7 @@3131   timestamp: number;3232   /** Visible entry count for user confirmation at restore time. */3333   entryCount: number;34-  /** First 16 chars of SHA-256(vaultBlob), uppercase hex. Verifies reconstruction integrity. */34+  /** First 32 chars (128 bits) of SHA-256(vaultBlob), uppercase hex. Verifies reconstruction integrity. AUD-004: was 16 chars; legacy Codexes still verify. */3535   vaultFingerprint: string;3636   /** When this Codex PDF was generated. Printed in the footer. */3737   generatedAt: number;@@ -57,7 +57,7 @@5757   p: boolean;5858   /** Chunk data, base64url-encoded. */5959   d: string;60-  /** First 16 chars (64 bits) of SHA-256(rawChunkBytes), hex. Catches per-chunk corruption with ≤2^-64 collision odds. */60+  /** First 32 chars (128 bits) of SHA-256(rawChunkBytes), hex. Catches per-chunk corruption with ≤2^-128 collision odds. AUD-004: was 16 chars; legacy Codexes still verify. */6161   h: string;6262 }6363 

view file