From 5764f8a060a65287966d5be310faef5eecd1d207 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sat, 2 May 2026 16:27:04 -0500 Subject: [PATCH] feat(sri): implement SRI verification for external scripts and add integrity.json files for Codec2 and RNode Flasher --- .../components/tools/RNodeFlasherPage.vue | 51 +++++- meshchatx/src/frontend/js/Codec2Loader.js | 55 ++++++- .../js/codec2-emscripten/integrity.json | 15 ++ .../public/rnode-flasher/js/integrity.json | 12 ++ tests/frontend/Codec2Loader.test.js | 57 ++++++- tests/frontend/SriIntegrity.test.js | 145 ++++++++++++++++++ 6 files changed, 322 insertions(+), 13 deletions(-) create mode 100644 meshchatx/src/frontend/public/assets/js/codec2-emscripten/integrity.json create mode 100644 meshchatx/src/frontend/public/rnode-flasher/js/integrity.json create mode 100644 tests/frontend/SriIntegrity.test.js diff --git a/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue b/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue index 6c5d2b0..ec608a4 100644 --- a/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue +++ b/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue @@ -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(); diff --git a/meshchatx/src/frontend/js/Codec2Loader.js b/meshchatx/src/frontend/js/Codec2Loader.js index f29e5db..4e181af 100644 --- a/meshchatx/src/frontend/js/Codec2Loader.js +++ b/meshchatx/src/frontend/js/Codec2Loader.js @@ -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}`)); }); diff --git a/meshchatx/src/frontend/public/assets/js/codec2-emscripten/integrity.json b/meshchatx/src/frontend/public/assets/js/codec2-emscripten/integrity.json new file mode 100644 index 0000000..0b17bb0 --- /dev/null +++ b/meshchatx/src/frontend/public/assets/js/codec2-emscripten/integrity.json @@ -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" + } +} diff --git a/meshchatx/src/frontend/public/rnode-flasher/js/integrity.json b/meshchatx/src/frontend/public/rnode-flasher/js/integrity.json new file mode 100644 index 0000000..25b485b --- /dev/null +++ b/meshchatx/src/frontend/public/rnode-flasher/js/integrity.json @@ -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" + } +} diff --git a/tests/frontend/Codec2Loader.test.js b/tests/frontend/Codec2Loader.test.js index 30e1f4d..c6941dc 100644 --- a/tests/frontend/Codec2Loader.test.js +++ b/tests/frontend/Codec2Loader.test.js @@ -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(); }); }); diff --git a/tests/frontend/SriIntegrity.test.js b/tests/frontend/SriIntegrity.test.js new file mode 100644 index 0000000..9fee529 --- /dev/null +++ b/tests/frontend/SriIntegrity.test.js @@ -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); + } + } + }); + }); +});