feat(sri): implement SRI verification for external scripts and add integrity.json files for Codec2 and RNode Flasher

This commit is contained in:
Ivan
2026-05-02 16:27:04 -05:00
parent 495de05eb2
commit 5764f8a060
6 changed files with 322 additions and 13 deletions
@@ -372,15 +372,58 @@ export default {
}
this.refreshCapabilities();
},
_loadScript(src) {
async _loadScript(src) {
// Fetch and verify SRI before injecting
const integrity = this._rnodeIntegrity || (await this._loadRnodeIntegrity());
const pathParts = src.split("/");
const filename = pathParts.slice(-2).join("/"); // e.g., "zip.min.js" or "crypto-js@3.9.1-1/core.js"
const expectedHash = integrity?.[filename];
if (!expectedHash) {
throw new Error(`RNode: SRI hash missing for ${filename}. Refusing to load untrusted code.`);
}
const res = await fetch(src);
if (!res.ok) {
throw new Error(`RNode: failed to fetch ${src} (${res.status})`);
}
const buf = await res.arrayBuffer();
const hash = await crypto.subtle.digest("SHA-384", buf);
const actualHash = "sha384-" + btoa(String.fromCharCode(...new Uint8Array(hash)));
if (actualHash !== expectedHash) {
throw new Error(`RNode: SRI hash mismatch for ${filename}. Possible tampering detected.`);
}
// Inject verified content as blob
const blob = new Blob([buf], { type: "application/javascript" });
const blobUrl = URL.createObjectURL(blob);
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = src;
script.onload = resolve;
script.onerror = reject;
script.src = blobUrl;
script.onload = () => {
URL.revokeObjectURL(blobUrl);
resolve();
};
script.onerror = () => {
URL.revokeObjectURL(blobUrl);
reject(new Error(`Failed to load ${src}`));
};
document.head.appendChild(script);
});
},
async _loadRnodeIntegrity() {
if (this._rnodeIntegrity) return this._rnodeIntegrity;
try {
const res = await fetch("/rnode-flasher/js/integrity.json");
if (!res.ok) throw new Error("Failed to load integrity.json");
const data = await res.json();
this._rnodeIntegrity = data.files || {};
return this._rnodeIntegrity;
} catch (e) {
console.error("RNode: Failed to load integrity hashes:", e);
throw e;
}
},
async _openTransport() {
if (this.connectionMethod === TRANSPORT_SERIAL) {
const transport = await SerialTransport.request();
+53 -2
View File
@@ -9,8 +9,41 @@ const codec2ScriptPaths = [
let loadPromise = null;
let resolvedOk = false;
let integrityHashes = null;
function injectScript(src) {
/** 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}`;
}
/** Loads integrity.json. Returns null if not available. */
async function loadIntegrityHashes() {
if (integrityHashes !== null) return integrityHashes;
try {
const res = await fetch("/assets/js/codec2-emscripten/integrity.json");
if (!res.ok) return null;
const data = await res.json();
integrityHashes = data.files || {};
return integrityHashes;
} catch {
return null;
}
}
/** Verifies SRI hash. Throws if mismatch or missing when required. */
async function verifySri(buf, expectedHash, name) {
if (!expectedHash) {
throw new Error(`Codec2: SRI hash missing for ${name}. Refusing to load untrusted code.`);
}
const actualHash = await computeSriHash(buf);
if (actualHash !== expectedHash) {
throw new Error(`Codec2: SRI hash mismatch for ${name}. Possible tampering detected.`);
}
}
async function injectScript(src) {
if (typeof document === "undefined") {
return Promise.resolve();
}
@@ -29,16 +62,34 @@ function injectScript(src) {
});
}
// Fetch, verify SRI, then inject as blob
const integrity = await loadIntegrityHashes();
const filename = src.split("/").pop();
const expectedHash = integrity?.[filename];
const res = await fetch(src);
if (!res.ok) {
throw new Error(`Codec2: failed to fetch ${src} (${res.status})`);
}
const buf = await res.arrayBuffer();
await verifySri(buf, expectedHash, filename);
// Create blob URL for verified content
const blob = new Blob([buf], { type: "application/javascript" });
const blobUrl = URL.createObjectURL(blob);
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = src;
script.src = blobUrl;
script.async = false;
script.setAttribute(attrName, src);
script.addEventListener("load", () => {
URL.revokeObjectURL(blobUrl);
script.setAttribute(loadedAttr, "true");
resolve();
});
script.addEventListener("error", () => {
URL.revokeObjectURL(blobUrl);
script.remove();
reject(new Error(`Failed to load ${src}`));
});
@@ -0,0 +1,15 @@
{
"version": "local",
"files": {
"c2dec.wasm": "sha384-4zOZs7/jXc1loaji7m+4oob/zkOWTL1sDJMI1YxbBpxYaiMipiF3lwYiAvmWCeir",
"c2dec.js": "sha384-rHKNP761F8BDKgj24VSzJO/rdm9usDJKoHjdIOKt83hS4IcZKJau6XDgZXuiZCA8",
"c2enc.wasm": "sha384-1EVNLUcps00Ihe9cR40JaZd73jnJRtQjS9e6zUVOd4tni7/BwcwV9oouj/CPgA8N",
"c2enc.js": "sha384-Rrnbr/tbCaDiL1n14+MccgIw0Cef+yrpbdYythu5/PkpzZhsVc+9Fle0WyT+7AWc",
"sox.wasm": "sha384-NMa5LHokdsVBdaUPlK1jnsSQ1nmgAnf8POPAwFpRkfjckdBCUSs1RhRd03CHwDAN",
"sox.js": "sha384-iSAIvLWRyVYwMrKhVzrmvcCzS5noiipjTxOowhXWkmDW0xFGjLV8YGpppX1qRdum",
"codec2-lib.js": "sha384-ts/lRDKjHaqD4pUZeyXI+nMIe75khxJEcORRLiqaJo496FFa1FSkfKzW2lm9RAus",
"codec2-microphone-recorder.js": "sha384-dGQR+cfKn/lejCt32+N8EJw94+VwFvTeFILKDo1BOm0R+1qElYCR+jrPHezOEzb9",
"processor.js": "sha384-m1JmxQg3+KdNiu25A6ArCGFaaKmN6MzONe+LaphWLPfwXqs8bxD6dWNbehUUG54Y",
"wav-encoder.js": "sha384-QToS0oLeqMQo+9wYKkx+pTNlBRZsRKsKplmJB/Plf3LwngxNi+0YFJBkb17aCNIM"
}
}
@@ -0,0 +1,12 @@
{
"version": "local",
"files": {
"zip.min.js": "sha384-cmZdkDGSIsPVhqU0ukL2jrqMz+w8htEprOs0cRQQqpOyXa+KQaFMYjioMb10W9IU",
"crypto-js@3.9.1-1/core.js": "sha384-jol9z4ZPGPDmUbsnDIVZFMN/LePaZZosij//98kSSqhMfSW8492HUOcStDqmJgQb",
"crypto-js@3.9.1-1/md5.js": "sha384-dkDpdL8rWfdl9pDjG0LmtTQhCkkzhhwt/32QdshNFJzIv3bFKcGg1P9O3bmj3e06",
"esptool-js@0.4.5/bundle.js": "sha384-oSX6xrk2dnf+rpEk51E6dJPAZnQaIGGyXqpUMTOI+/zy4EQTPcWAyd7GKuRu7dhu",
"nrf52_dfu_flasher.js": "sha384-w+piNnfdIh7UW5SxnYqwZoasIVB+45f2ArqYEMryVvJH2ZwmlqF0duPmYQEjQnxy",
"rnode.js": "sha384-iagMdctE36S8/4PC8Im2LMu/iQGMc/6jiDOJOM8bn1ILYwXFHoMyS13eeLwFl/Ft",
"web-serial-polyfill@1.0.15/dist/serial.js": "sha384-5/ferViMHB357beLZsaH2fUuLpRZlFJTWZBVyF/ZLHEzNOsQofokUthz/nB+T11x"
}
}
+50 -7
View File
@@ -52,6 +52,44 @@ describe("Codec2Loader integration (jsdom)", () => {
});
it("ensureCodec2ScriptsLoaded resolves when script tags load", async () => {
// Mock crypto.subtle.digest first
const mockHashBytes = new Uint8Array(48);
for (let i = 0; i < 48; i++) mockHashBytes[i] = i;
const mockHashB64 = btoa(String.fromCharCode(...mockHashBytes));
vi.stubGlobal("crypto", {
subtle: {
digest: vi.fn(async () => mockHashBytes.buffer),
},
});
// Mock fetch to return integrity.json and script content
const mockIntegrity = {
files: {
"c2enc.js": `sha384-${mockHashB64}`,
"c2dec.js": `sha384-${mockHashB64}`,
"sox.js": `sha384-${mockHashB64}`,
"codec2-lib.js": `sha384-${mockHashB64}`,
"wav-encoder.js": `sha384-${mockHashB64}`,
"codec2-microphone-recorder.js": `sha384-${mockHashB64}`,
},
};
vi.spyOn(globalThis, "fetch").mockImplementation((url) => {
if (url.includes("integrity.json")) {
return Promise.resolve({
ok: true,
json: async () => mockIntegrity,
});
}
// Return mock script content for any script request
const content = "// mock script";
return Promise.resolve({
ok: true,
arrayBuffer: async () => new TextEncoder().encode(content).buffer,
});
});
vi.spyOn(document.head, "appendChild").mockImplementation((node) => {
if (node instanceof HTMLScriptElement && node.src) {
origAppend(node);
@@ -64,19 +102,23 @@ describe("Codec2Loader integration (jsdom)", () => {
await ensureCodec2ScriptsLoaded();
await expect(ensureCodec2ScriptsLoaded()).resolves.toBeUndefined();
expect(document.querySelectorAll("script[data-codec2-src]").length).toBe(6);
vi.unstubAllGlobals();
});
it("startCodec2ScriptsBackgroundLoad swallows errors after retries", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(document.head, "appendChild").mockImplementation((node) => {
if (node instanceof HTMLScriptElement && node.src) {
origAppend(node);
queueMicrotask(() => node.dispatchEvent(new Event("error")));
return node;
}
return origAppend(node);
// Mock crypto
vi.stubGlobal("crypto", {
subtle: {
digest: vi.fn(async () => new ArrayBuffer(48)),
},
});
// Mock fetch to fail (simulate network/SRI failure)
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network error"));
await startCodec2ScriptsBackgroundLoad({
maxAttempts: 2,
baseDelayMs: 0,
@@ -85,5 +127,6 @@ describe("Codec2Loader integration (jsdom)", () => {
expect(warn).toHaveBeenCalled();
warn.mockRestore();
vi.unstubAllGlobals();
});
});
+145
View File
@@ -0,0 +1,145 @@
import { describe, it, expect } from "vitest";
import { readFileSync, existsSync } from "fs";
import { createHash } from "crypto";
import path from "path";
const REPO_ROOT = path.resolve(__dirname, "../..");
function computeSri384(filePath) {
const buf = readFileSync(filePath);
const hash = createHash("sha384").update(buf).digest("base64");
return `sha384-${hash}`;
}
describe("SRI (Subresource Integrity) Verification", () => {
describe("micron-parser-go WASM", () => {
it("has valid integrity.json with matching SRI hashes", () => {
const integrityPath = path.join(
REPO_ROOT,
"meshchatx/src/frontend/public/vendor/micron-parser-go/integrity.json"
);
const wasmPath = path.join(
REPO_ROOT,
"meshchatx/src/frontend/public/vendor/micron-parser-go/micron-parser-go.wasm"
);
const execPath = path.join(REPO_ROOT, "meshchatx/src/frontend/public/vendor/micron-parser-go/wasm_exec.js");
if (!existsSync(integrityPath)) {
return; // Skip if WASM not fetched
}
const integrity = JSON.parse(readFileSync(integrityPath, "utf-8"));
expect(integrity.version).toBeTruthy();
expect(integrity.wasm).toMatch(/^sha384-[A-Za-z0-9+/=]+$/);
expect(integrity.wasmExec).toMatch(/^sha384-[A-Za-z0-9+/=]+$/);
if (existsSync(wasmPath)) {
const actualWasmHash = computeSri384(wasmPath);
expect(actualWasmHash).toBe(integrity.wasm);
}
if (existsSync(execPath)) {
const actualExecHash = computeSri384(execPath);
expect(actualExecHash).toBe(integrity.wasmExec);
}
});
});
describe("Codec2 Emscripten", () => {
it("has valid integrity.json with all required files", () => {
const integrityPath = path.join(
REPO_ROOT,
"meshchatx/src/frontend/public/assets/js/codec2-emscripten/integrity.json"
);
const baseDir = path.join(REPO_ROOT, "meshchatx/src/frontend/public/assets/js/codec2-emscripten");
expect(existsSync(integrityPath)).toBe(true);
const integrity = JSON.parse(readFileSync(integrityPath, "utf-8"));
expect(integrity.version).toBeTruthy();
expect(integrity.files).toBeTruthy();
const requiredFiles = [
"c2dec.wasm",
"c2dec.js",
"c2enc.wasm",
"c2enc.js",
"sox.wasm",
"sox.js",
"codec2-lib.js",
"codec2-microphone-recorder.js",
"processor.js",
"wav-encoder.js",
];
for (const file of requiredFiles) {
expect(integrity.files[file]).toMatch(/^sha384-[A-Za-z0-9+/=]+$/);
const filePath = path.join(baseDir, file);
if (existsSync(filePath)) {
const actualHash = computeSri384(filePath);
expect(actualHash).toBe(integrity.files[file]);
}
}
});
});
describe("RNode Flasher", () => {
it("has valid integrity.json with all required vendor libraries", () => {
const integrityPath = path.join(REPO_ROOT, "meshchatx/src/frontend/public/rnode-flasher/js/integrity.json");
const baseDir = path.join(REPO_ROOT, "meshchatx/src/frontend/public/rnode-flasher/js");
expect(existsSync(integrityPath)).toBe(true);
const integrity = JSON.parse(readFileSync(integrityPath, "utf-8"));
expect(integrity.version).toBeTruthy();
expect(integrity.files).toBeTruthy();
const requiredFiles = [
"zip.min.js",
"crypto-js@3.9.1-1/core.js",
"crypto-js@3.9.1-1/md5.js",
"esptool-js@0.4.5/bundle.js",
"nrf52_dfu_flasher.js",
"rnode.js",
"web-serial-polyfill@1.0.15/dist/serial.js",
];
for (const file of requiredFiles) {
expect(integrity.files[file]).toMatch(/^sha384-[A-Za-z0-9+/=]+$/);
const filePath = path.join(baseDir, file);
if (existsSync(filePath)) {
const actualHash = computeSri384(filePath);
expect(actualHash).toBe(integrity.files[file]);
}
}
});
});
describe("SRI hash format validation", () => {
it("all SRI hashes use sha384- prefix and base64 format", async () => {
const integrityFiles = [
"meshchatx/src/frontend/public/vendor/micron-parser-go/integrity.json",
"meshchatx/src/frontend/public/assets/js/codec2-emscripten/integrity.json",
"meshchatx/src/frontend/public/rnode-flasher/js/integrity.json",
];
for (const relPath of integrityFiles) {
const fullPath = path.join(REPO_ROOT, relPath);
if (!existsSync(fullPath)) continue;
const data = JSON.parse(readFileSync(fullPath, "utf-8"));
const hashes =
data.wasm && data.wasmExec ? [data.wasm, data.wasmExec] : Object.values(data.files || {});
for (const hash of hashes) {
expect(hash).toMatch(/^sha384-[A-Za-z0-9+/=]+$/);
// SHA-384 in base64 should be exactly 64 characters
const b64Part = hash.replace("sha384-", "");
expect(b64Part.length).toBe(64);
}
}
});
});
});