f73e710e38
Build site and push to gh-pages / Build site (push) Waiting to run
Build / build (push) Waiting to run
Linter / lint (push) Waiting to run
Security / dependency-review (push) Waiting to run
Security / npm-audit (push) Waiting to run
Security / codeql (push) Waiting to run
Tests / test (push) Waiting to run
328 lines
10 KiB
TypeScript
328 lines
10 KiB
TypeScript
import { verifyIntegrity, isValidSRI } from "../src/integrity";
|
|
import { IntegrityError } from "../src/error";
|
|
import { describe, test, expect, jest } from "@jest/globals";
|
|
|
|
// Helper: compute an SRI hash for a given string
|
|
async function computeSRI(
|
|
data: string,
|
|
algorithm: "SHA-256" | "SHA-384" | "SHA-512" = "SHA-256",
|
|
): Promise<string> {
|
|
const encoder = new TextEncoder();
|
|
const buffer = encoder.encode(data);
|
|
const hashBuffer = await crypto.subtle.digest(algorithm, buffer);
|
|
const hashArray = new Uint8Array(hashBuffer);
|
|
let binary = "";
|
|
for (let i = 0; i < hashArray.length; i++) {
|
|
binary += String.fromCharCode(hashArray[i]);
|
|
}
|
|
const base64 = btoa(binary);
|
|
const algoPrefix = algorithm.replace("-", "").toLowerCase();
|
|
return `${algoPrefix}-${base64}`;
|
|
}
|
|
|
|
function base64ForByteLength(byteLength: number): string {
|
|
let binary = "";
|
|
for (let i = 0; i < byteLength; i++) {
|
|
binary += String.fromCharCode(0);
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
|
|
describe("isValidSRI", () => {
|
|
test("accepts valid sha256 SRI", () => {
|
|
expect(
|
|
isValidSRI("sha256-MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM="),
|
|
).toBe(true);
|
|
});
|
|
|
|
test("accepts valid sha384 SRI", () => {
|
|
expect(
|
|
isValidSRI(
|
|
"sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC",
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
test("accepts valid sha512 SRI", async () => {
|
|
const sri = await computeSRI("WebLLM", "SHA-512");
|
|
expect(isValidSRI(sri)).toBe(true);
|
|
});
|
|
|
|
test("rejects invalid algorithm", () => {
|
|
expect(isValidSRI("sha1-abc123")).toBe(false);
|
|
expect(isValidSRI("md5-abc123")).toBe(false);
|
|
});
|
|
|
|
test("rejects missing algorithm prefix", () => {
|
|
expect(isValidSRI("abc123")).toBe(false);
|
|
});
|
|
|
|
test("rejects empty string", () => {
|
|
expect(isValidSRI("")).toBe(false);
|
|
});
|
|
|
|
test("rejects malformed SRI", () => {
|
|
expect(isValidSRI("sha256-")).toBe(false);
|
|
expect(isValidSRI("sha256-!!!invalid!!!")).toBe(false);
|
|
});
|
|
|
|
test("rejects excessive base64 padding", () => {
|
|
// Valid base64 has at most 2 padding characters
|
|
expect(isValidSRI("sha256-abc===")).toBe(false);
|
|
expect(isValidSRI("sha256-abc====")).toBe(false);
|
|
});
|
|
|
|
test("rejects algorithm/hash decoded-length mismatch", () => {
|
|
const sha512Hash = base64ForByteLength(64);
|
|
expect(isValidSRI(`sha256-${sha512Hash}`)).toBe(false);
|
|
});
|
|
|
|
test("rejects invalid padded base64 quantum", () => {
|
|
expect(isValidSRI("sha256-A=")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("verifyIntegrity", () => {
|
|
const testData = "Hello, WebLLM!";
|
|
const testBuffer = new TextEncoder().encode(testData).buffer;
|
|
|
|
test("passes with correct SHA-256 hash", async () => {
|
|
const sri = await computeSRI(testData, "SHA-256");
|
|
await expect(
|
|
verifyIntegrity(testBuffer, sri, "https://example.com/test.json"),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
test("passes with correct SHA-384 hash", async () => {
|
|
const sri = await computeSRI(testData, "SHA-384");
|
|
await expect(
|
|
verifyIntegrity(testBuffer, sri, "https://example.com/test.json"),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
test("passes with correct SHA-512 hash", async () => {
|
|
const sri = await computeSRI(testData, "SHA-512");
|
|
await expect(
|
|
verifyIntegrity(testBuffer, sri, "https://example.com/test.json"),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
// Test with a well-known hardcoded hash to ensure the implementation
|
|
// produces correct results, not just self-consistent ones.
|
|
// SHA-256 of empty string = 47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=
|
|
test("produces correct SHA-256 for known empty-string hash", async () => {
|
|
const emptyBuffer = new ArrayBuffer(0);
|
|
const knownEmptySHA256 =
|
|
"sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=";
|
|
await expect(
|
|
verifyIntegrity(emptyBuffer, knownEmptySHA256, "https://example.com/e"),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
// SHA-256 of "abc" = ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0=
|
|
test("produces correct SHA-256 for known 'abc' hash", async () => {
|
|
const abcBuffer = new TextEncoder().encode("abc").buffer;
|
|
const knownAbcSHA256 =
|
|
"sha256-ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0=";
|
|
await expect(
|
|
verifyIntegrity(abcBuffer, knownAbcSHA256, "https://example.com/abc"),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
test("throws IntegrityError on hash mismatch", async () => {
|
|
const wrongSRI = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
|
await expect(
|
|
verifyIntegrity(
|
|
testBuffer,
|
|
wrongSRI,
|
|
"https://example.com/config.json",
|
|
"error",
|
|
),
|
|
).rejects.toThrow(IntegrityError);
|
|
});
|
|
|
|
test("IntegrityError contains expected and actual hashes", async () => {
|
|
expect.assertions(5);
|
|
const wrongSRI = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
|
try {
|
|
await verifyIntegrity(
|
|
testBuffer,
|
|
wrongSRI,
|
|
"https://example.com/config.json",
|
|
"error",
|
|
);
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(IntegrityError);
|
|
const integrityErr = err as IntegrityError;
|
|
expect(integrityErr.url).toBe("https://example.com/config.json");
|
|
expect(integrityErr.expected).toBe(wrongSRI);
|
|
expect(integrityErr.actual).toMatch(/^sha256-/);
|
|
expect(integrityErr.message).toContain("Integrity verification failed");
|
|
}
|
|
});
|
|
|
|
test("IntegrityError has correct name property", async () => {
|
|
expect.assertions(1);
|
|
const wrongSRI = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
|
try {
|
|
await verifyIntegrity(
|
|
testBuffer,
|
|
wrongSRI,
|
|
"https://example.com/file",
|
|
"error",
|
|
);
|
|
} catch (err) {
|
|
expect((err as Error).name).toBe("IntegrityError");
|
|
}
|
|
});
|
|
|
|
test("default onFailure throws on mismatch", async () => {
|
|
// When onFailure is not specified, it should default to "error" and throw
|
|
const wrongSRI = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
|
await expect(
|
|
verifyIntegrity(testBuffer, wrongSRI, "https://example.com/file"),
|
|
).rejects.toThrow(IntegrityError);
|
|
});
|
|
|
|
test("warns instead of throwing when onFailure is 'warn'", async () => {
|
|
const wrongSRI = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
|
|
|
const logModule = await import("loglevel");
|
|
const logWarnSpy = jest
|
|
.spyOn(logModule.default, "warn")
|
|
.mockImplementation(() => {});
|
|
|
|
await expect(
|
|
verifyIntegrity(
|
|
testBuffer,
|
|
wrongSRI,
|
|
"https://example.com/model.wasm",
|
|
"warn",
|
|
),
|
|
).resolves.toBeUndefined();
|
|
|
|
expect(logWarnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining("Integrity check failed"),
|
|
);
|
|
|
|
logWarnSpy.mockRestore();
|
|
});
|
|
|
|
test("warn message includes URL and both hashes", async () => {
|
|
const wrongSRI = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
|
|
|
const logModule = await import("loglevel");
|
|
const logWarnSpy = jest
|
|
.spyOn(logModule.default, "warn")
|
|
.mockImplementation(() => {});
|
|
|
|
await verifyIntegrity(
|
|
testBuffer,
|
|
wrongSRI,
|
|
"https://example.com/model.wasm",
|
|
"warn",
|
|
);
|
|
|
|
const warnMessage = logWarnSpy.mock.calls[0][0] as string;
|
|
expect(warnMessage).toContain("https://example.com/model.wasm");
|
|
expect(warnMessage).toContain(wrongSRI);
|
|
expect(warnMessage).toMatch(/sha256-[A-Za-z0-9+/]+=*/); // actual hash
|
|
|
|
logWarnSpy.mockRestore();
|
|
});
|
|
|
|
test("throws on invalid SRI format", async () => {
|
|
await expect(
|
|
verifyIntegrity(testBuffer, "invalid-format", "https://example.com/file"),
|
|
).rejects.toThrow("Invalid SRI hash format");
|
|
});
|
|
|
|
test("throws on algorithm/hash decoded-length mismatch", async () => {
|
|
const sha512Hash = base64ForByteLength(64);
|
|
await expect(
|
|
verifyIntegrity(
|
|
testBuffer,
|
|
`sha256-${sha512Hash}`,
|
|
"https://example.com/file",
|
|
),
|
|
).rejects.toThrow("Invalid SRI hash format");
|
|
});
|
|
|
|
test("throws on sha1 (unsupported algorithm)", async () => {
|
|
await expect(
|
|
verifyIntegrity(testBuffer, "sha1-abc123", "https://example.com/file"),
|
|
).rejects.toThrow("Invalid SRI hash format");
|
|
});
|
|
|
|
test("handles empty ArrayBuffer", async () => {
|
|
const emptyBuffer = new ArrayBuffer(0);
|
|
const sri = await computeSRI("", "SHA-256");
|
|
await expect(
|
|
verifyIntegrity(emptyBuffer, sri, "https://example.com/empty"),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
test("handles large data (1MB)", async () => {
|
|
const largeData = new Uint8Array(1024 * 1024);
|
|
// Fill with deterministic data
|
|
for (let i = 0; i < largeData.length; i++) {
|
|
largeData[i] = i % 256;
|
|
}
|
|
const hashBuffer = await crypto.subtle.digest("SHA-256", largeData);
|
|
const hashArray = new Uint8Array(hashBuffer);
|
|
let binary = "";
|
|
for (let i = 0; i < hashArray.length; i++) {
|
|
binary += String.fromCharCode(hashArray[i]);
|
|
}
|
|
const sri = `sha256-${btoa(binary)}`;
|
|
|
|
await expect(
|
|
verifyIntegrity(largeData.buffer, sri, "https://example.com/large.bin"),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
test("rejects when data is modified after hash computation", async () => {
|
|
const originalData = new Uint8Array([1, 2, 3, 4, 5]);
|
|
const sri = await computeSRI(
|
|
String.fromCharCode(...originalData),
|
|
"SHA-256",
|
|
);
|
|
|
|
const modifiedData = new Uint8Array([1, 2, 3, 4, 6]);
|
|
await expect(
|
|
verifyIntegrity(
|
|
modifiedData.buffer,
|
|
sri,
|
|
"https://example.com/tampered",
|
|
"error",
|
|
),
|
|
).rejects.toThrow(IntegrityError);
|
|
});
|
|
|
|
test("different data produces different actual hashes in error", async () => {
|
|
expect.assertions(2);
|
|
const wrongSRI = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
|
|
|
const data1 = new TextEncoder().encode("data1").buffer;
|
|
const data2 = new TextEncoder().encode("data2").buffer;
|
|
|
|
let actual1 = "";
|
|
let actual2 = "";
|
|
|
|
try {
|
|
await verifyIntegrity(data1, wrongSRI, "https://example.com/1", "error");
|
|
} catch (err) {
|
|
actual1 = (err as IntegrityError).actual;
|
|
}
|
|
|
|
try {
|
|
await verifyIntegrity(data2, wrongSRI, "https://example.com/2", "error");
|
|
} catch (err) {
|
|
actual2 = (err as IntegrityError).actual;
|
|
}
|
|
|
|
expect(actual1).not.toBe(actual2);
|
|
expect(isValidSRI(actual1) && isValidSRI(actual2)).toBe(true);
|
|
});
|
|
});
|