mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
89 lines
3.3 KiB
TypeScript
89 lines
3.3 KiB
TypeScript
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
import { createHash } from 'node:crypto';
|
|
import { sha256Hex, verifyBundle, constantTimeHexEqual } from '../plugin-sandbox/bundle-integrity';
|
|
|
|
const CODE = 'export default { activate() {} } // ' + 'x'.repeat(5000);
|
|
const nodeHash = (s: string) => createHash('sha256').update(s, 'utf-8').digest('hex');
|
|
const realCrypto = globalThis.crypto;
|
|
|
|
/**
|
|
* What a plain-http origin exposes: `crypto.getRandomValues` is there, but
|
|
* `crypto.subtle` is not (WebCrypto is limited to secure contexts).
|
|
*/
|
|
function stubInsecureContext(): void {
|
|
vi.stubGlobal('crypto', {
|
|
getRandomValues: (array: Uint8Array) => realCrypto.getRandomValues(array),
|
|
});
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe('sha256Hex', () => {
|
|
it('matches a reference digest via WebCrypto', async () => {
|
|
expect(typeof crypto.subtle?.digest).toBe('function');
|
|
expect(await sha256Hex(CODE)).toBe(nodeHash(CODE));
|
|
});
|
|
|
|
it('hashes byte input the same as string input', async () => {
|
|
expect(await sha256Hex(new TextEncoder().encode(CODE))).toBe(nodeHash(CODE));
|
|
});
|
|
|
|
it('handles multi-byte UTF-8', async () => {
|
|
const s = 'héllo → 世界 🚀';
|
|
expect(await sha256Hex(s)).toBe(nodeHash(s));
|
|
});
|
|
|
|
it('falls back to a JavaScript digest when crypto.subtle is unavailable (plain-http origin)', async () => {
|
|
stubInsecureContext();
|
|
expect(globalThis.crypto.subtle).toBeUndefined();
|
|
expect(await sha256Hex(CODE)).toBe(nodeHash(CODE));
|
|
expect(await sha256Hex('')).toBe(nodeHash(''));
|
|
expect(await sha256Hex(new TextEncoder().encode('bytes'))).toBe(nodeHash('bytes'));
|
|
});
|
|
|
|
it('falls back when WebCrypto refuses the digest call', async () => {
|
|
vi.stubGlobal('crypto', {
|
|
getRandomValues: (array: Uint8Array) => realCrypto.getRandomValues(array),
|
|
subtle: { digest: vi.fn().mockRejectedValue(new Error('NotSupportedError')) },
|
|
});
|
|
expect(await sha256Hex(CODE)).toBe(nodeHash(CODE));
|
|
});
|
|
});
|
|
|
|
describe('verifyBundle', () => {
|
|
it('accepts a quoted / upper-case expected hash (ETag form)', async () => {
|
|
const hash = nodeHash(CODE);
|
|
await expect(verifyBundle(CODE, '"' + hash + '"')).resolves.toBe(hash);
|
|
await expect(verifyBundle(CODE, hash.toUpperCase())).resolves.toBe(hash);
|
|
});
|
|
|
|
it('throws on mismatch', async () => {
|
|
await expect(verifyBundle(CODE, nodeHash('something else'))).rejects.toThrow(/integrity mismatch/);
|
|
});
|
|
|
|
it('returns the computed hash when no expected hash is given', async () => {
|
|
await expect(verifyBundle(CODE, undefined)).resolves.toBe(nodeHash(CODE));
|
|
await expect(verifyBundle(CODE, null)).resolves.toBe(nodeHash(CODE));
|
|
});
|
|
|
|
it('still verifies (and still refuses) without WebCrypto', async () => {
|
|
stubInsecureContext();
|
|
await expect(verifyBundle(CODE, nodeHash(CODE))).resolves.toBe(nodeHash(CODE));
|
|
await expect(verifyBundle(CODE, nodeHash('tampered'))).rejects.toThrow(/integrity mismatch/);
|
|
});
|
|
});
|
|
|
|
describe('constantTimeHexEqual', () => {
|
|
it('compares equal-length lower-case hex', () => {
|
|
expect(constantTimeHexEqual('abcd', 'abcd')).toBe(true);
|
|
expect(constantTimeHexEqual('abcd', 'abce')).toBe(false);
|
|
});
|
|
|
|
it('rejects structural mismatches', () => {
|
|
expect(constantTimeHexEqual('abcd', 'abc')).toBe(false);
|
|
expect(constantTimeHexEqual('abcd', undefined as unknown as string)).toBe(false);
|
|
});
|
|
});
|