From 495de05eb27291262439a8a47cd7942863f83b65 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sat, 2 May 2026 16:22:29 -0500 Subject: [PATCH] feat(micron): implement SRI verification for WASM assets and generate integrity.json --- eslint.config.mjs | 2 + meshchatx/src/frontend/js/MicronWasmLoader.js | 87 +++++++++++++++++-- .../vendor/micron-parser-go/integrity.json | 5 ++ scripts/fetch-micron-wasm.mjs | 27 +++++- tests/frontend/MicronWasmLoader.test.js | 63 ++++++++++++-- vite.config.js | 25 ++++++ vitest.config.js | 23 +++++ 7 files changed, 213 insertions(+), 19 deletions(-) create mode 100644 meshchatx/src/frontend/public/vendor/micron-parser-go/integrity.json diff --git a/eslint.config.mjs b/eslint.config.mjs index 7d82d7b..e156891 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -45,6 +45,8 @@ export default [ ...globals.browser, ...globals.node, __APP_BUILD_TIME__: "readonly", + __MICRON_WASM_SRI_WASM__: "readonly", + __MICRON_WASM_SRI_EXEC__: "readonly", axios: "readonly", Codec2Lib: "readonly", Codec2MicrophoneRecorder: "readonly", diff --git a/meshchatx/src/frontend/js/MicronWasmLoader.js b/meshchatx/src/frontend/js/MicronWasmLoader.js index 5655d4b..36fb9e3 100644 --- a/meshchatx/src/frontend/js/MicronWasmLoader.js +++ b/meshchatx/src/frontend/js/MicronWasmLoader.js @@ -5,6 +5,14 @@ */ let resolvedPromise = null; +let integrityHashes = null; + +/** Computes SHA-384 hash of ArrayBuffer for SRI verification. */ +async function computeSriHash(buf) { + const hash = await crypto.subtle.digest("SHA-384", buf); + const base64 = btoa(String.fromCharCode(...new Uint8Array(hash))); + return `sha384-${base64}`; +} /** Injects CSS required for ForceMonospace mode when using WASM. Safe to call multiple times. */ function injectMicronWasmStyles() { @@ -46,18 +54,69 @@ function baseUrl() { return `${root.replace(/\/?$/, "/")}vendor/micron-parser-go`; } -function injectScript(src) { +/** Returns SRI hashes embedded at build time (primary) or fetched from integrity.json (fallback). */ +async function getIntegrityHashes() { + if (integrityHashes !== null) { + return integrityHashes; + } + // Use build-time embedded hashes as primary trusted source (tamper-proof) + const embeddedWasm = typeof __MICRON_WASM_SRI_WASM__ !== "undefined" ? __MICRON_WASM_SRI_WASM__ : ""; + const embeddedExec = typeof __MICRON_WASM_SRI_EXEC__ !== "undefined" ? __MICRON_WASM_SRI_EXEC__ : ""; + if (embeddedWasm && embeddedExec) { + integrityHashes = { wasm: embeddedWasm, wasmExec: embeddedExec }; + return integrityHashes; + } + // Fallback: fetch from server (less secure, but allows development builds) + try { + const res = await fetch(`${baseUrl()}/integrity.json`); + if (!res.ok) return null; + integrityHashes = await res.json(); + return integrityHashes; + } catch { + return null; + } +} + +/** Verifies SRI hash of buffer against expected hash. Throws if mismatch or no hash provided. */ +async function verifySri(buf, expectedHash, name) { + if (!expectedHash) { + throw new Error(`Micron WASM: SRI hash missing for ${name}. Refusing to load untrusted code.`); + } + const actualHash = await computeSriHash(buf); + if (actualHash !== expectedHash) { + throw new Error( + `Micron WASM: SRI hash mismatch for ${name}. Possible tampering detected. Refusing to execute.` + ); + } +} + +async function injectScript(src, expectedHash) { const id = "meshchatx-micron-wasm-exec"; if (document.getElementById(id)) { - return Promise.resolve(); + return; } + // Fetch and verify SRI before injecting + const res = await fetch(src); + if (!res.ok) { + throw new Error(`Micron WASM: failed to fetch script ${src} (${res.status})`); + } + const buf = await res.arrayBuffer(); + await verifySri(buf, expectedHash, "wasm_exec.js"); + const blob = new Blob([buf], { type: "application/javascript" }); + const blobUrl = URL.createObjectURL(blob); return new Promise((resolve, reject) => { const s = document.createElement("script"); s.id = id; s.async = true; - s.src = src; - s.onload = () => resolve(); - s.onerror = () => reject(new Error(`Micron WASM: failed to load script ${src}`)); + s.src = blobUrl; + s.onload = () => { + URL.revokeObjectURL(blobUrl); + resolve(); + }; + s.onerror = () => { + URL.revokeObjectURL(blobUrl); + reject(new Error(`Micron WASM: failed to load script ${src}`)); + }; document.head.appendChild(s); }); } @@ -67,7 +126,9 @@ async function instantiateOnce() { throw new Error("Micron WASM: WebAssembly is not available"); } const root = baseUrl(); - await injectScript(`${root}/wasm_exec.js`); + const integrity = await getIntegrityHashes(); + + await injectScript(`${root}/wasm_exec.js`, integrity?.wasmExec); if (typeof globalThis.Go === "undefined") { throw new Error("Micron WASM: Go runtime missing after wasm_exec.js load"); } @@ -75,14 +136,26 @@ async function instantiateOnce() { const go = new globalThis.Go(); let result; try { - result = await WebAssembly.instantiateStreaming(fetch(wasmUrl), go.importObject); + // Try streaming first + const res = await fetch(wasmUrl); + if (!res.ok) { + throw new Error(`Micron WASM: fetch failed (${res.status})`); + } + const buf = await res.arrayBuffer(); + await verifySri(buf, integrity?.wasm, "micron-parser-go.wasm"); + result = await WebAssembly.instantiateStreaming( + new Response(buf, { headers: { "content-type": "application/wasm" } }), + go.importObject + ); } catch { + // Fallback to buffer instantiation const buf = await fetch(wasmUrl).then((r) => { if (!r.ok) { throw new Error(`Micron WASM: fetch failed (${r.status})`); } return r.arrayBuffer(); }); + await verifySri(buf, integrity?.wasm, "micron-parser-go.wasm"); result = await WebAssembly.instantiate(buf, go.importObject); } go.run(result.instance); diff --git a/meshchatx/src/frontend/public/vendor/micron-parser-go/integrity.json b/meshchatx/src/frontend/public/vendor/micron-parser-go/integrity.json new file mode 100644 index 0000000..cea4ebc --- /dev/null +++ b/meshchatx/src/frontend/public/vendor/micron-parser-go/integrity.json @@ -0,0 +1,5 @@ +{ + "version": "v1.0.3", + "wasm": "sha384-UTy7QMULnY7BvB4K66L00C+5o23/jZVGd9klogT4FFU7tU1AxwkLRXZYDiLeTdP+", + "wasmExec": "sha384-PWCs+V4BDf9yY1yjkD/p+9xNEs4iEbuvq+HezAOJiY3XL5GI6VyJXMsvnjiwNbce" +} \ No newline at end of file diff --git a/scripts/fetch-micron-wasm.mjs b/scripts/fetch-micron-wasm.mjs index 48978da..b0fe5be 100644 --- a/scripts/fetch-micron-wasm.mjs +++ b/scripts/fetch-micron-wasm.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node /** * Downloads micron-parser-go WASM release assets and matching wasm_exec.js for Vite public/. + * Generates SRI hashes for integrity verification at runtime. * Safe to run offline: exits 0 without files when MICRON_WASM_SKIP=1 or network fails. * * Override URLs: @@ -9,6 +10,7 @@ */ import fs from "fs"; import path from "path"; +import crypto from "crypto"; import { MICRON_PARSER_GO_RELEASE_TAG } from "./micron-parser-go-version.mjs"; import { micronWasmVendorPaths, micronWasmRepoRoot } from "./micron-wasm-resolve-bundled.mjs"; @@ -25,6 +27,11 @@ function rmQuiet(p) { } } +function computeSriHash(buf) { + const hash = crypto.createHash("sha384").update(buf).digest("base64"); + return `sha384-${hash}`; +} + async function fetchBinary(url, destFile) { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); @@ -36,7 +43,7 @@ async function fetchBinary(url, destFile) { const buf = Buffer.from(await res.arrayBuffer()); fs.mkdirSync(path.dirname(destFile), { recursive: true }); fs.writeFileSync(destFile, buf); - return buf.length; + return { size: buf.length, sri: computeSriHash(buf) }; } finally { clearTimeout(t); } @@ -55,18 +62,30 @@ async function main() { fs.mkdirSync(dir, { recursive: true }); + let wasmResult; + let execResult; try { console.log("fetch-micron-wasm: downloading wasm_exec.js..."); - await fetchBinary(execUrl, wasmExec); + execResult = await fetchBinary(execUrl, wasmExec); console.log("fetch-micron-wasm: downloading micron-parser-go.wasm..."); - const n = await fetchBinary(wasmUrl, wasm); - console.log(`fetch-micron-wasm: OK (${n} bytes WASM)`); + wasmResult = await fetchBinary(wasmUrl, wasm); + console.log(`fetch-micron-wasm: OK (${wasmResult.size} bytes WASM)`); } catch (e) { console.warn("fetch-micron-wasm: failed:", e?.message || e); rmQuiet(wasm); rmQuiet(wasmExec); + rmQuiet(path.join(dir, "integrity.json")); process.exit(0); } + + // Write SRI hashes for runtime verification + const integrity = { + version: MICRON_PARSER_GO_RELEASE_TAG, + wasm: wasmResult.sri, + wasmExec: execResult.sri, + }; + fs.writeFileSync(path.join(dir, "integrity.json"), JSON.stringify(integrity, null, 2)); + console.log("fetch-micron-wasm: SRI hashes written to integrity.json"); } main(); diff --git a/tests/frontend/MicronWasmLoader.test.js b/tests/frontend/MicronWasmLoader.test.js index 001ccb4..17d4490 100644 --- a/tests/frontend/MicronWasmLoader.test.js +++ b/tests/frontend/MicronWasmLoader.test.js @@ -100,10 +100,35 @@ describe("MicronWasmLoader.js", () => { return node; }); - vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - arrayBuffer: async () => new ArrayBuffer(16), - headers: new Headers({ "content-type": "application/wasm" }), + // Mock crypto.subtle.digest to return hash matching embedded SRI for test data + const mockWasmHash = __MICRON_WASM_SRI_WASM__?.replace("sha384-", "") || ""; + const mockExecHash = __MICRON_WASM_SRI_EXEC__?.replace("sha384-", "") || ""; + vi.stubGlobal("crypto", { + subtle: { + digest: vi.fn(async (algo, buf) => { + // Return different hash based on buffer size to distinguish wasm_exec.js vs wasm + const hash = + buf.byteLength < 1000 + ? mockExecHash // wasm_exec.js is smaller + : mockWasmHash; // wasm is larger + // Convert base64 to ArrayBuffer + const binary = atob(hash); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; + }), + }, + }); + + vi.spyOn(globalThis, "fetch").mockImplementation((url) => { + const isWasm = url.includes(".wasm"); + return Promise.resolve({ + ok: true, + arrayBuffer: async () => (isWasm ? new ArrayBuffer(4096) : new ArrayBuffer(500)), + headers: new Headers({ "content-type": isWasm ? "application/wasm" : "application/javascript" }), + }); }); const streaming = vi @@ -118,6 +143,7 @@ describe("MicronWasmLoader.js", () => { expect(instantiate).toHaveBeenCalled(); } finally { appendSpy.mockRestore(); + vi.unstubAllGlobals(); } }); @@ -137,10 +163,30 @@ describe("MicronWasmLoader.js", () => { return node; }); - vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - arrayBuffer: async () => new ArrayBuffer(16), - headers: new Headers({ "content-type": "application/wasm" }), + // Mock crypto.subtle.digest to return hash matching embedded SRI for test data + const mockWasmHash = __MICRON_WASM_SRI_WASM__?.replace("sha384-", "") || ""; + const mockExecHash = __MICRON_WASM_SRI_EXEC__?.replace("sha384-", "") || ""; + vi.stubGlobal("crypto", { + subtle: { + digest: vi.fn(async (algo, buf) => { + const hash = buf.byteLength < 1000 ? mockExecHash : mockWasmHash; + const binary = atob(hash); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; + }), + }, + }); + + vi.spyOn(globalThis, "fetch").mockImplementation((url) => { + const isWasm = url.includes(".wasm"); + return Promise.resolve({ + ok: true, + arrayBuffer: async () => (isWasm ? new ArrayBuffer(4096) : new ArrayBuffer(500)), + headers: new Headers({ "content-type": isWasm ? "application/wasm" : "application/javascript" }), + }); }); vi.spyOn(WebAssembly, "instantiateStreaming").mockRejectedValue(new Error("streaming failed")); @@ -156,6 +202,7 @@ describe("MicronWasmLoader.js", () => { expect(instantiate).toHaveBeenCalledTimes(2); } finally { appendSpy.mockRestore(); + vi.unstubAllGlobals(); } }); diff --git a/vite.config.js b/vite.config.js index ceab4aa..94360af 100644 --- a/vite.config.js +++ b/vite.config.js @@ -51,11 +51,36 @@ function isMicronWasmBundledResolved() { const micronWasmBundled = isMicronWasmBundledResolved(); +function loadMicronWasmIntegrity() { + if (!micronWasmBundled) return null; + const integrityPath = path.join( + __dirname, + "meshchatx", + "src", + "frontend", + "public", + "vendor", + "micron-parser-go", + "integrity.json" + ); + try { + const content = fs.readFileSync(integrityPath, "utf-8"); + return JSON.parse(content); + } catch { + console.warn("vite: could not load micron-parser-go integrity.json"); + return null; + } +} + +const micronWasmIntegrity = loadMicronWasmIntegrity(); + export default defineConfig({ define: { __APP_BUILD_TIME__: JSON.stringify(appBuildTimeIso), "import.meta.env.VITE_MICRON_WASM_BUNDLED": JSON.stringify(micronWasmBundled ? "true" : "false"), "import.meta.env.VITE_MICRON_PARSER_GO_RELEASE": JSON.stringify(MICRON_PARSER_GO_RELEASE_TAG), + __MICRON_WASM_SRI_WASM__: JSON.stringify(micronWasmIntegrity?.wasm || ""), + __MICRON_WASM_SRI_EXEC__: JSON.stringify(micronWasmIntegrity?.wasmExec || ""), }, plugins: [ tailwindcss(), diff --git a/vitest.config.js b/vitest.config.js index 973f6d5..f7d534a 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -20,6 +20,27 @@ function isMicronWasmBundledResolved(repoRoot) { const micronWasmBundled = isMicronWasmBundledResolved(__dirname); +function loadMicronWasmIntegrity(repoRoot) { + if (!micronWasmBundled) return null; + const integrityPath = path.join( + repoRoot, + "meshchatx", + "src", + "frontend", + "public", + "vendor", + "micron-parser-go", + "integrity.json" + ); + try { + const content = fs.readFileSync(integrityPath, "utf-8"); + return JSON.parse(content); + } catch { + return null; + } +} + +const micronWasmIntegrity = loadMicronWasmIntegrity(__dirname); const appBuildTimeIso = new Date().toISOString(); export default defineConfig({ @@ -27,6 +48,8 @@ export default defineConfig({ __APP_BUILD_TIME__: JSON.stringify(appBuildTimeIso), "import.meta.env.VITE_MICRON_WASM_BUNDLED": JSON.stringify(micronWasmBundled ? "true" : "false"), "import.meta.env.VITE_MICRON_PARSER_GO_RELEASE": JSON.stringify(MICRON_PARSER_GO_RELEASE_TAG), + __MICRON_WASM_SRI_WASM__: JSON.stringify(micronWasmIntegrity?.wasm || ""), + __MICRON_WASM_SRI_EXEC__: JSON.stringify(micronWasmIntegrity?.wasmExec || ""), }, plugins: [ vue({