From 75afbd51806180f0d201fbdedfba841c96f12f72 Mon Sep 17 00:00:00 2001 From: hl6226 Date: Mon, 25 May 2026 16:52:10 -0500 Subject: [PATCH] 1. Decode JWT token and show both header and payload; 2. Verify signature supporting three formats of public key; 3. Show header, payload and signature sections in different colors --- src/core/config/Categories.json | 1 + src/core/operations/JWTDecodeAndVerify.mjs | 175 +++++++++++++++++++++ src/web/waiters/InputWaiter.mjs | 52 +++++- src/web/waiters/RecipeWaiter.mjs | 14 ++ 4 files changed, 239 insertions(+), 3 deletions(-) create mode 100644 src/core/operations/JWTDecodeAndVerify.mjs diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index 5fcba29770..a4f4516a29 100644 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -156,6 +156,7 @@ "JWT Sign", "JWT Verify", "JWT Decode", + "JWT Decode and Verify", "Citrix CTX1 Encode", "Citrix CTX1 Decode", "AES Key Wrap", diff --git a/src/core/operations/JWTDecodeAndVerify.mjs b/src/core/operations/JWTDecodeAndVerify.mjs new file mode 100644 index 0000000000..d08767b488 --- /dev/null +++ b/src/core/operations/JWTDecodeAndVerify.mjs @@ -0,0 +1,175 @@ +/** + * JWT Decode and Verify operation. + * + * Shows the decoded header, decoded payload, and signature verification + * status (valid / invalid / not verified) in a single JSON output. + * + * @author hl6226 + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; +import jwt from "jsonwebtoken"; +import r from "jsrsasign"; +import OperationError from "../errors/OperationError.mjs"; +import {JWT_ALGORITHMS} from "../lib/JWT.mjs"; + +/** + * JWT Decode and Verify operation + */ +class JWTDecodeAndVerify extends Operation { + + /** + * JWTDecodeAndVerify constructor + */ + constructor() { + super(); + + this.name = "JWT Decode and Verify"; + this.module = "Ciphers"; + this.description = "Decodes a JSON Web Token and displays the header, payload, and signature verification status.

Provide a secret (for HMAC), PEM-encoded public key (for RSA/ECDSA) or JSON format public key (for JWKS) to verify the signature. Leave the key blank to decode without verification.

Output includes the decoded header, payload, and signature status: verified, invalid, or not checked."; + this.infoURL = "https://wikipedia.org/wiki/JSON_Web_Token"; + this.inputType = "string"; + this.outputType = "JSON"; + this.presentType = "html"; + this.args = [ + { + name: "Public/Secret Key (optional)", + type: "text", + value: "", + hint: "Leave blank to decode without verifying the signature. To verify the signature, enter the public key as a secret (for HMAC), PEM-encoded public key (for RSA/ECDSA) or JSON format public key (for JWKS)" + } + ]; + this.checks = [ + { + // Standard JWT: header.payload.signature (all base64url) + pattern: "^ey[A-Za-z0-9_-]+\\.ey[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]*$", + flags: "", + args: [""] + } + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {Object} + */ + run(input, args) { + const [key] = args; + input = input.trim(); + + // Decode without verification first to extract header + payload + let decoded; + try { + decoded = jwt.decode(input, { complete: true, json: true }); + } catch (err) { + throw new OperationError(`Failed to decode JWT: ${err.message}`); + } + + if (!decoded) { + throw new OperationError( + "Invalid JWT: could not decode token. " + + "Ensure the input is a valid base64url-encoded JWT." + ); + } + + const result = { + header: decoded.header, + payload: decoded.payload, + signatureVerified: "not verified", + signatureWarning: "No key provided — signature was not checked." + }; + + // Attempt signature verification if a key was supplied + if (key && key.trim() !== "") { + const algos = JWT_ALGORITHMS.map(a => (a === "None" ? "none" : a)); + try { + const alg = decoded.header.alg || ""; + let resolvedKey = key.trim(); + if (resolvedKey.startsWith("{") || resolvedKey.startsWith("[")) { + const parsed = JSON.parse(resolvedKey); + resolvedKey = Array.isArray(parsed) ? parsed[0] : + Array.isArray(parsed.keys) ? parsed.keys[0] : parsed; + } + const verifyKey = alg.startsWith("HS") ? { utf8: key.trim() } : r.KEYUTIL.getKey(resolvedKey); + const valid = r.KJUR.jws.JWS.verify(input, verifyKey, algos); + if (!valid) throw new Error("invalid signature"); + result.signatureVerified = true; + result.signatureWarning = "Signature verified successfully."; + } catch (err) { + result.signatureVerified = false; + result.signatureWarning = `Signature verification failed: ${err.message}`; + } + } + + return result; + } + + /** + * Presents the result as a formatted, human-readable string. + * + * @param {Object} data + * @param {Object[]} args + * @returns {string} + */ + present(data, args) { + if (typeof data !== "object" || data === null) return String(data); + + const G = "#52af6d"; // green — signature + const O = "#e08030"; // orange — header + const P = "#b090d0"; // purple — payload + const R = "#761c17"; // red — failed + + const esc = s => String(s) + .replace(/&/g, "&") + .replace(//g, ">"); + + const colorJson = obj => esc(JSON.stringify(obj, null, 2)) + .replace(/\n/g, "
") + .replace(/ /g, " "); + + // Expiry notes + let expiryNote = ""; + if (data.payload && typeof data.payload.exp === "number") { + const expDate = new Date(data.payload.exp * 1000); + const expired = expDate.getTime() < Date.now(); + expiryNote += `
  exp  : ${esc(expDate.toUTCString())}${expired ? "  ⚠ TOKEN EXPIRED" : "  ✓ not yet expired"}`; + } + if (data.payload && typeof data.payload.nbf === "number") { + const nbfDate = new Date(data.payload.nbf * 1000); + const notYet = nbfDate.getTime() > Date.now(); + expiryNote += `
  nbf  : ${esc(nbfDate.toUTCString())}${notYet ? "  ⚠ TOKEN NOT YET VALID" : "  ✓ valid"}`; + } + if (data.payload && typeof data.payload.iat === "number") { + const iatDate = new Date(data.payload.iat * 1000); + expiryNote += `
  iat  : ${esc(iatDate.toUTCString())}`; + } + + const sigIcon = data.signatureVerified === true ? "✓" : + data.signatureVerified === false ? "✗" : "?"; + const sigColor = data.signatureVerified === true ? G : + data.signatureVerified === false ? R : ""; + const sigMsg = sigColor ? + `${esc(data.signatureWarning)}` : + esc(data.signatureWarning); + + const hr = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"; + + return [ + hr, "

HEADER

", hr, + `${colorJson(data.header)}`, + "", + hr, "

PAYLOAD

", hr, + `${colorJson(data.payload)}`, + expiryNote ? `
Time-based claims:${expiryNote}` : "", + "", + hr, `

SIGNATURE  ${sigIcon}

`, hr, + sigMsg, "" + ].join("
"); + } +} + +export default JWTDecodeAndVerify; diff --git a/src/web/waiters/InputWaiter.mjs b/src/web/waiters/InputWaiter.mjs index 94663a0b2e..1b6bf5fa06 100644 --- a/src/web/waiters/InputWaiter.mjs +++ b/src/web/waiters/InputWaiter.mjs @@ -18,11 +18,14 @@ import { drawSelection, rectangularSelection, crosshairCursor, - dropCursor + dropCursor, + ViewPlugin, + Decoration } from "@codemirror/view"; import { EditorState, - Compartment + Compartment, + RangeSetBuilder } from "@codemirror/state"; import { defaultKeymap, @@ -45,6 +48,35 @@ import {fileDetailsPanel} from "../utils/fileDetails.mjs"; import {eolCodeToSeq, eolCodeToName, renderSpecialChar} from "../utils/editorUtils.mjs"; +/** JWT input syntax highlight colours */ +const jwtMarkHeader = Decoration.mark({attributes: {style: "color: #e08030"}}); +const jwtMarkPayload = Decoration.mark({attributes: {style: "color: #b090d0"}}); +const jwtMarkSig = Decoration.mark({attributes: {style: "color: #52af6d"}}); + +/** + * Returns a CodeMirror ViewPlugin that colours JWT parts in the input editor. + * Orange = header, light-purple = payload, green = signature. + */ +function jwtHighlightPlugin() { + return ViewPlugin.fromClass(class { + constructor(view) { this.decorations = this._build(view); } + update(u) { if (u.docChanged) this.decorations = this._build(u.view); } + _build(view) { + const text = view.state.doc.toString(); + const m = text.match(/^([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]*)$/); + if (!m) return Decoration.none; + const b = new RangeSetBuilder(); + const d1 = m[1].length; + const d2 = d1 + 1 + m[2].length; + b.add(0, d1, jwtMarkHeader); + b.add(d1 + 1, d2, jwtMarkPayload); + if (m[3].length) b.add(d2 + 1, d2 + 1 + m[3].length, jwtMarkSig); + return b.finish(); + } + }, {decorations: v => v.decorations}); +} + + /** * Waiter to handle events related to the input. */ @@ -91,7 +123,8 @@ class InputWaiter { this.inputEditorConf = { eol: new Compartment, lineWrapping: new Compartment, - fileDetailsPanel: new Compartment + fileDetailsPanel: new Compartment, + jwtHighlight: new Compartment }; const self = this; @@ -126,6 +159,7 @@ class InputWaiter { this.inputEditorConf.fileDetailsPanel.of([]), this.inputEditorConf.lineWrapping.of(EditorView.lineWrapping), this.inputEditorConf.eol.of(EditorState.lineSeparator.of("\n")), + this.inputEditorConf.jwtHighlight.of([]), // Keymap keymap.of([ @@ -273,6 +307,18 @@ class InputWaiter { }); } + /** + * Enables or disables JWT syntax highlighting on the input editor. + * @param {boolean} enable + */ + setJWTHighlight(enable) { + this.inputEditorView.dispatch({ + effects: this.inputEditorConf.jwtHighlight.reconfigure( + enable ? jwtHighlightPlugin() : [] + ) + }); + } + /** * Gets the value of the current input * @returns {string} diff --git a/src/web/waiters/RecipeWaiter.mjs b/src/web/waiters/RecipeWaiter.mjs index 4272ef3b67..e3dd053fc4 100755 --- a/src/web/waiters/RecipeWaiter.mjs +++ b/src/web/waiters/RecipeWaiter.mjs @@ -509,6 +509,7 @@ class RecipeWaiter { log.debug(`'${e.target.querySelector(".op-title").textContent}' added to recipe`); this.triggerArgEvents(e.target); + this._updateJWTHighlight(); window.dispatchEvent(this.manager.statechange); } @@ -522,10 +523,23 @@ class RecipeWaiter { */ opRemove(e) { log.debug("Operation removed from recipe"); + this._updateJWTHighlight(); window.dispatchEvent(this.manager.statechange); } + /** + * Enables JWT input highlighting when the sole recipe operation is + * "JWT Decode and Verify", disables it otherwise. + */ + _updateJWTHighlight() { + const ops = document.querySelectorAll("#rec-list .op-title"); + const isJWT = ops.length === 1 && + ops[0].textContent.trim() === "JWT Decode and Verify"; + this.manager.input.setJWTHighlight(isJWT); + } + + /** * Handler for text argument dragover events. * Gives the user a visual cue to show that items can be dropped here.