Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<interactive-example>` 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

Expand Down
213 changes: 213 additions & 0 deletions javascript/src/wombatSetup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArrayBuffer>`
// 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
Expand Down Expand Up @@ -335,4 +544,8 @@ export default {
escapeDotSegments: escapeDotSegments,
urlRewriteFunction: urlRewriteFunction,
getWombatInfo: getWombatInfo,
randomUUIDFromBytes: randomUUIDFromBytes,
sha1Bytes: sha1Bytes,
sha256Bytes: sha256Bytes,
digestPolyfill: digestPolyfill,
};
112 changes: 112 additions & 0 deletions javascript/test/wombatSetup.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 =
Expand Down