Merge b9611a55107e1b140573d6de1c1e5860dc34a7bb into 4290ea753912378913b1f3f54e0fc5720afeda5d
This commit is contained in:
commit
bf163c016f
@ -170,6 +170,7 @@
|
||||
"JWT Sign",
|
||||
"JWT Verify",
|
||||
"JWT Decode",
|
||||
"JWT Decode and Verify",
|
||||
"Citrix CTX1 Encode",
|
||||
"Citrix CTX1 Decode",
|
||||
"AES Key Wrap",
|
||||
|
||||
175
src/core/operations/JWTDecodeAndVerify.mjs
Normal file
175
src/core/operations/JWTDecodeAndVerify.mjs
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
|
||||
const colorJson = obj => esc(JSON.stringify(obj, null, 2))
|
||||
.replace(/\n/g, "<br>")
|
||||
.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 += `<br> exp : <span style="color:${P}">${esc(expDate.toUTCString())}${expired ? " ⚠ TOKEN EXPIRED" : " ✓ 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> nbf : <span style="color:${P}">${esc(nbfDate.toUTCString())}${notYet ? " ⚠ TOKEN NOT YET VALID" : " ✓ valid"}</span>`;
|
||||
}
|
||||
if (data.payload && typeof data.payload.iat === "number") {
|
||||
const iatDate = new Date(data.payload.iat * 1000);
|
||||
expiryNote += `<br> iat : <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 ${sigIcon}</h4>`, hr,
|
||||
sigMsg, ""
|
||||
].join("<br>");
|
||||
}
|
||||
}
|
||||
|
||||
export default JWTDecodeAndVerify;
|
||||
@ -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}
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user