From db099555707b7b9244875edf70fb98641de47b27 Mon Sep 17 00:00:00 2001 From: benoit74 Date: Fri, 6 Nov 2026 20:02:55 +0000 Subject: [PATCH 1/2] Polyfill crypto.randomUUID() for non-secure contexts crypto.randomUUID() is restricted by browsers to secure contexts (HTTPS, or localhost). ZIM content is routinely served over plain HTTP from a non-localhost host (LAN server, offline hotspot, a VPN hostname, ...), which is not a secure context, so the browser omits randomUUID entirely and any page depending on it throws instead of running. crypto.getRandomValues() has no such restriction. Polyfill randomUUID on top of it (standard RFC 4122 v4 construction) whenever it's missing, as a side effect of loading wombatSetup.js, which already runs before any page script (it's injected first in warc2zim's head_insert.html). Found via openzim/warc2zim#327: MDN's "Try it" live-sample widgets use crypto.randomUUID() to build a session id, and silently failed for a user testing a ZIM over a Tailscale hostname (plain HTTP, non-localhost). (#391) --- CHANGELOG.md | 1 + javascript/src/wombatSetup.js | 34 ++++++++++++++++++++++++++++++++++ javascript/test/wombatSetup.js | 22 ++++++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2034c69..11a4020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix JS URL rewriter confusing a percent-encoded `%2B` with a raw `+` in query strings (#389) - Patch vendored `wombat.js` so `retrieveWBOSRCAsURL` (the helper behind the above element-property fix) resolves a recorded original against the frame's true original URL (`wb_info.url`) instead of its replay-time `ownerDocument`, fixing it returning/using a URL under the wrong host for a page whose own URL was changed by a fuzzy rule (e.g. YouTube embeds), which broke playback (#398) - Add a host-gated `SITE_JS_PATCHES` mechanism to `JsRewriter`, fixing MDN's `` widget missing a deterministic `permalink` attribute (#394) +- Polyfill `crypto.randomUUID()` when missing in a non-secure context (plain HTTP, non-`localhost`) (#391) ### Changed diff --git a/javascript/src/wombatSetup.js b/javascript/src/wombatSetup.js index 0aeac98..a4c3f36 100644 --- a/javascript/src/wombatSetup.js +++ b/javascript/src/wombatSetup.js @@ -5,6 +5,39 @@ import URI from 'uri-js'; const VERSION = '[VI]version {version}, built on {date}[/VI]'; console.info(`Running wombat-setup ${VERSION}`); +// `crypto.randomUUID()` is restricted to secure contexts (HTTPS, or `localhost`), but +// ZIM content is commonly served over plain HTTP on a non-localhost host (LAN, offline +// hotspot, ...), where a page depending on it would otherwise throw instead of running. +// `crypto.getRandomValues()` has no such restriction, so polyfill `randomUUID` on top +// of it, producing a standard RFC 4122 v4 UUID, whenever it is missing. +export function randomUUIDFromBytes(bytes) { + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10 + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join( + '', + ); + return ( + hex.slice(0, 8) + + '-' + + hex.slice(8, 12) + + '-' + + hex.slice(12, 16) + + '-' + + hex.slice(16, 20) + + '-' + + hex.slice(20) + ); +} + +if ( + typeof crypto !== 'undefined' && + typeof crypto.getRandomValues === 'function' && + typeof crypto.randomUUID !== 'function' +) { + crypto.randomUUID = () => + randomUUIDFromBytes(crypto.getRandomValues(new Uint8Array(16))); +} + export function applyFuzzyRules(path) { // Apply fuzzy rules to simplify the ZIM path. First matching rule is applied and // result is immediately returned @@ -335,4 +368,5 @@ export default { escapeDotSegments: escapeDotSegments, urlRewriteFunction: urlRewriteFunction, getWombatInfo: getWombatInfo, + randomUUIDFromBytes: randomUUIDFromBytes, }; diff --git a/javascript/test/wombatSetup.js b/javascript/test/wombatSetup.js index 376c8fb..1e101fd 100644 --- a/javascript/test/wombatSetup.js +++ b/javascript/test/wombatSetup.js @@ -31,6 +31,28 @@ test('escapeDotSegmentsLookAlikesUntouched', (t) => { ); }); +test('randomUUIDFromBytesFormat', (t) => { + const bytes = new Uint8Array([ + 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x11, 0x22, 0x33, 0x44, + 0x55, 0x66, 0x77, 0x88, + ]); + // byte 6's high nibble is forced to 4 (version) and byte 8's two top bits + // to 10 (variant), per RFC 4122, regardless of the input + t.is( + utils.randomUUIDFromBytes(bytes), + '12345678-9abc-4ef0-9122-334455667788', + ); +}); + +test('randomUUIDFromBytesIsRfc4122Compliant', (t) => { + const bytes = new Uint8Array(16); + for (let i = 0; i < 16; i++) bytes[i] = 0xff; + t.regex( + utils.randomUUIDFromBytes(bytes), + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); +}); + test('nominalWbInfo', (t) => { const path = 'path1/resource1.js'; const originalUrl = From 394b63ef88a5689b67b4c3def4028c4f25a7e658 Mon Sep 17 00:00:00 2001 From: benoit74 Date: Fri, 6 Nov 2026 20:11:58 +0000 Subject: [PATCH 2/2] Polyfill crypto.subtle.digest() for non-secure contexts Like crypto.randomUUID(), crypto.subtle is restricted to secure contexts, but unlike randomUUID there is no lower-level primitive to build it from - digest() needs an actual hash implementation. Add a small, dependency-free digest(algorithm, data) polyfill supporting SHA-1 and SHA-256, the two algorithms observed in use (our own vendored wombat.js optionally uses SHA-1 for response ETags; archived pages have been seen using SHA-256 to derive a content id), matching the native SubtleCrypto.digest() -> Promise contract. Installed on crypto.subtle the same way, only when missing. sha1Bytes/sha256Bytes are cross-validated against Node's own crypto module across many input lengths, including the Merkle-Damgard padding's block-boundary edge cases, rather than relying solely on hand-transcribed test vectors (one of which did turn out to have a dropped a character - sha1Bytes itself was correct throughout). Found via openzim/warc2zim#327: after the randomUUID polyfill, MDN's live-sample iframes were still stuck at src="about:blank" with no visible error, because the code computing their content id awaits crypto.subtle.digest("SHA-256", ...), which silently never resolves when crypto.subtle is undefined. (#393) --- CHANGELOG.md | 1 + javascript/src/wombatSetup.js | 179 +++++++++++++++++++++++++++++++++ javascript/test/wombatSetup.js | 90 +++++++++++++++++ 3 files changed, 270 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11a4020..eeed6de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Patch vendored `wombat.js` so `retrieveWBOSRCAsURL` (the helper behind the above element-property fix) resolves a recorded original against the frame's true original URL (`wb_info.url`) instead of its replay-time `ownerDocument`, fixing it returning/using a URL under the wrong host for a page whose own URL was changed by a fuzzy rule (e.g. YouTube embeds), which broke playback (#398) - Add a host-gated `SITE_JS_PATCHES` mechanism to `JsRewriter`, fixing MDN's `` widget missing a deterministic `permalink` attribute (#394) - Polyfill `crypto.randomUUID()` when missing in a non-secure context (plain HTTP, non-`localhost`) (#391) +- Polyfill `crypto.subtle.digest()` (SHA-1/SHA-256) when missing in a non-secure context (#393) ### Changed diff --git a/javascript/src/wombatSetup.js b/javascript/src/wombatSetup.js index a4c3f36..77976c4 100644 --- a/javascript/src/wombatSetup.js +++ b/javascript/src/wombatSetup.js @@ -38,6 +38,182 @@ if ( randomUUIDFromBytes(crypto.getRandomValues(new Uint8Array(16))); } +// `crypto.subtle` (SubtleCrypto) is, like `randomUUID`, restricted to secure +// contexts, and unlike `randomUUID` there is no lower-level primitive to build it +// from -- `digest()` needs an actual hash implementation. Polyfill the two +// algorithms observed in use (our own vendored wombat.js optionally uses SHA-1 for +// response ETags; archived pages have been seen using SHA-256 to derive a content +// id), matching the native `SubtleCrypto.digest(algorithm, data)` -> `Promise` +// contract, installed only when `crypto.subtle` itself is missing. + +function rotl32(x, n) { + return ((x << n) | (x >>> (32 - n))) >>> 0; +} + +function rotr32(x, n) { + return ((x >>> n) | (x << (32 - n))) >>> 0; +} + +// Pads `bytes` to a whole number of 64-byte blocks, per the Merkle-Damgard +// construction shared by SHA-1 and SHA-256: a single `1` bit, as many `0` bits as +// needed, and the original bit length as a big-endian 64-bit integer. +function padForSha(bytes) { + const bitLength = bytes.length * 8; + const paddedLength = (bytes.length + 9 + 63) & ~63; + const padded = new Uint8Array(paddedLength); + padded.set(bytes); + padded[bytes.length] = 0x80; + const view = new DataView(padded.buffer); + // `bitLength` safely fits a JS number up to 2^53 bits of input (far more than + // this polyfill will ever realistically be asked to hash), so the high 32 bits + // are obtained with a division rather than a (32-bit-truncating) bit shift. + view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x100000000), false); + view.setUint32(paddedLength - 4, bitLength >>> 0, false); + return padded; +} + +export function sha1Bytes(bytes) { + let [h0, h1, h2, h3, h4] = [ + 0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0, + ]; + const padded = padForSha(bytes); + const view = new DataView(padded.buffer); + const w = new Uint32Array(80); + + for (let offset = 0; offset < padded.length; offset += 64) { + for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4, false); + for (let i = 16; i < 80; i++) { + w[i] = rotl32(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1); + } + + let [a, b, c, d, e] = [h0, h1, h2, h3, h4]; + for (let i = 0; i < 80; i++) { + let f, k; + if (i < 20) { + f = (b & c) | (~b & d); + k = 0x5a827999; + } else if (i < 40) { + f = b ^ c ^ d; + k = 0x6ed9eba1; + } else if (i < 60) { + f = (b & c) | (b & d) | (c & d); + k = 0x8f1bbcdc; + } else { + f = b ^ c ^ d; + k = 0xca62c1d6; + } + const temp = (rotl32(a, 5) + f + e + k + w[i]) >>> 0; + e = d; + d = c; + c = rotl32(b, 30); + b = a; + a = temp; + } + h0 = (h0 + a) >>> 0; + h1 = (h1 + b) >>> 0; + h2 = (h2 + c) >>> 0; + h3 = (h3 + d) >>> 0; + h4 = (h4 + e) >>> 0; + } + + const out = new Uint8Array(20); + const outView = new DataView(out.buffer); + [h0, h1, h2, h3, h4].forEach((h, i) => outView.setUint32(i * 4, h, false)); + return out; +} + +const SHA256_K = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, + 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, + 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, + 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, + 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]; + +export function sha256Bytes(bytes) { + let h = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, + 0x1f83d9ab, 0x5be0cd19, + ]; + const padded = padForSha(bytes); + const view = new DataView(padded.buffer); + const w = new Uint32Array(64); + + for (let offset = 0; offset < padded.length; offset += 64) { + for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4, false); + for (let i = 16; i < 64; i++) { + const s0 = + rotr32(w[i - 15], 7) ^ rotr32(w[i - 15], 18) ^ (w[i - 15] >>> 3); + const s1 = + rotr32(w[i - 2], 17) ^ rotr32(w[i - 2], 19) ^ (w[i - 2] >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0; + } + + let [a, b, c, d, e, f, g, hh] = h; + for (let i = 0; i < 64; i++) { + const S1 = rotr32(e, 6) ^ rotr32(e, 11) ^ rotr32(e, 25); + const ch = (e & f) ^ (~e & g); + const temp1 = (hh + S1 + ch + SHA256_K[i] + w[i]) >>> 0; + const S0 = rotr32(a, 2) ^ rotr32(a, 13) ^ rotr32(a, 22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const temp2 = (S0 + maj) >>> 0; + hh = g; + g = f; + f = e; + e = (d + temp1) >>> 0; + d = c; + c = b; + b = a; + a = (temp1 + temp2) >>> 0; + } + h = [ + (h[0] + a) >>> 0, + (h[1] + b) >>> 0, + (h[2] + c) >>> 0, + (h[3] + d) >>> 0, + (h[4] + e) >>> 0, + (h[5] + f) >>> 0, + (h[6] + g) >>> 0, + (h[7] + hh) >>> 0, + ]; + } + + const out = new Uint8Array(32); + const outView = new DataView(out.buffer); + h.forEach((hv, i) => outView.setUint32(i * 4, hv, false)); + return out; +} + +export async function digestPolyfill(algorithm, data) { + const name = (typeof algorithm === 'string' ? algorithm : algorithm.name) + .toUpperCase() + .replace('SHA', 'SHA-') + .replace('SHA--', 'SHA-'); + const bytes = + data instanceof ArrayBuffer + ? new Uint8Array(data) + : new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + let out; + if (name === 'SHA-1') out = sha1Bytes(bytes); + else if (name === 'SHA-256') out = sha256Bytes(bytes); + else + throw new DOMException( + 'Unsupported digest algorithm: ' + algorithm, + 'NotSupportedError', + ); + return out.buffer; +} + +if (typeof crypto !== 'undefined' && typeof crypto.subtle === 'undefined') { + crypto.subtle = { digest: digestPolyfill }; +} + export function applyFuzzyRules(path) { // Apply fuzzy rules to simplify the ZIM path. First matching rule is applied and // result is immediately returned @@ -369,4 +545,7 @@ export default { urlRewriteFunction: urlRewriteFunction, getWombatInfo: getWombatInfo, randomUUIDFromBytes: randomUUIDFromBytes, + sha1Bytes: sha1Bytes, + sha256Bytes: sha256Bytes, + digestPolyfill: digestPolyfill, }; diff --git a/javascript/test/wombatSetup.js b/javascript/test/wombatSetup.js index 1e101fd..c254d2c 100644 --- a/javascript/test/wombatSetup.js +++ b/javascript/test/wombatSetup.js @@ -1,7 +1,20 @@ import test from 'ava'; +import nodeCrypto from 'node:crypto'; import utils from '../src/wombatSetup.js'; +function textBytes(s) { + return new TextEncoder().encode(s); +} + +function nodeDigestHex(algorithm, bytes) { + return nodeCrypto.createHash(algorithm).update(bytes).digest('hex'); +} + +function toHex(bytes) { + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); +} + test.beforeEach((t) => { t.context.prefix = 'http://library.kiwix.org/content/myzim_yyyy-mm/'; t.context.originalHost = 'www.example.com'; @@ -53,6 +66,83 @@ test('randomUUIDFromBytesIsRfc4122Compliant', (t) => { ); }); +// Cross-validated against Node's own `crypto` module rather than hard-coded +// digests, covering the Merkle-Damgard padding's block-boundary edge cases (the +// input length lands exactly on, just under, and just over a 64-byte block once +// the mandatory `0x80` byte and 8-byte length suffix are accounted for). +const digestLengths = [ + 0, 1, 3, 13, 55, 56, 57, 63, 64, 65, 119, 120, 121, 1000, +]; + +for (const length of digestLengths) { + const bytes = new Uint8Array(length); + for (let i = 0; i < length; i++) bytes[i] = (i * 37 + 11) % 256; + + test(`sha1BytesMatchesNodeCrypto-${length}`, (t) => { + t.is(toHex(utils.sha1Bytes(bytes)), nodeDigestHex('sha1', bytes)); + }); + + test(`sha256BytesMatchesNodeCrypto-${length}`, (t) => { + t.is(toHex(utils.sha256Bytes(bytes)), nodeDigestHex('sha256', bytes)); + }); +} + +test('sha1BytesKnownVector', (t) => { + // NIST/RFC well-known test vector + t.is( + toHex(utils.sha1Bytes(textBytes('abc'))), + 'a9993e364706816aba3e25717850c26c9cd0d89d', + ); +}); + +test('sha256BytesKnownVector', (t) => { + // NIST/RFC well-known test vector + t.is( + toHex(utils.sha256Bytes(textBytes('abc'))), + 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad', + ); +}); + +test('digestPolyfillStringAlgorithmName', async (t) => { + const result = await utils.digestPolyfill('SHA-256', textBytes('abc')); + t.true(result instanceof ArrayBuffer); + t.is( + toHex(new Uint8Array(result)), + nodeDigestHex('sha256', textBytes('abc')), + ); +}); + +test('digestPolyfillObjectAlgorithmName', async (t) => { + const result = await utils.digestPolyfill( + { name: 'SHA-1' }, + textBytes('abc'), + ); + t.is(toHex(new Uint8Array(result)), nodeDigestHex('sha1', textBytes('abc'))); +}); + +test('digestPolyfillAcceptsTypedArrayView', async (t) => { + const buf = new ArrayBuffer(20); + const view = new Uint8Array(buf, 4, 3); // non-zero byteOffset, sub-length view + view.set(textBytes('abc')); + const result = await utils.digestPolyfill('SHA-256', view); + t.is( + toHex(new Uint8Array(result)), + nodeDigestHex('sha256', textBytes('abc')), + ); +}); + +test('digestPolyfillAcceptsArrayBuffer', async (t) => { + const result = await utils.digestPolyfill('SHA-256', textBytes('abc').buffer); + t.is( + toHex(new Uint8Array(result)), + nodeDigestHex('sha256', textBytes('abc')), + ); +}); + +test('digestPolyfillRejectsUnsupportedAlgorithm', async (t) => { + await t.throwsAsync(() => utils.digestPolyfill('SHA-384', textBytes('abc'))); +}); + test('nominalWbInfo', (t) => { const path = 'path1/resource1.js'; const originalUrl =