mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-05-14 21:55:04 +00:00
feat(sri): implement SRI verification for external scripts and add integrity.json files for Codec2 and RNode Flasher
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user