openssl rand), all from the browser's CSPRNGdiff --git a/popup.js b/popup.js index b7b81a1..20c5d94 100644 --- a/popup.js +++ b/popup.js @@ -5,6 +5,7 @@ var TAB_META = { crc : { rows : 2 }, cipher : { rows : 2, placeholder : "text to encrypt, or a Salted__ base64 payload to decrypt" }, net : { rows : 1, placeholder : "10.0.12.42/22, 2001:db8::1, ::ffff:192.0.2.1, or a decimal" }, + url : { rows : 2, placeholder : "https://example.com/path?x=1&y=2#frag" }, time : { rows : 1, placeholder : "unix seconds or ms, ISO 8601, RFC-1123, 2019-02-27 09:36:55 \u2014 empty is now" }, number : { rows : 1, placeholder : "42, 0x2a, 0b101010, 0o52, XLII, 3.14 \u2014 any size" }, string : { rows : 2, placeholder : "text, or hex to decode; fooBarBaz for the case conversions" }, @@ -12,7 +13,8 @@ var TAB_META = { diff : { rows : 6, placeholder : "original text" }, encode : { rows : 2 }, cron : { rows : 1, placeholder : "*/15 9-17 * * mon-fri" }, - jwt : { rows : 3, placeholder : "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\u2026" } + jwt : { rows : 3, placeholder : "a token to decode and verify, or a JSON payload to sign with HS256" }, + uuid : { rows : 1, placeholder : "paste a UUID or ULID to inspect, or a name for UUID v5" } }; document.addEventListener("DOMContentLoaded", function () { @@ -289,7 +291,7 @@ document.addEventListener("DOMContentLoaded", function () { // Diff: the second text and the options inputOther.addEventListener("input", function () { - hasher.options.diff.other = inputOther.value; + hasher.options.other = inputOther.value; document.getElementById("input-other-counter").textContent = inputOther.value.length ? inputOther.value.length + " chars" : ""; hasher.update(); }); @@ -335,12 +337,16 @@ document.addEventListener("DOMContentLoaded", function () { inputMask.checked = false; applyMask(); } - inputWrapper.hidden = hasher.tab == tabs.password || hasher.tab == tabs.uuid; + inputWrapper.hidden = hasher.tab == tabs.password; passwordOptions.hidden = hasher.tab != tabs.password; uuidOptions.hidden = hasher.tab != tabs.uuid; cronOptions.hidden = hasher.tab != tabs.cron; document.getElementById("json-options").hidden = hasher.tab != tabs.json; - otherWrapper.hidden = hasher.tab != tabs.diff; + // the second text area: the other text for Diff, the public key for JWT + otherWrapper.hidden = !(hasher.tab == tabs.diff || hasher.tab == tabs.jwt); + otherWrapper.querySelector(".label").textContent = hasher.tab == tabs.jwt ? "Public key" : "Compare with"; + inputOther.placeholder = hasher.tab == tabs.jwt ? "-----BEGIN PUBLIC KEY----- or a JWK, for RS / PS / ES / EdDSA tokens" : "changed text"; + inputOther.rows = hasher.tab == tabs.jwt ? 3 : 6; diffOptions.hidden = hasher.tab != tabs.diff; inputWrapper.classList.toggle("columns", hasher.tab == tabs.diff); diff --git a/test/diff.test.js b/test/diff.test.js index f4a3db2..a849c01 100644 --- a/test/diff.test.js +++ b/test/diff.test.js @@ -79,10 +79,11 @@ test('html rendering', () => { }); test('elements', () => { - hasher.options.diff = { other: '', ignoreWhitespace: false, ignoreCase: false }; + hasher.options.other = ''; + hasher.options.diff = { ignoreWhitespace: false, ignoreCase: false }; assert.equal(calc('15changes', ''), ''); assert.equal(hasher.elements.d1.hint(''), ''); - hasher.options.diff.other = 'a\nb\n'; + hasher.options.other = 'a\nb\n'; assert.equal(hasher.elements.d1.hint('a\nb\n'), 'identical'); assert.equal(hasher.elements.d1.tone('a\nb\n'), 'ok'); assert.equal(calc('15changes', 'a\nb\n'), ''); @@ -91,7 +92,8 @@ test('elements', () => { assert.match(calc('15changes', 'a\nc\n'), /d-del.*d-add/s); hasher.options.diff.ignoreCase = true; assert.equal(hasher.elements.d1.hint('A\nB\n'), 'identical'); - hasher.options.diff = { other: '', ignoreWhitespace: false, ignoreCase: false }; + hasher.options.other = ''; + hasher.options.diff = { ignoreWhitespace: false, ignoreCase: false }; }); test('large inputs do not blow up', () => { diff --git a/test/ids.test.js b/test/ids.test.js index eba97b3..ff05717 100644 --- a/test/ids.test.js +++ b/test/ids.test.js @@ -65,3 +65,37 @@ test('hints state the fixed entropy of each format', () => { assert.equal(hasher.elements.p4.hint(), 'time-ordered · 48-bit timestamp + 74 random bits'); assert.equal(hasher.elements.p5.hint(), 'time-ordered · 48-bit timestamp + 80 random bits'); }); + +test('inspect a pasted UUID', () => { + const { hasher } = ctx; + assert.equal(calc('14inspect', '6ba7b810-9dad-11d1-80b4-00c04fd430c8'), 'UUID v1 — time-based, MAC address · RFC 4122 variant'); + assert.match(hasher.elements.u1.hint('6ba7b810-9dad-11d1-80b4-00c04fd430c8'), /^created 1998-02-04T22:13:53\.151Z · \d+ years ago$/); + assert.equal(calc('14inspect', ids.uuid4()), 'UUID v4 — random · RFC 4122 variant'); + assert.equal(hasher.elements.u1.hint(ids.uuid4()), '', 'v4 has no time'); + const v7 = ids.uuid7(new Date(1700000000123)); + assert.equal(calc('14inspect', v7), 'UUID v7 — time-ordered, Unix ms · RFC 4122 variant'); + assert.match(hasher.elements.u1.hint(v7), /^created 2023-11-14T22:13:20\.123Z/); + // the RFC 9562 v6 example + assert.equal(calc('14inspect', '1EC9414C-232A-6B00-B3C8-9F6BDECED846'), 'UUID v6 — time-ordered (reordered v1) · RFC 4122 variant'); + assert.match(hasher.elements.u1.hint('1EC9414C-232A-6B00-B3C8-9F6BDECED846'), /^created 2022-02-22T19:22:22\.000Z/); + assert.equal(calc('14inspect', '00000000-0000-0000-0000-000000000000'), 'nil UUID (all zeros)'); + assert.equal(calc('14inspect', 'ffffffff-ffff-ffff-ffff-ffffffffffff'), 'max UUID (all ones)'); + assert.equal(calc('14inspect', '{6BA7B810-9DAD-11D1-80B4-00C04FD430C8}'), 'UUID v1 — time-based, MAC address · RFC 4122 variant', 'braces and upper case'); + assert.equal(calc('14inspect', 'urn:uuid:6ba7b810-9dad-11d1-80b4-00c04fd430c8').slice(0, 7), 'UUID v1', 'urn prefix'); + assert.equal(calc('14inspect', '6ba7b8109dad11d180b400c04fd430c8').slice(0, 7), 'UUID v1', 'no dashes'); + assert.equal(calc('14inspect', '01ARYZ6S41TSV4RRFFQ69G5FAV'), 'ULID — 48-bit time + 80 random bits'); + assert.match(hasher.elements.u1.hint('01ARYZ6S41TSV4RRFFQ69G5FAV'), /^created 2016-07-30T22:36:16\.385Z/); + assert.equal(calc('14inspect', 'hello'), ''); + assert.equal(calc('14inspect', ''), ''); +}); + +// python3 -c 'import uuid; print(uuid.uuid5(uuid.NAMESPACE_DNS, "python.org"))' +test('UUID v5 from a name', () => { + assert.equal(calc('14v5dns', 'python.org'), '886313e1-3b8a-5372-9b90-0c9aee199e5d'); + assert.equal(calc('14v5url', 'http://python.org'), '50960143-e2b7-5b7a-9ca7-05375f51d5c1'); + assert.equal(calc('14v5dns', 'привет'), '43eec7ca-0ff7-5d77-9faa-6955bd8e6dc9', 'UTF-8 name'); + assert.equal(calc('14v5dns', ' python.org '), '886313e1-3b8a-5372-9b90-0c9aee199e5d', 'trimmed'); + assert.equal(calc('14v5dns', ids.uuid4()), '', 'a UUID is inspected, not hashed'); + assert.equal(calc('14v5dns', '01ARYZ6S41TSV4RRFFQ69G5FAV'), '', 'a ULID too'); + assert.equal(calc('14v5dns', ''), ''); +}); diff --git a/test/jwt.test.js b/test/jwt.test.js index e1f28f2..2595cea 100644 --- a/test/jwt.test.js +++ b/test/jwt.test.js @@ -1,7 +1,7 @@ 'use strict'; const { test } = require('node:test'); const assert = require('node:assert/strict'); -const { ctx, calc } = require('./load'); +const { ctx, calc, run } = require('./load'); const { jwt } = ctx; @@ -68,24 +68,80 @@ test('claims', () => { assert.deepEqual(Array.from(jwt.describeClaims('not an object', now)), []); }); -test('elements', () => { +test('elements', async () => { + const { hasher } = ctx; assert.equal(calc('13header', HS256), '{\n "alg": "HS256",\n "typ": "JWT"\n}'); assert.equal(calc('13payload', HS256), '{\n "sub": "1234567890",\n "name": "John Doe",\n "iat": 1516239022\n}'); assert.match(calc('13claims', HS256), /^sub: 1234567890\niat: 2018-\d{2}-\d{2} .* \(issued \d+ years ago\)$/); - assert.equal(calc('13signature', HS256, ''), 'HS256: enter the secret above to verify'); - assert.equal(calc('13signature', HS256, 'your-256-bit-secret'), 'HS256: valid, signed with this secret'); - assert.equal(calc('13signature', HS256, 'nope'), 'HS256: INVALID for this secret'); - assert.equal(calc('13signature', RS256, 'x'), 'RS256: cannot verify here (only HS256/384/512)'); + assert.deepEqual(await run('13signature', HS256, ''), { value: 'HS256: enter the secret above to verify', hint: '', tone: '' }); + assert.deepEqual(await run('13signature', HS256, 'your-256-bit-secret'), { value: 'HS256: valid, signed with this secret', hint: '', tone: 'ok' }); + assert.deepEqual(await run('13signature', HS256, 'nope'), { value: 'HS256: INVALID for this secret', hint: '', tone: 'bad' }); + hasher.options.other = ''; + assert.equal(calc('13signature', RS256, 'x'), 'RS256: paste the public key (PEM or JWK) below to verify'); assert.equal(calc('13header', 'garbage'), 'Invalid: expected 3 dot-separated segments, got 1'); - const { hasher } = ctx; assert.equal(hasher.elements.w1.hint(HS256), 'HS256'); assert.equal(hasher.elements.w2.hint(HS256), '3 claims'); - assert.equal(hasher.elements.w4.tone(HS256, 'your-256-bit-secret'), 'ok'); - assert.equal(hasher.elements.w4.tone(HS256, 'nope'), 'bad'); - assert.equal(hasher.elements.w4.tone(HS256, ''), ''); assert.equal(hasher.elements.w1.hint('garbage'), ''); for (const id of ['13payload', '13claims', '13signature']) { assert.equal(calc(id, 'garbage', 'x'), '', id); assert.equal(calc(id, '', 'x'), '', id); } }); + +test('sign a JSON payload with HS256', () => { + const payload = '{"sub": "1234567890", "name": "John Doe", "iat": 1516239022}'; + const token = calc('13sign', payload, 'your-256-bit-secret'); + assert.equal(token, HS256, 'byte-for-byte the jwt.io example'); + assert.equal(calc('13sign', payload, ''), ''); + assert.equal(ctx.hasher.elements.w5.hint(payload, ''), 'enter a secret above to sign this payload'); + assert.equal(calc('13sign', HS256, 'secret'), '', 'a token is not a payload'); + assert.equal(calc('13sign', '[1,2]', 'secret'), '', 'an array is not a payload'); + assert.equal(calc('13header', payload), '', 'a payload does not show up as an invalid token'); +}); + +const nodeCrypto = require('node:crypto'); +const b64u = (buf) => Buffer.from(buf).toString('base64url'); +const makeToken = (alg, sign) => { + const h = b64u(JSON.stringify({ alg, typ: 'JWT' })); + const p = b64u(JSON.stringify({ sub: '42' })); + return `${h}.${p}.${b64u(sign(`${h}.${p}`))}`; +}; + +test('verify RS256 / PS256 / ES256 / EdDSA with a PEM or JWK public key', async () => { + const { hasher } = ctx; + const rsa = nodeCrypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + const rsaPem = rsa.publicKey.export({ type: 'spki', format: 'pem' }); + const rs256 = makeToken('RS256', (data) => nodeCrypto.sign('sha256', Buffer.from(data), rsa.privateKey)); + hasher.options.other = rsaPem; + assert.deepEqual(await run('13signature', rs256, ''), { value: 'RS256: valid, signed by this key', hint: '', tone: 'ok' }); + hasher.options.other = JSON.stringify(rsa.publicKey.export({ format: 'jwk' })); + assert.equal((await run('13signature', rs256, '')).value, 'RS256: valid, signed by this key', 'JWK too'); + hasher.options.other = JSON.stringify({ keys: [rsa.publicKey.export({ format: 'jwk' })] }); + assert.equal((await run('13signature', rs256, '')).value, 'RS256: valid, signed by this key', 'a JWKS too'); + + const ps256 = makeToken('PS256', (data) => nodeCrypto.sign('sha256', Buffer.from(data), { key: rsa.privateKey, padding: nodeCrypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: 32 })); + hasher.options.other = rsaPem; + assert.equal((await run('13signature', ps256, '')).value, 'PS256: valid, signed by this key'); + + const ec = nodeCrypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }); + const es256 = makeToken('ES256', (data) => nodeCrypto.sign('sha256', Buffer.from(data), { key: ec.privateKey, dsaEncoding: 'ieee-p1363' })); + hasher.options.other = ec.publicKey.export({ type: 'spki', format: 'pem' }); + assert.equal((await run('13signature', es256, '')).value, 'ES256: valid, signed by this key'); + + const ed = nodeCrypto.generateKeyPairSync('ed25519'); + const eddsa = makeToken('EdDSA', (data) => nodeCrypto.sign(null, Buffer.from(data), ed.privateKey)); + hasher.options.other = ed.publicKey.export({ type: 'spki', format: 'pem' }); + assert.equal((await run('13signature', eddsa, '')).value, 'EdDSA: valid, signed by this key'); + + // the wrong key, a tampered token, a key that is not a key + const other = nodeCrypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + hasher.options.other = other.publicKey.export({ type: 'spki', format: 'pem' }); + assert.deepEqual(await run('13signature', rs256, ''), { value: 'RS256: INVALID for this key', hint: '', tone: 'bad' }); + hasher.options.other = rsaPem; + assert.equal((await run('13signature', rs256.slice(0, -4) + 'AAAA', '')).value, 'RS256: INVALID for this key'); + hasher.options.other = 'not a key'; + assert.deepEqual(await run('13signature', rs256, ''), { value: 'RS256: expected a PEM public key or a JWK', hint: '', tone: 'bad' }); + hasher.options.other = '{"kty":"oct"}'; + assert.equal((await run('13signature', rs256, '')).tone, 'bad'); + hasher.options.other = ''; +}); diff --git a/test/load.js b/test/load.js index ec1ed5f..3a1255a 100644 --- a/test/load.js +++ b/test/load.js @@ -16,7 +16,7 @@ function popupScripts() { } function load() { - const ctx = vm.createContext({ console, crypto: require('node:crypto').webcrypto, TextEncoder, TextDecoder, btoa, atob }); + const ctx = vm.createContext({ console, crypto: require('node:crypto').webcrypto, TextEncoder, TextDecoder, btoa, atob, URL, URLSearchParams }); ctx.window = ctx; for (const src of popupScripts()) { vm.runInContext(fs.readFileSync(path.join(root, src), 'utf8'), ctx, { filename: src }); @@ -26,13 +26,34 @@ function load() { const ctx = load(); +function element(id) { + const el = ctx.hasher.findById(id); + if (!el) { + throw new Error(`no element with id ${id}`); + } + return el; +} + +// calculate() may return a string, { value, hint, tone }, or a promise of either +function unwrap(r) { + if (r !== null && typeof r === 'object' && 'value' in r) { + return { value: String(r.value == null ? '' : r.value), hint: r.hint || '', tone: r.tone || '' }; + } + return { value: String(r), hint: '', tone: '' }; +} + // Runs one output element the way hasher.update() does and returns the displayed string function calc(id, input, password = '') { - const element = ctx.hasher.findById(id); - if (!element) { - throw new Error(`no element with id ${id}`); + const r = element(id).calculate(input, password); + if (r && typeof r.then === 'function') { + throw new Error(`${id} is async: use run()`); } - return String(element.calculate(input, password)); + return unwrap(r).value; +} + +// Same, for any element: resolves to { value, hint, tone } +async function run(id, input, password = '') { + return unwrap(await element(id).calculate(input, password)); } -module.exports = { ctx, calc, root, popupScripts }; +module.exports = { ctx, calc, run, root, popupScripts }; diff --git a/test/url.test.js b/test/url.test.js new file mode 100644 index 0000000..c620ee4 --- /dev/null +++ b/test/url.test.js @@ -0,0 +1,73 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { ctx, calc } = require('./load'); + +const { hasher, urls } = ctx; +const full = 'https://user:pw@sub.example.com:8443/a/b%20c?x=1&y=%D0%BF%D1%80%D0%B8%D0%B2%D0%B5%D1%82&x=2#frag'; + +test('a full URL taken apart', () => { + assert.equal(calc('16scheme', full), 'https'); + assert.equal(hasher.elements.url1.hint(full), ''); + assert.equal(calc('16host', full), 'sub.example.com'); + assert.equal(calc('16port', full), '8443'); + assert.equal(calc('16path', full), '/a/b%20c'); + assert.equal(hasher.elements.url4.hint(full), 'decoded: /a/b c'); + assert.equal(calc('16query', full), 'x = 1\ny = привет\nx = 2'); + assert.equal(hasher.elements.url5.hint(full), '3 parameters, decoded'); + assert.equal(calc('16fragment', full), 'frag'); + assert.equal(calc('16credentials', full), 'user:pw'); + assert.equal(hasher.elements.url7.tone(full), 'bad', 'credentials in a URL are flagged'); + assert.equal(calc('16origin', full), 'https://sub.example.com:8443'); + assert.equal(calc('16normalized', full), full); +}); + +test('defaults and empties', () => { + const u = 'http://example.com'; + assert.equal(calc('16port', u), '80'); + assert.equal(hasher.elements.url3.hint(u), 'default for http'); + assert.equal(calc('16path', u), '/'); + assert.equal(calc('16query', u), ''); + assert.equal(hasher.elements.url5.hint(u), ''); + assert.equal(calc('16fragment', u), ''); + assert.equal(calc('16credentials', u), ''); + assert.equal(calc('16normalized', u), 'http://example.com/'); +}); + +test('a bare host is read as https', () => { + assert.equal(calc('16scheme', 'example.com/path?x=1'), 'https'); + assert.equal(hasher.elements.url1.hint('example.com/path?x=1'), 'no scheme given, https assumed'); + assert.equal(calc('16host', 'example.com/path?x=1'), 'example.com'); + assert.equal(calc('16host', 'localhost:3000/api'), 'localhost', 'host:port is not a scheme'); + assert.equal(calc('16port', 'localhost:3000/api'), '3000'); + assert.equal(calc('16host', '192.168.1.1:8080'), '192.168.1.1'); + assert.equal(calc('16host', 'https://[2001:db8::1]:8080/'), '[2001:db8::1]'); +}); + +test('IDN hosts', () => { + assert.equal(calc('16host', 'http://пример.рф/путь'), 'xn--e1afmkfd.xn--p1ai'); + assert.equal(hasher.elements.url2.hint('http://пример.рф/путь'), 'IDN: пример.рф'); + assert.equal(hasher.elements.url4.hint('http://пример.рф/путь'), 'decoded: /путь'); + assert.equal(hasher.elements.url2.hint('https://example.com'), ''); + assert.equal(urls.toUnicode('xn--mnchen-3ya.de'), 'münchen.de'); + assert.equal(urls.toUnicode('xn--fsqu00a.xn--0zwm56d'), '例子.测试'); +}); + +test('other schemes', () => { + assert.equal(calc('16scheme', 'mailto:a@b.c'), 'mailto'); + assert.equal(calc('16host', 'mailto:a@b.c'), ''); + assert.equal(calc('16port', 'mailto:a@b.c'), ''); + assert.equal(calc('16origin', 'mailto:a@b.c'), '', 'opaque origin is not shown'); + assert.equal(calc('16port', 'ftp://x.org'), '21'); +}); + +test('invalid', () => { + assert.equal(calc('16scheme', 'hello world'), 'Invalid: not a URL'); + assert.equal(calc('16host', 'hello world'), ''); + assert.equal(calc('16scheme', ''), ''); +}); + +test('row order', () => { + const titles = Object.values(hasher.elements).filter((e) => e.tab === ctx.tabs.url).map((e) => e.title); + assert.deepEqual(Array.from(titles), ['Scheme', 'Host', 'Port', 'Path', 'Query', 'Fragment', 'Credentials', 'Origin', 'Normalized']); +});