mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
feat(micron): implement SRI verification for WASM assets and generate integrity.json
This commit is contained in:
parent
464cfa50b0
commit
495de05eb2
7 changed files with 213 additions and 19 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
5
meshchatx/src/frontend/public/vendor/micron-parser-go/integrity.json
vendored
Normal file
5
meshchatx/src/frontend/public/vendor/micron-parser-go/integrity.json
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"version": "v1.0.3",
|
||||
"wasm": "sha384-UTy7QMULnY7BvB4K66L00C+5o23/jZVGd9klogT4FFU7tU1AxwkLRXZYDiLeTdP+",
|
||||
"wasmExec": "sha384-PWCs+V4BDf9yY1yjkD/p+9xNEs4iEbuvq+HezAOJiY3XL5GI6VyJXMsvnjiwNbce"
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue