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
↓ peach-browser-aa736dab.tar.gzatlas at this save
files changed7 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 −2src/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+});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 () => {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 () => {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;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 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(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