From 52b04610fa43e30b44f17d140619201f90c0e1f0 Mon Sep 17 00:00:00 2001 From: Sergey Novikov Date: Sun, 13 Sep 2026 21:49:24 +0200 Subject: [PATCH 1/4] README: one-line standalone link --- README.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a3c020e..242c9a8 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 @@ -64,8 +63,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). From b4be66cd3e09455a6aceea17089d728bb62d37ce Mon Sep 17 00:00:00 2001 From: Sergey Novikov Date: Sun, 13 Sep 2026 21:52:58 +0200 Subject: [PATCH 2/4] JWT: verify RS/PS/ES/EdDSA with a public key, sign HS256 payloads - update() accepts async elements: calculate() may return a promise of a string or of { value, hint, tone }; a sequence number drops results from superseded updates - jwt.js: importPublicKey() reads a PEM (SPKI) or a JWK / JWKS and verifyAsymmetric() checks RS256/384/512, PS256/384/512, ES256/384/512 and EdDSA with WebCrypto; the JWT tab shows the second text area as 'Public key' for that, the Secret field stays for HS* - a JSON object in the input is a payload: 'Signed token, HS256' signs it with the secret (reproduces the jwt.io example byte for byte) - tests generate RSA, EC P-256 and Ed25519 keys with node:crypto and check valid, wrong-key, tampered and not-a-key cases --- README.md | 2 +- hasher.js | 149 +++++++++++++++++++++++++++++++++------------- lib/snov/jwt.js | 82 +++++++++++++++++++++++++ popup.html | 14 ++--- popup.js | 10 +++- test/diff.test.js | 8 ++- test/jwt.test.js | 76 +++++++++++++++++++---- test/load.js | 31 ++++++++-- 8 files changed, 303 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 242c9a8..2bbfac6 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,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. diff --git a/hasher.js b/hasher.js index fd752db..5ad7654 100644 --- a/hasher.js +++ b/hasher.js @@ -218,6 +218,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 +258,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 @@ -1396,13 +1411,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 +1479,44 @@ 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"; } }, - - // Diff (input vs hasher.options.diff.other) + // Diff (input vs hasher.options.other) d1: { id : tabs.diff+"changes", tab : tabs.diff, @@ -1489,7 +1525,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 +1533,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" : ""; } } }, @@ -1585,6 +1621,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; @@ -1596,34 +1633,66 @@ var hasher = { // tabs that need input show only the prompt until there is some var waiting = input.length == 0 && this.EMPTY[tabName] != undefined; 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/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/popup.html b/popup.html index b393cbc..8e10295 100644 --- a/popup.html +++ b/popup.html @@ -57,6 +57,12 @@ + - @@ -180,7 +180,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..df9b95a 100644 --- a/popup.js +++ b/popup.js @@ -12,7 +12,7 @@ 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" } }; document.addEventListener("DOMContentLoaded", function () { @@ -289,7 +289,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(); }); @@ -340,7 +340,11 @@ document.addEventListener("DOMContentLoaded", function () { 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/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..c5a9054 100644 --- a/test/load.js +++ b/test/load.js @@ -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 }; From f88905240810f65bb028200883d205a6963cd0ce Mon Sep 17 00:00:00 2001 From: Sergey Novikov Date: Sun, 13 Sep 2026 21:55:00 +0200 Subject: [PATCH 3/4] UUID tab: inspect a pasted UUID or ULID, derive v5 from a name The input is back on the UUID tab. A pasted UUID (any spelling: dashes or not, braces, urn:uuid:, upper case) shows its version with a description, the variant, and for v1 / v6 / v7 the embedded creation time; nil and max UUIDs are named; a ULID shows its time. Any other text is a name: UUID v5 in the DNS and URL namespaces (checked against Python's uuid.uuid5). The v4 / v7 / ULID generators stay. --- README.md | 2 +- hasher.js | 53 +++++++++++++++++++++++++++++++++++++++- lib/snov/ids.js | 63 ++++++++++++++++++++++++++++++++++++++++++++++++ popup.html | 2 +- popup.js | 5 ++-- test/ids.test.js | 34 ++++++++++++++++++++++++++ 6 files changed, 154 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2bbfac6..0687546 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ 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 * 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 diff --git a/hasher.js b/hasher.js index 5ad7654..b8fd691 100644 --- a/hasher.js +++ b/hasher.js @@ -1282,6 +1282,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, @@ -1568,6 +1617,8 @@ var hasher = { jwt : "Paste a token to decode it.", 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 */ @@ -1631,7 +1682,7 @@ 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.other.length == 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/popup.html b/popup.html index 8e10295..0c784e2 100644 --- a/popup.html +++ b/popup.html @@ -170,7 +170,7 @@

    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
  • 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
  • diff --git a/popup.js b/popup.js index df9b95a..2d56b33 100644 --- a/popup.js +++ b/popup.js @@ -12,7 +12,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 : "a token to decode and verify, or a JSON payload to sign with HS256" } + 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 () { @@ -335,7 +336,7 @@ 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; 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', ''), ''); +}); From bc76651a52bf6d7514aa3a29e425614a10b1cf21 Mon Sep 17 00:00:00 2001 From: Sergey Novikov Date: Sun, 13 Sep 2026 21:57:28 +0200 Subject: [PATCH 4/4] URL tab lib/snov/urls.js on the URL API: scheme, host (with the Unicode form of an IDN via an RFC 3492 punycode decoder), port (default per scheme in the hint), path (decoded in the hint), query parameters decoded one per line, fragment, credentials (flagged red), origin, normalized form. A bare host or host:port is read as https, with a hint saying so. The popup grows to 780 px and tab labels get 1 px less padding so the 16 tabs stay on one row with the three header icons. --- README.md | 1 + hasher.js | 137 ++++++++++++++++++++++++++++++++++++++++++++++- lib/snov/urls.js | 97 +++++++++++++++++++++++++++++++++ main.css | 6 +-- popup.html | 4 +- popup.js | 1 + test/load.js | 2 +- test/url.test.js | 73 +++++++++++++++++++++++++ 8 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 lib/snov/urls.js create mode 100644 test/url.test.js diff --git a/README.md b/README.md index 0687546..eaea9a7 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Chrome Web Store listing is being republished. Until then, load it unpacked: * 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: 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) diff --git a/hasher.js b/hasher.js index b8fd691..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 */ @@ -1565,6 +1577,128 @@ var hasher = { } }, + // 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.other) d1: { id : tabs.diff+"changes", @@ -1615,6 +1749,7 @@ 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 */ 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 0c784e2..ab07843 100644 --- a/popup.html +++ b/popup.html @@ -13,6 +13,7 @@ + @@ -26,7 +27,7 @@