feat(micron): implement SRI verification for WASM assets and generate integrity.json

This commit is contained in:
Ivan 2026-05-02 16:22:29 -05:00
parent 464cfa50b0
commit 495de05eb2
No known key found for this signature in database
GPG key ID: B84314E2D9332AE0
7 changed files with 213 additions and 19 deletions

View file

@ -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",

View file

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

View file

@ -0,0 +1,5 @@
{
"version": "v1.0.3",
"wasm": "sha384-UTy7QMULnY7BvB4K66L00C+5o23/jZVGd9klogT4FFU7tU1AxwkLRXZYDiLeTdP+",
"wasmExec": "sha384-PWCs+V4BDf9yY1yjkD/p+9xNEs4iEbuvq+HezAOJiY3XL5GI6VyJXMsvnjiwNbce"
}

View file

@ -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();

View file

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

View file

@ -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(),

View file

@ -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({