Merge b9611a55107e1b140573d6de1c1e5860dc34a7bb into 4290ea753912378913b1f3f54e0fc5720afeda5d
This commit is contained in:
commit
bf163c016f
@ -170,6 +170,7 @@
|
|||||||
"JWT Sign",
|
"JWT Sign",
|
||||||
"JWT Verify",
|
"JWT Verify",
|
||||||
"JWT Decode",
|
"JWT Decode",
|
||||||
|
"JWT Decode and Verify",
|
||||||
"Citrix CTX1 Encode",
|
"Citrix CTX1 Encode",
|
||||||
"Citrix CTX1 Decode",
|
"Citrix CTX1 Decode",
|
||||||
"AES Key Wrap",
|
"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,
|
drawSelection,
|
||||||
rectangularSelection,
|
rectangularSelection,
|
||||||
crosshairCursor,
|
crosshairCursor,
|
||||||
dropCursor
|
dropCursor,
|
||||||
|
ViewPlugin,
|
||||||
|
Decoration
|
||||||
} from "@codemirror/view";
|
} from "@codemirror/view";
|
||||||
import {
|
import {
|
||||||
EditorState,
|
EditorState,
|
||||||
Compartment
|
Compartment,
|
||||||
|
RangeSetBuilder
|
||||||
} from "@codemirror/state";
|
} from "@codemirror/state";
|
||||||
import {
|
import {
|
||||||
defaultKeymap,
|
defaultKeymap,
|
||||||
@ -45,6 +48,35 @@ import {fileDetailsPanel} from "../utils/fileDetails.mjs";
|
|||||||
import {eolCodeToSeq, eolCodeToName, renderSpecialChar} from "../utils/editorUtils.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.
|
* Waiter to handle events related to the input.
|
||||||
*/
|
*/
|
||||||
@ -91,7 +123,8 @@ class InputWaiter {
|
|||||||
this.inputEditorConf = {
|
this.inputEditorConf = {
|
||||||
eol: new Compartment,
|
eol: new Compartment,
|
||||||
lineWrapping: new Compartment,
|
lineWrapping: new Compartment,
|
||||||
fileDetailsPanel: new Compartment
|
fileDetailsPanel: new Compartment,
|
||||||
|
jwtHighlight: new Compartment
|
||||||
};
|
};
|
||||||
|
|
||||||
const self = this;
|
const self = this;
|
||||||
@ -126,6 +159,7 @@ class InputWaiter {
|
|||||||
this.inputEditorConf.fileDetailsPanel.of([]),
|
this.inputEditorConf.fileDetailsPanel.of([]),
|
||||||
this.inputEditorConf.lineWrapping.of(EditorView.lineWrapping),
|
this.inputEditorConf.lineWrapping.of(EditorView.lineWrapping),
|
||||||
this.inputEditorConf.eol.of(EditorState.lineSeparator.of("\n")),
|
this.inputEditorConf.eol.of(EditorState.lineSeparator.of("\n")),
|
||||||
|
this.inputEditorConf.jwtHighlight.of([]),
|
||||||
|
|
||||||
// Keymap
|
// Keymap
|
||||||
keymap.of([
|
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
|
* Gets the value of the current input
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
|
|||||||
@ -517,6 +517,7 @@ class RecipeWaiter {
|
|||||||
log.debug(`'${e.target.querySelector(".op-title").textContent}' added to recipe`);
|
log.debug(`'${e.target.querySelector(".op-title").textContent}' added to recipe`);
|
||||||
|
|
||||||
this.triggerArgEvents(e.target);
|
this.triggerArgEvents(e.target);
|
||||||
|
this._updateJWTHighlight();
|
||||||
window.dispatchEvent(this.manager.statechange);
|
window.dispatchEvent(this.manager.statechange);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -530,10 +531,23 @@ class RecipeWaiter {
|
|||||||
*/
|
*/
|
||||||
opRemove(e) {
|
opRemove(e) {
|
||||||
log.debug("Operation removed from recipe");
|
log.debug("Operation removed from recipe");
|
||||||
|
this._updateJWTHighlight();
|
||||||
window.dispatchEvent(this.manager.statechange);
|
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.
|
* Handler for text argument dragover events.
|
||||||
* Gives the user a visual cue to show that items can be dropped here.
|
* Gives the user a visual cue to show that items can be dropped here.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user