diff --git a/CHANGELOG.md b/CHANGELOG.md index 2034c69..eeed6de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ 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) +- 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 0aeac98..77976c4 100644 --- a/javascript/src/wombatSetup.js +++ b/javascript/src/wombatSetup.js @@ -5,6 +5,215 @@ 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))); +} + +// `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 @@ -335,4 +544,8 @@ export default { escapeDotSegments: escapeDotSegments, 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 376c8fb..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'; @@ -31,6 +44,105 @@ 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}$/, + ); +}); + +// 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 =