diff --git a/README.md b/README.md index a3c020e..eaea9a7 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,7 @@ Developer's toolbox: hashes, HMAC, CRC, ciphers, IP/subnet, time, number and string conversions. Everything runs locally in the popup — nothing is sent anywhere. -Standalone version for other browsers (the same popup, deployed from `master` by GitHub Actions): -https://s12v.github.io/hasher/ +Standalone version: https://s12v.github.io/hasher/ ## Privacy @@ -31,8 +30,9 @@ Chrome Web Store listing is being republished. Until then, load it unpacked: * HMAC: SHA-256, SHA-1, SHA-512, SHA-384, MD5 (base64 hints for SHA-256/512) * CRC: CRC-32, CRC-32C, CRC-16/MODBUS, CRC-16/CCITT-FALSE, CRC-16/XMODEM, CRC-8, CRC-64/XZ, Adler-32 — one parametrised implementation checked against the CRC catalogue * Password: passphrases from the EFF large wordlist (words, separator, digits, capitalize) and passwords (length, symbols) with the entropy estimate — a port of [ppgen](https://github.com/s12v/ppgen); random keys of N bytes as hex and Base64 (`openssl rand`) -* UUID: UUID v4, UUID v7, ULID +* UUID: generate v4, v7, ULID; inspect a pasted UUID / ULID (version, variant, embedded time); UUID v5 from a name (DNS and URL namespaces) * Cipher: AES-256-CBC with a passphrase, byte-compatible with `openssl enc -aes-256-cbc -pbkdf2 -a` (PBKDF2-SHA256, 10000 iterations) and with the legacy `-md md5` format; decrypt detects which +* URL: scheme, host (IDN shown in Unicode), port, path, decoded query parameters, fragment, credentials, origin, normalized form * IP: IPv4 and IPv6 — canonical form (RFC 5952), decimal, hex, binary, PTR, address type (private, link-local, ULA, documentation…); for a prefix: network, netmask / wildcard, first / last host, broadcast, hosts * Time: Unix time (s or ms), ISO 8601, RFC-1123 or `2019-02-27 09:36:55` in → Unix s / ms, ISO 8601, RFC-1123, DATETIME UTC / local out * Num: any base in (decimal, `0x`, `0b`, `0o`, Roman, floating point), the others out — decimal, hex, octal, binary, Roman, bits, size in KiB / kB, IEEE-754 double / single; arbitrary size (BigInt) @@ -40,7 +40,7 @@ Chrome Web Store listing is being republished. Until then, load it unpacked: * JSON: pretty-print and minify (optionally with sorted keys), quote as a JSON string (a pasted string literal is unquoted on the spot), syntax errors with position * Diff: compare two texts side by side, line by line, with changed characters highlighted; ignore whitespace / case * Encode: Base64 / Base64url, Base32, Base58 — encode and decode (binary output shown as hex), encodeURI(), encodeURIComponent(), HTML special chars, ROT13 -* JWT: decode, claims with expiry as local time, verify HS256/384/512 with a secret +* JWT: decode, claims with expiry as local time; verify HS* with a secret, RS* / PS* / ES* / EdDSA with a PEM or JWK public key (WebCrypto); sign a JSON payload with HS256 * Cron: schedule builder (every N minutes, hourly, daily, weekly with weekdays, monthly, yearly) ↔ crontab expression, plain English and the next 5 run times Usage: enter text and click on the result — it is copied to clipboard. @@ -64,8 +64,8 @@ directory over HTTP (`python3 -m http.server`) — `file://` blocks the wordlist ## Release 1. Set the same new version in `manifest.json` and `package.json`, commit. -2. Tag and push: `git tag v1.6.0 && git push origin master v1.6.0`. -3. The Release workflow runs the tests, builds `hasher-1.6.0.zip` (`npm run build` does the same locally into `dist/`) +2. Tag and push: `git tag v2.2.0 && git push origin master v2.2.0`. +3. The Release workflow runs the tests, builds `hasher-2.2.0.zip` (`npm run build` does the same locally into `dist/`) and attaches it to a GitHub Release. 4. Upload the zip in the [Chrome Web Store developer dashboard](https://chrome.google.com/webstore/devconsole). diff --git a/hasher.js b/hasher.js index fd752db..982bc3e 100644 --- a/hasher.js +++ b/hasher.js @@ -13,7 +13,8 @@ var tabs = { json : 12, jwt : 13, uuid : 14, - diff : 15 + diff : 15, + url : 16 }; /* @@ -96,6 +97,17 @@ function parseNumber(input) { } } +/* + * URL tab: { url, assumed } for the input, { error } when it is not a URL + */ +function parseUrl(input) { + try { + return urls.parse(input); + } catch (err) { + return { error : err.message }; + } +} + /* * IP tab: { version, address, prefix } for the input, { error } when it is not an address */ @@ -218,6 +230,20 @@ function jsonInput(input) { return { value : parsed.value, plain : true }; } +/* + * JWT tab: the input as a JSON object to sign, or null when it is a token / anything else + */ +function jwtPayload(input) { + var str = input.trim(); + if (str.charAt(0) != "{") return null; + try { + var value = JSON.parse(str); + return (value !== null && typeof value == "object" && !Array.isArray(value)) ? value : null; + } catch (err) { + return null; + } +} + /* * Same object with keys sorted recursively (arrays keep their order) */ @@ -244,7 +270,8 @@ var hasher = { password : { length : 16, symbols : false }, key : { bytes : 32 }, json : { sorted : false }, - diff : { other : "", ignoreWhitespace : false, ignoreCase : false } + other : "", + diff : { ignoreWhitespace : false, ignoreCase : false } }, elements: { // Hash, most used first @@ -1267,6 +1294,55 @@ var hasher = { return basex.base64(ids.randomBytes(passgen.clamp(hasher.options.key.bytes, 1, 1024, 32))); } }, + // UUID: inspect a pasted UUID / ULID, derive v5 from a name, or generate + u1: { + id : tabs.uuid+"inspect", + tab : tabs.uuid, + title : "Inspect", + calculate : function (input) { + var u = ids.parseUuid(input); + if (u) { + if (u.nil) return "nil UUID (all zeros)"; + if (u.max) return "max UUID (all ones)"; + if (u.version === null) return u.variant + " variant, not an RFC 4122 UUID"; + var names = { 1 : "time-based, MAC address", 2 : "DCE security", 3 : "name-based, MD5", 4 : "random", 5 : "name-based, SHA-1", 6 : "time-ordered (reordered v1)", 7 : "time-ordered, Unix ms", 8 : "custom" }; + return "UUID v" + u.version + (names[u.version] ? " \u2014 " + names[u.version] : "") + " \u00b7 " + u.variant + " variant"; + } + if (ids.parseUlid(input)) return "ULID \u2014 48-bit time + 80 random bits"; + return ""; + }, + hint : function (input) { + var u = ids.parseUuid(input); + var l = ids.parseUlid(input); + var time = u && u.time !== null ? u.time : l ? l.time : null; + if (time === null) return ""; + var d = new Date(time); + return "created " + d.toISOString() + " \u00b7 " + jwt.relative(Math.round((time - Date.now()) / 1000)); + } + }, + u2: { + id : tabs.uuid+"v5dns", + tab : tabs.uuid, + title : "UUID v5, DNS namespace", + calculate : function (input) { + var name = input.trim(); + if (!name.length || ids.parseUuid(name) || ids.parseUlid(name)) return ""; + return ids.uuid5(ids.NAMESPACES.DNS, name); + }, + hint : function (input) { + return this.calculate(input).length ? "deterministic: the same name always gives this UUID" : ""; + } + }, + u3: { + id : tabs.uuid+"v5url", + tab : tabs.uuid, + title : "UUID v5, URL namespace", + calculate : function (input) { + var name = input.trim(); + if (!name.length || ids.parseUuid(name) || ids.parseUlid(name)) return ""; + return ids.uuid5(ids.NAMESPACES.URL, name); + } + }, p3: { id : tabs.uuid+"uuid4", tab : tabs.uuid, @@ -1396,13 +1472,14 @@ var hasher = { }, - // JWT (the Password field is the HMAC secret) + // JWT: a token to decode (secret in the Password field, public key in the second text area), + // or a JSON payload to sign w1: { id : tabs.jwt+"header", tab : tabs.jwt, title : "Header", calculate : function (input) { - if (input.trim().length == 0) return ""; + if (input.trim().length == 0 || jwtPayload(input)) return ""; try { return JSON.stringify(jwt.parse(input).header, null, 2); } catch (err) { @@ -1463,24 +1540,166 @@ var hasher = { } catch (err) { return ""; } - var alg = parsed.header.alg || "?"; - if (password.length == 0) { - return alg + (jwt.HMAC[alg] ? ": enter the secret above to verify" : ": cannot verify here (only HS256/384/512)"); + var alg = String(parsed.header.alg || "?"); + if (jwt.HMAC[alg]) { + if (password.length == 0) { + return { value : alg + ": enter the secret above to verify", hint : "", tone : "" }; + } + var ok = jwt.verify(parsed, password); + return { value : alg + (ok ? ": valid, signed with this secret" : ": INVALID for this secret"), tone : ok ? "ok" : "bad" }; } - var ok = jwt.verify(parsed, password); - if (ok === null) { - return alg + ": cannot verify here (only HS256/384/512)"; + if (!jwt.ASYMMETRIC[alg]) { + return alg + ": cannot verify here"; } - return alg + (ok ? ": valid, signed with this secret" : ": INVALID for this secret"); + if (hasher.options.other.trim().length == 0) { + return alg + ": paste the public key (PEM or JWK) below to verify"; + } + return jwt.verifyAsymmetric(parsed, hasher.options.other).then(function (ok) { + return { value : alg + (ok ? ": valid, signed by this key" : ": INVALID for this key"), tone : ok ? "ok" : "bad" }; + }, function (err) { + return { value : alg + ": " + (err && err.message ? err.message : "the key could not be used"), tone : "bad" }; + }); + } + }, + w5: { + id : tabs.jwt+"sign", + tab : tabs.jwt, + title : "Signed token, HS256", + calculate : function (input, password) { + var payload = jwtPayload(input); + if (!payload) return ""; + if (password.length == 0) return ""; + return jwt.signHS256(payload, password); }, - tone : function (input, password) { - var value = this.calculate(input, password); - return /: valid/.test(value) ? "ok" : /INVALID/.test(value) ? "bad" : ""; + hint : function (input, password) { + if (!jwtPayload(input)) return ""; + return password.length ? "header {\"alg\":\"HS256\",\"typ\":\"JWT\"} \u00b7 secret from the field above" : "enter a secret above to sign this payload"; } }, + // URL + url1: { + id : tabs.url+"scheme", + tab : tabs.url, + title : "Scheme", + calculate : function (input) { + if (input.trim().length == 0) return ""; + var p = parseUrl(input); + return p.error ? "Invalid: " + p.error : p.url.protocol.replace(/:$/, ""); + }, + hint : function (input) { + var p = parseUrl(input); + return (!p.error && p.assumed) ? "no scheme given, https assumed" : ""; + } + }, + url2: { + id : tabs.url+"host", + tab : tabs.url, + title : "Host", + calculate : function (input) { + var p = parseUrl(input); + return p.error ? "" : p.url.hostname; + }, + hint : function (input) { + var p = parseUrl(input); + if (p.error || !p.url.hostname) return ""; + var unicode = urls.toUnicode(p.url.hostname); + return unicode != p.url.hostname ? "IDN: " + unicode : ""; + } + }, + url3: { + id : tabs.url+"port", + tab : tabs.url, + title : "Port", + calculate : function (input) { + var p = parseUrl(input); + if (p.error || !p.url.hostname) return ""; + return p.url.port || urls.DEFAULT_PORTS[p.url.protocol] || ""; + }, + hint : function (input) { + var p = parseUrl(input); + return (!p.error && !p.url.port && urls.DEFAULT_PORTS[p.url.protocol]) ? "default for " + p.url.protocol.replace(/:$/, "") : ""; + } + }, + url4: { + id : tabs.url+"path", + tab : tabs.url, + title : "Path", + calculate : function (input) { + var p = parseUrl(input); + return p.error ? "" : p.url.pathname; + }, + hint : function (input) { + var p = parseUrl(input); + if (p.error) return ""; + try { + var decoded = decodeURIComponent(p.url.pathname); + return decoded != p.url.pathname ? "decoded: " + decoded : ""; + } catch (err) { + return ""; + } + } + }, + url5: { + id : tabs.url+"query", + tab : tabs.url, + title : "Query", + calculate : function (input) { + var p = parseUrl(input); + return p.error ? "" : urls.query(p.url); + }, + hint : function (input) { + var p = parseUrl(input); + if (p.error) return ""; + var n = Array.from(p.url.searchParams.keys()).length; + return n ? n + " parameter" + (n == 1 ? "" : "s") + ", decoded" : ""; + } + }, + url6: { + id : tabs.url+"fragment", + tab : tabs.url, + title : "Fragment", + calculate : function (input) { + var p = parseUrl(input); + return p.error ? "" : p.url.hash.replace(/^#/, ""); + } + }, + url7: { + id : tabs.url+"credentials", + tab : tabs.url, + title : "Credentials", + calculate : function (input) { + var p = parseUrl(input); + if (p.error || !(p.url.username || p.url.password)) return ""; + return p.url.username + (p.url.password ? ":" + p.url.password : ""); + }, + tone : function () { + return "bad"; + } + }, + url8: { + id : tabs.url+"origin", + tab : tabs.url, + title : "Origin", + calculate : function (input) { + var p = parseUrl(input); + return (p.error || p.url.origin == "null") ? "" : p.url.origin; + } + }, + url9: { + id : tabs.url+"normalized", + tab : tabs.url, + title : "Normalized", + calculate : function (input) { + var p = parseUrl(input); + return p.error ? "" : p.url.href; + }, + hint : function () { + return "as the browser would send it"; + } + }, - // Diff (input vs hasher.options.diff.other) + // Diff (input vs hasher.options.other) d1: { id : tabs.diff+"changes", tab : tabs.diff, @@ -1489,7 +1708,7 @@ var hasher = { tall : true, nocopy : true, calculate : function (input) { - var ops = diff.lines(input, hasher.options.diff.other, hasher.options.diff); + var ops = diff.lines(input, hasher.options.other, hasher.options.diff); var s = diff.stats(ops); if (s.added == 0 && s.removed == 0) { return ""; @@ -1497,15 +1716,15 @@ var hasher = { return diff.html(ops); }, hint : function (input) { - var s = diff.stats(diff.lines(input, hasher.options.diff.other, hasher.options.diff)); + var s = diff.stats(diff.lines(input, hasher.options.other, hasher.options.diff)); if (s.added == 0 && s.removed == 0) { - return (input.length || hasher.options.diff.other.length) ? "identical" : ""; + return (input.length || hasher.options.other.length) ? "identical" : ""; } return "+" + s.added + " \u2212" + s.removed + " lines"; }, tone : function (input) { - var s = diff.stats(diff.lines(input, hasher.options.diff.other, hasher.options.diff)); - return (s.added == 0 && s.removed == 0 && (input.length || hasher.options.diff.other.length)) ? "ok" : ""; + var s = diff.stats(diff.lines(input, hasher.options.other, hasher.options.diff)); + return (s.added == 0 && s.removed == 0 && (input.length || hasher.options.other.length)) ? "ok" : ""; } } }, @@ -1530,8 +1749,11 @@ var hasher = { cron : "Enter a crontab expression.", json : "Paste JSON to format it.", jwt : "Paste a token to decode it.", + url : "Enter a URL to take it apart.", diff : "Enter two texts to compare." }, + /* tabs whose rows do not need input at all */ + GENERATORS : { password : true, uuid : true }, /* * JSON value as displayed: keys sorted when the option is on */ @@ -1585,6 +1807,7 @@ var hasher = { * Recalculate. Rows without a value or a hint are hidden; an empty tab shows a prompt. */ update : function () { + var self = this; var input = this.inputField().value; var password = document.getElementById("input-password").value; var tabName = null; @@ -1594,36 +1817,68 @@ var hasher = { } } // tabs that need input show only the prompt until there is some - var waiting = input.length == 0 && this.EMPTY[tabName] != undefined; + var waiting = input.length == 0 && this.EMPTY[tabName] != undefined && !this.GENERATORS[tabName]; if (tabName == "diff") { - waiting = input.length == 0 && this.options.diff.other.length == 0; + waiting = input.length == 0 && this.options.other.length == 0; } - var visible = 0; + // a later update() supersedes any async result still in flight + var seq = this.updateSeq = (this.updateSeq || 0) + 1; for (var i in this.elements) { var element = this.elements[i]; if (element.tab != this.tab) { continue; } - var value = waiting ? "" : String(element.calculate(input, password)); - var hint = (element.hint != undefined && !waiting) ? String(element.hint(input, password)) : ""; - var tone = (element.tone != undefined && !waiting) ? element.tone(input, password) : ""; - if (!tone && /^Invalid/.test(value)) { - tone = "bad"; - } - if (element.html) { - document.getElementById(element.id).innerHTML = value; - } else { - document.getElementById(element.id).textContent = value; - } - document.getElementById(element.id + "-hint").textContent = hint; - document.getElementById(element.id + "-hint").setAttribute("data-tone", tone); - document.getElementById(element.id + "-value").setAttribute("data-tone", tone); - var row = document.getElementById(element.id + "-element"); - row.hidden = value.length == 0 && hint.length == 0; - if (!row.hidden) { - visible++; + if (waiting) { + this.show(element, "", "", ""); + continue; + } + var result = element.calculate(input, password); + if (result && typeof result.then == "function") { + // async element: resolves to a string or { value, hint, tone } + this.show(element, "\u2026", "", ""); + (function (el) { + result.then(function (r) { + if (seq != self.updateSeq) return; + var value = (r !== null && typeof r == "object" && "value" in r) ? r.value : r; + self.show(el, String(value == null ? "" : value), (r && r.hint) || "", (r && r.tone) || ""); + self.updateEmpty(tabName); + }, function (err) { + if (seq != self.updateSeq) return; + self.show(el, "Invalid: " + (err && err.message ? err.message : err), "", "bad"); + self.updateEmpty(tabName); + }); + })(element); + continue; + } + if (result !== null && typeof result == "object" && "value" in result) { + this.show(element, String(result.value == null ? "" : result.value), result.hint || "", result.tone || ""); + continue; } + var hint = element.hint != undefined ? String(element.hint(input, password)) : ""; + var tone = element.tone != undefined ? element.tone(input, password) : ""; + this.show(element, String(result), hint, tone); } + this.updateEmpty(tabName); + }, + /* + * Put a value (and hint, tone) into an element's row; empty rows are hidden + */ + show : function (element, value, hint, tone) { + if (!tone && /^Invalid/.test(value)) { + tone = "bad"; + } + if (element.html) { + document.getElementById(element.id).innerHTML = value; + } else { + document.getElementById(element.id).textContent = value; + } + document.getElementById(element.id + "-hint").textContent = hint; + document.getElementById(element.id + "-hint").setAttribute("data-tone", tone); + document.getElementById(element.id + "-value").setAttribute("data-tone", tone); + document.getElementById(element.id + "-element").hidden = value.length == 0 && hint.length == 0; + }, + updateEmpty : function (tabName) { + var visible = document.querySelectorAll("#output .element:not([hidden])").length; var empty = document.getElementById("empty"); empty.textContent = this.EMPTY[tabName] || "Nothing to show."; empty.hidden = visible > 0; diff --git a/lib/snov/ids.js b/lib/snov/ids.js index d719845..fce2c92 100644 --- a/lib/snov/ids.js +++ b/lib/snov/ids.js @@ -66,6 +66,69 @@ var ids = { return time + rand; }, + /* ---- inspecting ---- */ + + UUID_RE : /^\{?([0-9a-f]{8})-?([0-9a-f]{4})-?([0-9a-f]{4})-?([0-9a-f]{4})-?([0-9a-f]{12})\}?$/i, + GREGORIAN_OFFSET : 122192928000000000n, // 100-ns intervals from 1582-10-15 to 1970-01-01 + + /** + * { version, variant, time (ms) | null, nil, max } for a UUID in any usual spelling; null otherwise + */ + parseUuid : function (str) { + var m = this.UUID_RE.exec(str.trim().replace(/^urn:uuid:/i, "")); + if (!m) { + return null; + } + var hex = (m[1] + m[2] + m[3] + m[4] + m[5]).toLowerCase(); + var out = { hex : hex, version : parseInt(hex.charAt(12), 16), time : null, nil : /^0+$/.test(hex), max : /^f+$/.test(hex) }; + var v = parseInt(hex.charAt(16), 16); + out.variant = (v & 8) == 0 ? "NCS (reserved)" : (v & 12) == 8 ? "RFC 4122" : (v & 14) == 12 ? "Microsoft" : "reserved"; + if (out.nil || out.max || out.variant != "RFC 4122") { + out.version = null; + return out; + } + if (out.version == 1) { + var ts = BigInt("0x" + hex.slice(13, 16) + hex.slice(8, 12) + hex.slice(0, 8)); + out.time = Number((ts - this.GREGORIAN_OFFSET) / 10000n); + } else if (out.version == 6) { + var ts6 = BigInt("0x" + hex.slice(0, 12) + hex.slice(13, 16)); + out.time = Number((ts6 - this.GREGORIAN_OFFSET) / 10000n); + } else if (out.version == 7) { + out.time = parseInt(hex.slice(0, 12), 16); + } + return out; + }, + + /* { time } for a ULID; null otherwise */ + parseUlid : function (str) { + var s = str.trim().toUpperCase(); + if (!/^[0-9A-HJKMNP-TV-Z]{26}$/.test(s) || s.charAt(0) > "7") { + return null; + } + return { time : this.ulidTime(s) }; + }, + + /* ---- name-based ---- */ + + NAMESPACES : { + DNS : "6ba7b810-9dad-11d1-80b4-00c04fd430c8", + URL : "6ba7b811-9dad-11d1-80b4-00c04fd430c8", + OID : "6ba7b812-9dad-11d1-80b4-00c04fd430c8", + X500 : "6ba7b814-9dad-11d1-80b4-00c04fd430c8" + }, + + /* UUID v5: SHA-1 of namespace bytes + name, version and variant bits set */ + uuid5 : function (namespace, name) { + var ns = CryptoJS.enc.Hex.parse(namespace.replace(/-/g, "")); + var digest = CryptoJS.SHA1(ns.concat(CryptoJS.enc.Utf8.parse(name))); + var hex = CryptoJS.enc.Hex.stringify(digest).slice(0, 32); + var b = new Uint8Array(16); + for (var i = 0; i < 16; i++) b[i] = parseInt(hex.substr(i * 2, 2), 16); + b[6] = (b[6] & 0x0f) | 0x50; + b[8] = (b[8] & 0x3f) | 0x80; + return this.formatUuid(b); + }, + /* Unix milliseconds embedded in a ULID */ ulidTime : function (ulid) { var ms = 0; diff --git a/lib/snov/jwt.js b/lib/snov/jwt.js index caa84d6..7fab8a3 100644 --- a/lib/snov/jwt.js +++ b/lib/snov/jwt.js @@ -53,6 +53,88 @@ var jwt = { HMAC : { HS256 : "HmacSHA256", HS384 : "HmacSHA384", HS512 : "HmacSHA512" }, + /* WebCrypto parameters for the asymmetric JWS algorithms */ + ASYMMETRIC : { + RS256 : { name : "RSASSA-PKCS1-v1_5", hash : "SHA-256" }, + RS384 : { name : "RSASSA-PKCS1-v1_5", hash : "SHA-384" }, + RS512 : { name : "RSASSA-PKCS1-v1_5", hash : "SHA-512" }, + PS256 : { name : "RSA-PSS", hash : "SHA-256", saltLength : 32 }, + PS384 : { name : "RSA-PSS", hash : "SHA-384", saltLength : 48 }, + PS512 : { name : "RSA-PSS", hash : "SHA-512", saltLength : 64 }, + ES256 : { name : "ECDSA", hash : "SHA-256", namedCurve : "P-256" }, + ES384 : { name : "ECDSA", hash : "SHA-384", namedCurve : "P-384" }, + ES512 : { name : "ECDSA", hash : "SHA-512", namedCurve : "P-521" }, + EdDSA : { name : "Ed25519" } + }, + + /** + * Sign a payload object with HS256: header.payload.signature + */ + signHS256 : function (payload, secret) { + var header = this.encodeSegment(CryptoJS.enc.Utf8.parse(JSON.stringify({ alg : "HS256", typ : "JWT" }))); + var body = this.encodeSegment(CryptoJS.enc.Utf8.parse(JSON.stringify(payload))); + var signingInput = header + "." + body; + return signingInput + "." + this.encodeSegment(CryptoJS.HmacSHA256(signingInput, secret)); + }, + + /* base64url -> Uint8Array */ + segmentBytes : function (segment) { + var b64 = segment.replace(/-/g, "+").replace(/_/g, "/"); + while (b64.length % 4 != 0) b64 += "="; + var bin = atob(b64); + var out = new Uint8Array(bin.length); + for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; + }, + + /** + * A public key from PEM ("-----BEGIN PUBLIC KEY-----", SPKI) or a JWK (JSON with kty), + * imported for the token's algorithm. Returns a Promise of a CryptoKey; rejects with a reason. + */ + importPublicKey : function (text, alg) { + var params = this.ASYMMETRIC[alg]; + if (!params) { + return Promise.reject(new Error(alg + " is not verifiable here")); + } + var trimmed = text.trim(); + var usage = ["verify"]; + if (trimmed.charAt(0) == "{") { + var jwk; + try { + jwk = JSON.parse(trimmed); + } catch (err) { + return Promise.reject(new Error("the key is not valid JSON")); + } + if (jwk.keys && jwk.keys.length) jwk = jwk.keys[0]; // a JWKS: take the first key + return crypto.subtle.importKey("jwk", jwk, params, false, usage); + } + var m = /-----BEGIN (?:RSA )?PUBLIC KEY-----([\s\S]*?)-----END/.exec(trimmed); + if (!m) { + return Promise.reject(new Error("expected a PEM public key or a JWK")); + } + var der; + try { + der = this.segmentBytes(m[1].replace(/\s+/g, "")); + } catch (err) { + return Promise.reject(new Error("the PEM body is not base64")); + } + return crypto.subtle.importKey("spki", der, params, false, usage); + }, + + /** + * Verify an RS* / PS* / ES* / EdDSA token with a public key; Promise of true/false + */ + verifyAsymmetric : function (parsed, keyText) { + var self = this; + var alg = parsed.header.alg; + var params = this.ASYMMETRIC[alg]; + return this.importPublicKey(keyText, alg).then(function (key) { + var verifyParams = params.name == "ECDSA" ? { name : "ECDSA", hash : params.hash } : + params.name == "RSA-PSS" ? { name : "RSA-PSS", saltLength : params.saltLength } : { name : params.name }; + return crypto.subtle.verify(verifyParams, key, self.segmentBytes(parsed.signature), new TextEncoder().encode(parsed.signingInput)); + }); + }, + /** * true / false for HS* algorithms, null when the algorithm cannot be checked here */ diff --git a/lib/snov/urls.js b/lib/snov/urls.js new file mode 100644 index 0000000..ae19345 --- /dev/null +++ b/lib/snov/urls.js @@ -0,0 +1,97 @@ +/** + * URL parsing helpers on top of the URL API, plus punycode decoding for IDN hosts. + * + * @author Sergey Novikov + */ +var urls = { + DEFAULT_PORTS : { "http:" : "80", "https:" : "443", "ftp:" : "21", "ws:" : "80", "wss:" : "443" }, + + /** + * { url: URL, assumed: bool } — "example.com/x" is read as https://example.com/x (assumed); + * throws when it is not a URL at all + */ + parse : function (input) { + var str = input.trim(); + if (str.length == 0) { + throw new Error("empty"); + } + var assumed = false; + // no scheme, or a "host:port" that would otherwise read as a scheme ("localhost:3000") + if (!/^[a-z][a-z0-9+.-]*:/i.test(str) || /^[a-z][a-z0-9+.-]*:\d/i.test(str)) { + str = "https://" + str; + assumed = true; + } + var url; + try { + url = new URL(str); + } catch (err) { + throw new Error("not a URL"); + } + if (assumed && !/^[^\/]+\.[^\/]+|^localhost|^\d+\.\d+\.\d+\.\d+|^\[/.test(url.host)) { + throw new Error("not a URL"); + } + return { url : url, assumed : assumed }; + }, + + /* one decoded "key = value" per line */ + query : function (url) { + var out = []; + url.searchParams.forEach(function (value, key) { + out.push(key + " = " + value); + }); + return out.join("\n"); + }, + + /* xn--e1afmkfd.xn--p1ai -> пример.рф; other labels pass through */ + toUnicode : function (host) { + var self = this; + return host.split(".").map(function (label) { + return /^xn--/i.test(label) ? self.punycodeDecode(label.slice(4)) : label; + }).join("."); + }, + + /* RFC 3492 decoder for one label (without the xn-- prefix) */ + punycodeDecode : function (input) { + var base = 36, tMin = 1, tMax = 26, skew = 38, damp = 700, initialBias = 72, initialN = 128; + var output = []; + var basic = input.lastIndexOf("-"); + for (var j = 0; j < Math.max(basic, 0); j++) { + output.push(input.charCodeAt(j)); + } + var n = initialN, bias = initialBias, i = 0; + var digit = function (c) { + if (c >= 48 && c <= 57) return c - 22; + if (c >= 65 && c <= 90) return c - 65; + if (c >= 97 && c <= 122) return c - 97; + return base; + }; + var adapt = function (delta, numPoints, firstTime) { + delta = firstTime ? Math.floor(delta / damp) : delta >> 1; + delta += Math.floor(delta / numPoints); + var k = 0; + while (delta > ((base - tMin) * tMax) >> 1) { + delta = Math.floor(delta / (base - tMin)); + k += base; + } + return k + Math.floor((base - tMin + 1) * delta / (delta + skew)); + }; + for (var index = basic > 0 ? basic + 1 : 0; index < input.length;) { + var oldi = i, w = 1; + for (var k = base; ; k += base) { + if (index >= input.length) throw new Error("bad punycode"); + var d = digit(input.charCodeAt(index++)); + if (d >= base) throw new Error("bad punycode"); + i += d * w; + var t = k <= bias ? tMin : k >= bias + tMax ? tMax : k - bias; + if (d < t) break; + w *= base - t; + } + var out = output.length + 1; + bias = adapt(i - oldi, out, oldi == 0); + n += Math.floor(i / out); + i %= out; + output.splice(i++, 0, n); + } + return String.fromCodePoint.apply(null, output); + } +}; diff --git a/main.css b/main.css index 57a2b3e..2740e8b 100644 --- a/main.css +++ b/main.css @@ -93,7 +93,7 @@ body { margin: 0; - min-width: 760px; + min-width: 780px; overflow-x: hidden; font: 13px/1.4 var(--font); background: var(--bg); @@ -115,7 +115,7 @@ html.page body { padding: 24px 16px; } html.page #card { - width: 760px; + width: 780px; max-width: 100%; margin: 0 auto; background: var(--bg); @@ -176,7 +176,7 @@ html.page #header { } #tabs li span { display: block; - padding: 4px 4px; + padding: 4px 3px; border-radius: 6px; } #tabs li:hover { diff --git a/popup.html b/popup.html index b393cbc..ab07843 100644 --- a/popup.html +++ b/popup.html @@ -13,6 +13,7 @@ + @@ -26,7 +27,7 @@
@@ -170,8 +171,9 @@

Hasher

  • HMAC SHA-256, SHA-1, SHA-512, SHA-384, MD5 — webhook signatures, TOTP, JWT HS*
  • CRC CRC-32, CRC-32C, CRC-16 (MODBUS, CCITT-FALSE, XMODEM), CRC-8, CRC-64/XZ, Adler-32
  • Password passphrases from the EFF wordlist (7776 words, ~12.9 bits each), random passwords and random keys (hex / Base64, like openssl rand), all from the browser's CSPRNG
  • -
  • UUID UUID v4, UUID v7 and ULID
  • +
  • UUID generate v4, v7 and ULID; paste one to see its version, variant and embedded time; a name gives its UUID v5 (DNS and URL namespaces)
  • Cipher AES-256-CBC with a passphrase, byte-compatible with openssl enc -pbkdf2 (and the legacy -md md5 format); decrypt reads both
  • +
  • URL scheme, host (with the Unicode form of an IDN), port, path, decoded query parameters, fragment, credentials, origin, normalized form; a bare host is read as https
  • IP IPv4 and IPv6: canonical form, decimal, hex, binary, PTR name, address type (private, link-local, ULA, documentation…); for a prefix — network, netmask and wildcard, first / last host, broadcast, host count
  • Time Unix time (seconds or milliseconds), ISO 8601, RFC-1123 or "2019-02-27 09:36:55" in → all of them out
  • Num a number in any base in (decimal, 0x hex, 0b binary, 0o octal, Roman, floating point), the others out: decimal, hex, octal, binary, Roman, bit count, size in KiB / kB, IEEE-754 double and single
  • @@ -180,7 +182,7 @@

    Hasher

  • Diff compare two texts side by side, line by line, with the changed characters highlighted; ignore whitespace / case
  • Encode Base64 / Base64url, Base32, Base58 (encode and decode, binary shown as hex), encodeURI, encodeURIComponent, HTML special characters, ROT13
  • Cron schedule builder (every N minutes, hourly, daily, weekly with weekdays, monthly, yearly) ↔ crontab expression, plain-English description and the next 5 runs; lists, ranges, steps, names, @daily and friends
  • -
  • JWT decode, claims with expiry as local time, HS256 / HS384 / HS512 verification with the secret
  • +
  • JWT decode, claims with expiry as local time; verify HS* with the secret, RS* / PS* / ES* / EdDSA with a PEM or JWK public key; sign a JSON payload with HS256
  • diff --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']); +});