Merge b9611a55107e1b140573d6de1c1e5860dc34a7bb into 4290ea753912378913b1f3f54e0fc5720afeda5d

This commit is contained in:
hl6226 2026-08-09 09:28:37 -05:00 committed by GitHub
commit bf163c016f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 239 additions and 3 deletions

View File

@ -170,6 +170,7 @@
"JWT Sign",
"JWT Verify",
"JWT Decode",
"JWT Decode and Verify",
"Citrix CTX1 Encode",
"Citrix CTX1 Decode",
"AES Key Wrap",

View File

@ -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 <b>header</b>, <b>payload</b>, and <b>signature verification status</b>.<br><br>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.<br><br>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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const colorJson = obj => esc(JSON.stringify(obj, null, 2))
.replace(/\n/g, "<br>")
.replace(/ /g, "&nbsp;");
// 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 += `<br>&nbsp;&nbsp;exp&nbsp;&nbsp;: <span style="color:${P}">${esc(expDate.toUTCString())}${expired ? "&nbsp;&nbsp;⚠ TOKEN EXPIRED" : "&nbsp;&nbsp;✓ not yet expired"}</span>`;
}
if (data.payload && typeof data.payload.nbf === "number") {
const nbfDate = new Date(data.payload.nbf * 1000);
const notYet = nbfDate.getTime() > Date.now();
expiryNote += `<br>&nbsp;&nbsp;nbf&nbsp;&nbsp;: <span style="color:${P}">${esc(nbfDate.toUTCString())}${notYet ? "&nbsp;&nbsp;⚠ TOKEN NOT YET VALID" : "&nbsp;&nbsp;✓ valid"}</span>`;
}
if (data.payload && typeof data.payload.iat === "number") {
const iatDate = new Date(data.payload.iat * 1000);
expiryNote += `<br>&nbsp;&nbsp;iat&nbsp;&nbsp;: <span style="color:${P}">${esc(iatDate.toUTCString())}</span>`;
}
const sigIcon = data.signatureVerified === true ? "✓" :
data.signatureVerified === false ? "✗" : "?";
const sigColor = data.signatureVerified === true ? G :
data.signatureVerified === false ? R : "";
const sigMsg = sigColor ?
`<span style="color:${sigColor}">${esc(data.signatureWarning)}</span>` :
esc(data.signatureWarning);
const hr = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━";
return [
hr, " <h4 style='color:#e08030'>HEADER</h4>", hr,
`<span style="color:${O}">${colorJson(data.header)}</span>`,
"",
hr, " <h4 style='color:#b090d0'>PAYLOAD</h4>", hr,
`<span style="color:${P}">${colorJson(data.payload)}</span>`,
expiryNote ? `<br>Time-based claims:${expiryNote}` : "",
"",
hr, ` <h4 style="color:${G}">SIGNATURE&nbsp;&nbsp;${sigIcon}</h4>`, hr,
sigMsg, ""
].join("<br>");
}
}
export default JWTDecodeAndVerify;

View File

@ -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}

View File

@ -517,6 +517,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);
}
@ -530,10 +531,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.