diff --git a/CHANGELOG.md b/CHANGELOG.md
index c8ad2c1..3f9a722 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -30,3 +30,33 @@ so the base app installer stays small and you only download what you actually us
Relevant commits: `1b59500` (Redis/RabbitMQ/Container Manager), `5ad6ba2` (Kafka
Explorer), and the Rust-side sidecar cutovers `9a76337`/`d777bb0`/`8566311`.
+
+### JWT Debugger now verifies and signs
+
+The tool decoded a token and stopped there. It now answers the two questions
+people actually open it with — is this signature real, and can I mint one like
+it — across every algorithm the app's webview can perform: **HS256/384/512,
+RS256/384/512, PS256/384/512, ES256/384/512, EdDSA**, plus unsigned `none` for
+testing how a server reacts to one.
+
+- **Verify** takes the shared secret for `HS*`, or a PEM public key, an X.509
+ certificate, a JWK, or a whole JWK Set (the right key is picked by the
+ token's own `kid`).
+- **Sign** takes a PKCS#8 private key or private JWK, an expiry like `1h` /
+ `7d`, and an optional `kid`. **Generate pair** creates a fresh key pair and
+ fills the public half in for the Verify side.
+- **Decode** gained a claims table: `exp` / `nbf` / `iat` as real times with
+ "in 3 minutes" / "12 days ago", and an expired token says so.
+- HMAC secrets carry an explicit encoding (plain text / base64 / base64url /
+ hex), because a secret from `openssl rand -base64 32` is *bytes* — signing
+ with its printed characters instead produces a token the real service
+ rejects.
+- Verification never takes the algorithm from the token's own `alg` header
+ (that is the algorithm-confusion attack): you pick the algorithm you expect,
+ and a token that disagrees is reported as a mismatch. Expired or
+ wrong-audience results are shown as such rather than as a bad signature.
+
+Everything runs locally through the OS webview's own Web Crypto — no token,
+key or claim leaves the machine — and tokens and keys are held in the OS
+secret store rather than ordinary app storage. Over MCP, `jwt_sign` and
+`jwt_verify` join `jwt_decode`.
diff --git a/src/components/mcpUtilityTools.ts b/src/components/mcpUtilityTools.ts
index 3c11851..08b63fb 100644
--- a/src/components/mcpUtilityTools.ts
+++ b/src/components/mcpUtilityTools.ts
@@ -94,9 +94,45 @@ export const JWT_MCP_TOOLS: McpToolDef[] = [
// gated by the `jwt` tool id.
{
"name": "jwt_decode",
- "description": "Decode a JWT's header and payload. Decode-only — does NOT verify the signature (no verification key available).",
+ "description": "Decode a JWT's header and payload, plus `expired`/`notYetValid` computed from exp/nbf. Does NOT check the signature — use jwt_verify for that.",
"inputSchema": { "type": "object", "properties": { "token": { "type": "string" } }, "required": ["token"] }
},
+ {
+ "name": "jwt_verify",
+ "description": "Verify a JWT's signature (and optionally iss/aud/sub) with a key you supply. `algorithm` is the one you EXPECT — the token's own `alg` header is never trusted to choose, so a token whose header disagrees is rejected with reason \"alg\". A failed check is a normal result, not an error: returns { valid, reason, message, header, payload }, where reason is one of format/key/alg/signature/expired/nbf/claim. `key` is the shared secret for HS*, or a PEM SPKI public key, X.509 certificate, JWK or JWK Set for the rest. `keyEncoding` says how an HS* secret is written down (utf8 default, base64, base64url, hex) — getting it wrong changes the bytes and fails the signature.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "token": { "type": "string" },
+ "algorithm": { "type": "string", "enum": ["HS256", "HS384", "HS512", "RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512", "EdDSA", "none"] },
+ "key": { "type": "string" },
+ "keyEncoding": { "type": "string", "enum": ["utf8", "base64", "base64url", "hex"] },
+ "issuer": { "type": "string" },
+ "audience": { "type": "string" },
+ "subject": { "type": "string" },
+ "clockTolerance": { "type": "number", "description": "Seconds of leeway on exp/nbf." }
+ },
+ "required": ["token", "algorithm", "key"]
+ }
+ },
+ {
+ "name": "jwt_sign",
+ "description": "Sign claims into a JWT. `key` is the shared secret for HS*, or a PEM PKCS#8 private key or private JWK for RS*/PS*/ES*/EdDSA (PKCS#1 \"BEGIN RSA PRIVATE KEY\" is not accepted — convert with `openssl pkcs8 -topk8`). `expiresIn`/`notBefore` take a duration (\"1h\", \"7d\", or plain seconds), never an absolute timestamp. `iat` is set unless issuedAt is false. Algorithm \"none\" produces an UNSIGNED token that proves nothing — only for testing how a server reacts to one.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "payload": { "type": "object", "description": "Claims object. A JSON string is also accepted." },
+ "algorithm": { "type": "string", "enum": ["HS256", "HS384", "HS512", "RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512", "EdDSA", "none"] },
+ "key": { "type": "string" },
+ "keyEncoding": { "type": "string", "enum": ["utf8", "base64", "base64url", "hex"] },
+ "expiresIn": { "type": "string" },
+ "notBefore": { "type": "string" },
+ "kid": { "type": "string" },
+ "issuedAt": { "type": "boolean" }
+ },
+ "required": ["payload", "algorithm"]
+ }
+ },
];
export const JSON_MCP_TOOLS: McpToolDef[] = [
diff --git a/src/components/tools/JwtDebugger.tsx b/src/components/tools/JwtDebugger.tsx
deleted file mode 100644
index 71e8328..0000000
--- a/src/components/tools/JwtDebugger.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-import { useMemo } from 'react';
-import { Textarea } from '@/components/ui/textarea';
-import { PaneHeader } from '@/components/ui/tool-layout';
-import { CodeViewer } from '@/design-system';
-import { Callout } from '@/components/ui/callout';
-import { Shield } from 'lucide-react';
-import { jwtDecode } from 'jwt-decode';
-import { usePluginSdk, useSecretState } from '@/platform';
-import { quickPasteHint, useQuickPaste } from '@/hooks/useQuickPaste';
-import { useInputHistory } from '@/hooks/useInputHistory';
-
-export function JwtDebugger() {
- // Một JWT dán vào debugger thường là bearer token THẬT của người dùng, nên
- // nó thuộc kho bí mật chứ không phải mặt phẳng khoá dùng chung mà mọi module
- // trong webview đọc được.
- const sdk = usePluginSdk();
- const [token, setToken, tokenReady] = useSecretState(sdk, 'token', '');
-
- useQuickPaste(setToken);
- useInputHistory(token, setToken);
-
- const decoded = useMemo(() => {
- if (!token.trim()) return { header: '', payload: '', error: '' };
- try {
- // Both segments are base64url-encoded; jwt-decode handles base64url +
- // UTF-8 correctly, whereas raw atob() rejects '-'/'_' and mangles UTF-8.
- const headerDecoded = jwtDecode(token, { header: true });
- const payloadDecoded = jwtDecode(token, { header: false });
- return {
- header: JSON.stringify(headerDecoded, null, 2),
- payload: JSON.stringify(payloadDecoded, null, 2),
- error: '',
- };
- } catch (err) {
- return { header: '', payload: '', error: err instanceof Error ? err.message : 'Invalid JWT token' };
- }
- }, [token]);
-
- return (
-
- {/* Token input — fixed height */}
-
-
- {/* Decoded output — scrollable */}
-
- {decoded.error ? (
-
{decoded.error}
- ) : decoded.header ? (
- <>
- {/* Header và Payload từng được phân biệt bằng MÀU KHUNG — accent cho
- Header, tím cho Payload. Hai vấn đề: accent đổi theo tone chủ đạo
- nên chỉ một trong hai khung đổi màu khi swap tone, và bản thân màu
- không nói gì mà nhãn chưa nói. Giờ cả hai dùng cùng một khung
- trung tính; nhãn chữ mang toàn bộ sự phân biệt. */}
-
-
- {/* Cảnh báo thật (không xác thực chữ ký) — dùng Callout thay vì tự
- dựng khung amber lần thứ 8 trong repo. */}
-
- This tool does not verify the signature.
-
- >
- ) : (
-
-
-
- {tokenReady ? 'Paste a JWT token to decode it' : 'Loading…'}
-
-
Supports HS256, RS256, ES256 and more
-
- )}
-
-
- );
-}
diff --git a/src/components/tools/jwt/JwtDebugger.tsx b/src/components/tools/jwt/JwtDebugger.tsx
new file mode 100644
index 0000000..da85506
--- /dev/null
+++ b/src/components/tools/jwt/JwtDebugger.tsx
@@ -0,0 +1,323 @@
+import { useCallback, useMemo, useState } from 'react';
+import { AlertTriangle, BadgeCheck, FileSignature, PenLine, ShieldCheck } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Segmented } from '@/components/ui/segmented';
+import { Callout } from '@/components/ui/callout';
+import { CopyButton } from '@/components/ui/copy-button';
+import { PaneHeader, ToolToolbar } from '@/components/ui/tool-layout';
+import { CodeViewer } from '@/design-system';
+import { JsonEditor } from '@/components/ui/code-editor';
+import { usePluginSdk, usePluginState, useSecretState } from '@/platform';
+import { quickPasteHint, useQuickPaste } from '@/hooks/useQuickPaste';
+import { useInputHistory } from '@/hooks/useInputHistory';
+import {
+ decodeToken, generateKeyPairPem, inspectClaims, isAsymmetric, isHmac, signToken, verifyToken,
+ type JwtAlgorithm, type SecretEncoding, type VerifyOutcome,
+} from './jwtCrypto';
+import { AlgorithmSelect, ClaimTable, EmptyHint, KeyField, useAlgorithmSupport } from './jwtParts';
+
+type Mode = 'decode' | 'verify' | 'sign';
+
+const MODES = [
+ { value: 'decode' as const, label: 'Decode', icon: FileSignature },
+ { value: 'verify' as const, label: 'Verify', icon: ShieldCheck },
+ { value: 'sign' as const, label: 'Sign', icon: PenLine },
+];
+
+const SAMPLE_PAYLOAD = JSON.stringify({ sub: '1234567890', name: 'Jane Doe', role: 'admin' }, null, 2);
+
+export function JwtDebugger() {
+ // A JWT pasted into a debugger is usually a REAL bearer token, and the keys
+ // below are the credentials that mint them — both belong in the secret
+ // vault, not the shared key plane every module in the webview can read.
+ // Everything else here (mode, algorithm, the claims being drafted) is
+ // ordinary preference state.
+ const sdk = usePluginSdk();
+ const [token, setToken, tokenReady] = useSecretState(sdk, 'token', '');
+ const [secret, setSecret] = useSecretState(sdk, 'hmac-secret', '');
+ const [privateKey, setPrivateKey] = useSecretState(sdk, 'private-key', '');
+ const [publicKey, setPublicKey] = useSecretState(sdk, 'public-key', '');
+
+ const [mode, setMode] = usePluginState(sdk, 'mode', 'decode');
+ const [algorithm, setAlgorithm] = usePluginState(sdk, 'algorithm', 'HS256');
+ const [encoding, setEncoding] = usePluginState(sdk, 'secret-encoding', 'utf8');
+ const [payload, setPayload] = usePluginState(sdk, 'sign-payload', SAMPLE_PAYLOAD);
+ const [kid, setKid] = usePluginState(sdk, 'sign-kid', '');
+ const [expiresIn, setExpiresIn] = usePluginState(sdk, 'sign-expires-in', '1h');
+ const [issuer, setIssuer] = usePluginState(sdk, 'verify-issuer', '');
+ const [audience, setAudience] = usePluginState(sdk, 'verify-audience', '');
+ const [clockTolerance, setClockTolerance] = usePluginState(sdk, 'verify-clock-tolerance', '0');
+
+ const [outcome, setOutcome] = useState(null);
+ const [signed, setSigned] = useState('');
+ const [signError, setSignError] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [generating, setGenerating] = useState(false);
+
+ const support = useAlgorithmSupport();
+
+ useQuickPaste(setToken, mode !== 'sign');
+ useInputHistory(token, setToken, mode !== 'sign');
+
+ const decoded: Decoded = useMemo(() => {
+ if (!token.trim()) return null;
+ try {
+ const parts = decodeToken(token);
+ return { ok: true, ...parts, claims: inspectClaims(parts.payload) };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : 'Invalid JWT token' };
+ }
+ }, [token]);
+
+ // The key box shows the secret for HMAC and the right half of the pair
+ // otherwise — so generating a pair in Sign leaves Verify already filled in.
+ const keyValue = isHmac(algorithm) ? secret : mode === 'sign' ? privateKey : publicKey;
+ const setKeyValue = isHmac(algorithm) ? setSecret : mode === 'sign' ? setPrivateKey : setPublicKey;
+
+ const handleGenerate = useCallback(async () => {
+ setGenerating(true);
+ try {
+ const pair = await generateKeyPairPem(algorithm);
+ setPrivateKey(pair.privateKey);
+ setPublicKey(pair.publicKey);
+ setSignError('');
+ } catch (e) {
+ setSignError((e as Error).message);
+ } finally {
+ setGenerating(false);
+ }
+ }, [algorithm, setPrivateKey, setPublicKey]);
+
+ const handleVerify = useCallback(async () => {
+ setBusy(true);
+ try {
+ const tolerance = Number(clockTolerance);
+ setOutcome(await verifyToken({
+ token,
+ algorithm,
+ key: {
+ material: isHmac(algorithm) ? secret : publicKey,
+ encoding,
+ // Lets a whole JWKS be pasted: the token names which key signed it.
+ kid: decoded?.ok && typeof decoded.header.kid === 'string' ? decoded.header.kid : undefined,
+ },
+ issuer: issuer.trim(),
+ audience: audience.trim(),
+ clockToleranceSec: Number.isFinite(tolerance) && tolerance > 0 ? tolerance : undefined,
+ }));
+ } finally {
+ setBusy(false);
+ }
+ }, [token, algorithm, secret, publicKey, encoding, decoded, issuer, audience, clockTolerance]);
+
+ const handleSign = useCallback(async () => {
+ setBusy(true);
+ setSignError('');
+ try {
+ const jwt = await signToken({
+ algorithm,
+ payload,
+ expiresIn: expiresIn.trim() || undefined,
+ header: kid.trim() ? { kid: kid.trim() } : undefined,
+ key: { material: isHmac(algorithm) ? secret : privateKey, encoding },
+ });
+ setSigned(jwt);
+ } catch (e) {
+ setSigned('');
+ setSignError((e as Error).message);
+ } finally {
+ setBusy(false);
+ }
+ }, [algorithm, payload, expiresIn, kid, secret, privateKey, encoding]);
+
+ return (
+
+
+ { setMode(m); setOutcome(null); }} options={MODES} aria-label="JWT mode" />
+ {mode !== 'decode' && (
+ <>
+ { setAlgorithm(a); setOutcome(null); }} support={support} />
+
+ {mode === 'verify' ? 'Verify signature' : 'Sign token'}
+
+ >
+ )}
+
+
+ {mode !== 'sign' && (
+
+ )}
+
+
+ {mode === 'decode' &&
}
+
+ {mode === 'verify' && (
+ <>
+
+
+
+
+
+
+
+ {decoded?.ok &&
}
+ >
+ )}
+
+ {mode === 'sign' && (
+ <>
+
+
+ Payload (claims)
+ iat is set automatically
+
+
+
+
+
+
+
+
+
+
+ {isAsymmetric(algorithm) && publicKey && (
+
+
+ Public key (for whoever verifies)
+
+
+
+ )}
+ {signError &&
{signError} }
+ {algorithm === 'none' && (
+
+ alg: none produces a token with an empty signature. Anyone can edit its
+ claims and it stays "valid" — only useful for testing how a server reacts to one.
+
+ )}
+ {signed && (
+
+
+ Signed token
+
+ { setToken(signed); setMode('decode'); }}>
+ Open in Decode
+
+
+
+
+
+
+ )}
+ >
+ )}
+
+
+ );
+}
+
+function LabelledInput({ label, value, onChange, placeholder }: {
+ label: string; value: string; onChange: (v: string) => void; placeholder?: string;
+}) {
+ return (
+
+ {label}
+ onChange(e.target.value)} placeholder={placeholder} className="h-ctl text-xs" spellCheck={false} />
+
+ );
+}
+
+type Decoded =
+ | { ok: true; header: Record; payload: unknown; signature: string; claims: ReturnType }
+ | { ok: false; error: string }
+ | null;
+
+function DecodeBody({ decoded, tokenReady }: { decoded: Decoded; tokenReady: boolean }) {
+ if (!decoded) return ;
+ if (!decoded.ok) return {decoded.error} ;
+
+ return (
+ <>
+
+
+
+ {/* The old copy said "this tool does not verify the signature", which is
+ no longer true of the tool — only of this pane. Point at the mode
+ that does it rather than leaving a dead end. */}
+
+ Anyone can read or rewrite these claims. Switch to Verify with the issuer's key to find out
+ whether the signature actually holds.
+
+ >
+ );
+}
+
+function VerifyResult({ outcome }: { outcome: VerifyOutcome | null }) {
+ if (!outcome) {
+ return (
+
+ Pick the algorithm you expect the issuer to use, paste its key, then press Verify. The token's own
+ alg header is never trusted to choose for you.
+
+ );
+ }
+ if (outcome.valid) {
+ return {outcome.message} ;
+ }
+ // "Expired" and "wrong audience" mean the signature itself held — a red
+ // "invalid" on those reads as "forged", which is a different problem with a
+ // different fix.
+ const tone = outcome.code === 'expired' || outcome.code === 'nbf' || outcome.code === 'claim' ? 'warning' : 'error';
+ const title = outcome.code === 'expired' ? 'Expired'
+ : outcome.code === 'nbf' ? 'Not valid yet'
+ : outcome.code === 'claim' ? 'Claim check failed'
+ : outcome.code === 'alg' ? 'Algorithm mismatch'
+ : outcome.code === 'key' ? 'Key problem'
+ : 'Signature invalid';
+ return {outcome.message} ;
+}
diff --git a/src/components/tools/jwt/jwtCrypto.test.ts b/src/components/tools/jwt/jwtCrypto.test.ts
new file mode 100644
index 0000000..50bad92
--- /dev/null
+++ b/src/components/tools/jwt/jwtCrypto.test.ts
@@ -0,0 +1,284 @@
+// @vitest-environment node
+//
+// Node rather than jsdom, for the same realm reason jsonwebtokenShim.test.ts
+// documents: jose checks key material with `instanceof Uint8Array`, and under
+// jsdom the polyfilled TextEncoder hands back a Uint8Array from Node's outer
+// realm, which fails that check even though the value is exactly right.
+
+import { describe, expect, it } from 'vitest';
+import { exportJWK, importSPKI } from 'jose';
+import {
+ JWT_ALGORITHMS, decodeSecret, decodeToken, generateKeyPairPem, inspectClaims,
+ isAlgorithmSupported, isAsymmetric, isHmac, signToken, verifyToken,
+ type JwtAlgorithm,
+} from './jwtCrypto';
+
+const SECRET = 'top-secret-value';
+
+async function keysFor(alg: JwtAlgorithm) {
+ return generateKeyPairPem(alg);
+}
+
+describe('algorithm table', () => {
+ it('covers every JWS algorithm a webview can do, plus none', () => {
+ expect(JWT_ALGORITHMS.map((a) => a.alg)).toEqual([
+ 'HS256', 'HS384', 'HS512',
+ 'RS256', 'RS384', 'RS512',
+ 'PS256', 'PS384', 'PS512',
+ 'ES256', 'ES384', 'ES512',
+ 'EdDSA', 'none',
+ ]);
+ });
+
+ it('classifies the families', () => {
+ expect(isHmac('HS384')).toBe(true);
+ expect(isHmac('RS256')).toBe(false);
+ expect(isAsymmetric('ES512')).toBe(true);
+ expect(isAsymmetric('HS256')).toBe(false);
+ expect(isAsymmetric('none')).toBe(false);
+ });
+});
+
+describe('decodeSecret', () => {
+ it('reads the same bytes out of every encoding', () => {
+ const bytes = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
+ expect(decodeSecret('3q2+7w==', 'base64')).toEqual(bytes);
+ expect(decodeSecret('3q2-7w', 'base64url')).toEqual(bytes);
+ expect(decodeSecret('deadbeef', 'hex')).toEqual(bytes);
+ expect(decodeSecret('AB', 'utf8')).toEqual(new Uint8Array([65, 66]));
+ });
+
+ it('signs differently depending on the encoding chosen', async () => {
+ // The whole reason the encoding is an explicit control: the same text
+ // means different bytes, so it means a different signature.
+ const asText = await signToken({ algorithm: 'HS256', payload: { a: 1 }, issuedAt: false, key: { material: 'c2VjcmV0', encoding: 'utf8' } });
+ const asBase64 = await signToken({ algorithm: 'HS256', payload: { a: 1 }, issuedAt: false, key: { material: 'c2VjcmV0', encoding: 'base64' } });
+ expect(asText).not.toBe(asBase64);
+
+ const out = await verifyToken({ token: asBase64, algorithm: 'HS256', key: { material: 'secret', encoding: 'utf8' } });
+ expect(out.valid).toBe(true); // base64 "c2VjcmV0" is the bytes of "secret"
+ });
+
+ it('rejects malformed input instead of signing with junk', () => {
+ expect(() => decodeSecret('zz', 'hex')).toThrow(/valid hex/);
+ expect(() => decodeSecret('abc', 'hex')).toThrow(/valid hex/);
+ });
+});
+
+describe('sign + verify round trip, every algorithm', () => {
+ for (const { alg } of JWT_ALGORITHMS.filter((a) => a.family === 'hmac')) {
+ it(`${alg} round-trips on a shared secret`, async () => {
+ const token = await signToken({ algorithm: alg, payload: { sub: 'u1' }, key: { material: SECRET } });
+ const out = await verifyToken({ token, algorithm: alg, key: { material: SECRET } });
+ expect(out.valid).toBe(true);
+ expect(out.payload?.sub).toBe('u1');
+ expect(out.header?.alg).toBe(alg);
+ expect(out.header?.typ).toBe('JWT');
+ });
+ }
+
+ for (const { alg } of JWT_ALGORITHMS.filter((a) => a.family !== 'hmac' && a.alg !== 'none')) {
+ it(`${alg} round-trips on a generated key pair`, async () => {
+ if (!(await isAlgorithmSupported(alg))) return; // engine without this curve — probe covers it below
+ const { privateKey, publicKey } = await keysFor(alg);
+ const token = await signToken({ algorithm: alg, payload: { sub: 'u1' }, key: { material: privateKey } });
+ const out = await verifyToken({ token, algorithm: alg, key: { material: publicKey } });
+ expect(out.valid).toBe(true);
+ expect(out.payload?.sub).toBe('u1');
+ });
+ }
+});
+
+describe('verify says no', () => {
+ it('rejects a wrong secret', async () => {
+ const token = await signToken({ algorithm: 'HS256', payload: { a: 1 }, key: { material: SECRET } });
+ const out = await verifyToken({ token, algorithm: 'HS256', key: { material: 'wrong' } });
+ expect(out.valid).toBe(false);
+ expect(out.code).toBe('signature');
+ });
+
+ it('rejects a tampered payload', async () => {
+ const token = await signToken({ algorithm: 'HS256', payload: { admin: false }, key: { material: SECRET } });
+ const [h, , s] = token.split('.');
+ const forged = Buffer.from(JSON.stringify({ admin: true })).toString('base64url');
+ const out = await verifyToken({ token: `${h}.${forged}.${s}`, algorithm: 'HS256', key: { material: SECRET } });
+ expect(out.valid).toBe(false);
+ expect(out.code).toBe('signature');
+ });
+
+ it('refuses to take the algorithm from the token header', async () => {
+ // Algorithm confusion: an HS256 token offered to an RS256 verifier. The
+ // answer must be "no", not "let me use the alg you brought".
+ const token = await signToken({ algorithm: 'HS256', payload: { a: 1 }, key: { material: SECRET } });
+ const { publicKey } = await keysFor('RS256');
+ const out = await verifyToken({ token, algorithm: 'RS256', key: { material: publicKey } });
+ expect(out.valid).toBe(false);
+ expect(out.code).toBe('alg');
+ expect(out.message).toMatch(/HS256/);
+ });
+
+ it('separates "expired" from "bad signature"', async () => {
+ const token = await signToken({ algorithm: 'HS256', payload: { exp: Math.floor(Date.now() / 1000) - 60 }, key: { material: SECRET } });
+ const out = await verifyToken({ token, algorithm: 'HS256', key: { material: SECRET } });
+ expect(out.valid).toBe(false);
+ expect(out.code).toBe('expired');
+ expect(out.payload).toBeDefined(); // the claims still come back, so you can read them
+ });
+
+ it('accepts an expired token within the clock tolerance', async () => {
+ const token = await signToken({ algorithm: 'HS256', payload: { exp: Math.floor(Date.now() / 1000) - 10 }, key: { material: SECRET } });
+ const out = await verifyToken({ token, algorithm: 'HS256', key: { material: SECRET }, clockToleranceSec: 120 });
+ expect(out.valid).toBe(true);
+ });
+
+ it('reports a failed issuer check as a claim failure, not a signature failure', async () => {
+ const token = await signToken({ algorithm: 'HS256', payload: { iss: 'https://real' }, key: { material: SECRET } });
+ const out = await verifyToken({ token, algorithm: 'HS256', key: { material: SECRET }, issuer: 'https://expected' });
+ expect(out.valid).toBe(false);
+ expect(out.code).toBe('claim');
+ });
+
+ it('explains a PKCS#1 key instead of failing obscurely', async () => {
+ const out = await verifyToken({
+ token: await signToken({ algorithm: 'HS256', payload: {}, key: { material: SECRET } }),
+ algorithm: 'HS256',
+ key: { material: SECRET },
+ });
+ expect(out.valid).toBe(true);
+
+ const signAttempt = signToken({
+ algorithm: 'RS256',
+ payload: {},
+ key: { material: '-----BEGIN RSA PRIVATE KEY-----\nMII...\n-----END RSA PRIVATE KEY-----' },
+ });
+ await expect(signAttempt).rejects.toThrow(/PKCS#8|pkcs8/);
+ });
+
+ it('will not sign with a public key', async () => {
+ const { publicKey } = await keysFor('ES256');
+ await expect(signToken({ algorithm: 'ES256', payload: {}, key: { material: publicKey } })).rejects.toThrow(/private key/i);
+ });
+});
+
+describe('alg "none"', () => {
+ it('produces a token with an empty signature', async () => {
+ const token = await signToken({ algorithm: 'none', payload: { sub: 'anon' } });
+ expect(token.split('.')[2]).toBe('');
+ expect(decodeToken(token).header.alg).toBe('none');
+ });
+
+ it('never counts as verified', async () => {
+ const token = await signToken({ algorithm: 'none', payload: { sub: 'anon' } });
+ const out = await verifyToken({ token, algorithm: 'none', key: { material: '' } });
+ expect(out.valid).toBe(false);
+ expect(out.payload?.sub).toBe('anon'); // readable, just not trusted
+ });
+
+ it('does not slip past an HS256 verifier', async () => {
+ const token = await signToken({ algorithm: 'none', payload: { admin: true } });
+ const out = await verifyToken({ token, algorithm: 'HS256', key: { material: SECRET } });
+ expect(out.valid).toBe(false);
+ expect(out.code).toBe('alg');
+ });
+});
+
+describe('JWK and JWK Set keys', () => {
+ it('verifies against a public JWK', async () => {
+ const { privateKey, publicKey } = await keysFor('ES256');
+ const pubJwk = await exportJWK(await importSPKI(publicKey, 'ES256', { extractable: true }));
+ const token = await signToken({ algorithm: 'ES256', payload: { a: 1 }, key: { material: privateKey } });
+ const out = await verifyToken({ token, algorithm: 'ES256', key: { material: JSON.stringify(pubJwk) } });
+ expect(out.valid).toBe(true);
+ });
+
+ it('refuses to sign with a public JWK', async () => {
+ const { publicKey } = await keysFor('ES256');
+ const pubJwk = await exportJWK(await importSPKI(publicKey, 'ES256', { extractable: true }));
+ await expect(signToken({ algorithm: 'ES256', payload: {}, key: { material: JSON.stringify(pubJwk) } }))
+ .rejects.toThrow(/public key/i);
+ });
+
+ it('picks the key with the matching kid out of a JWK Set', async () => {
+ const a = await keysFor('ES256');
+ const b = await keysFor('ES256');
+ const jwkA = { ...(await exportJWK(await importSPKI(a.publicKey, 'ES256', { extractable: true }))), kid: 'a' };
+ const jwkB = { ...(await exportJWK(await importSPKI(b.publicKey, 'ES256', { extractable: true }))), kid: 'b' };
+ const token = await signToken({ algorithm: 'ES256', payload: { a: 1 }, header: { kid: 'b' }, key: { material: b.privateKey } });
+
+ const jwks = JSON.stringify({ keys: [jwkA, jwkB] });
+ expect((await verifyToken({ token, algorithm: 'ES256', key: { material: jwks, kid: 'b' } })).valid).toBe(true);
+ expect((await verifyToken({ token, algorithm: 'ES256', key: { material: jwks, kid: 'a' } })).valid).toBe(false);
+ });
+});
+
+describe('claim helpers', () => {
+ it('turns exp/nbf/iat into readable time and a verdict', () => {
+ const now = Date.UTC(2026, 0, 1, 12, 0, 0);
+ const out = inspectClaims({ exp: now / 1000 - 3600, iat: now / 1000 - 7200, iss: 'https://issuer', aud: ['a', 'b'] }, now);
+ expect(out.expired).toBe(true);
+ expect(out.notYetValid).toBe(false);
+ const exp = out.rows.find((r) => r.claim === 'exp')!;
+ expect(exp.tone).toBe('bad');
+ expect(exp.detail).toBe('1 hour ago');
+ expect(out.rows.find((r) => r.claim === 'aud')!.value).toBe('a, b');
+ });
+
+ it('flags a token that is valid but expiring within minutes', () => {
+ const now = Date.now();
+ expect(inspectClaims({ exp: now / 1000 + 60 }, now).rows[0].tone).toBe('warn');
+ });
+
+ it('flags a not-yet-valid token without calling it expired', () => {
+ const now = Date.now();
+ const out = inspectClaims({ nbf: now / 1000 + 600 }, now);
+ expect(out.notYetValid).toBe(true);
+ expect(out.expired).toBe(false);
+ });
+});
+
+describe('sign options', () => {
+ it('treats a bare number TTL as seconds from now, not a 1970 timestamp', async () => {
+ const token = await signToken({ algorithm: 'HS256', payload: {}, expiresIn: 3600, key: { material: SECRET } });
+ const { payload } = decodeToken(token);
+ expect(payload.exp! - payload.iat!).toBe(3600);
+ });
+
+ it('accepts jose duration strings', async () => {
+ const token = await signToken({ algorithm: 'HS256', payload: {}, expiresIn: '2h', key: { material: SECRET } });
+ const { payload } = decodeToken(token);
+ expect(payload.exp! - payload.iat!).toBe(7200);
+ });
+
+ it('lets the header carry kid but never a contradicting alg', async () => {
+ const token = await signToken({
+ algorithm: 'HS256', payload: {}, key: { material: SECRET },
+ header: { kid: 'key-1', alg: 'RS256' },
+ });
+ const { header } = decodeToken(token);
+ expect(header.kid).toBe('key-1');
+ expect(header.alg).toBe('HS256');
+ });
+
+ it('can omit iat', async () => {
+ const token = await signToken({ algorithm: 'HS256', payload: {}, issuedAt: false, key: { material: SECRET } });
+ expect(decodeToken(token).payload.iat).toBeUndefined();
+ });
+
+ it('rejects a payload that is not a JSON object', async () => {
+ await expect(signToken({ algorithm: 'HS256', payload: '[1,2]', key: { material: SECRET } })).rejects.toThrow(/JSON object/);
+ await expect(signToken({ algorithm: 'HS256', payload: '{oops', key: { material: SECRET } })).rejects.toThrow(/not valid JSON/);
+ });
+});
+
+describe('isAlgorithmSupported', () => {
+ it('is true for HMAC and none without touching the engine', async () => {
+ expect(await isAlgorithmSupported('HS512')).toBe(true);
+ expect(await isAlgorithmSupported('none')).toBe(true);
+ });
+
+ it('answers for every asymmetric algorithm without throwing', async () => {
+ for (const { alg } of JWT_ALGORITHMS.filter((a) => a.family !== 'hmac' && a.alg !== 'none')) {
+ expect(typeof await isAlgorithmSupported(alg)).toBe('boolean');
+ }
+ });
+});
diff --git a/src/components/tools/jwt/jwtCrypto.ts b/src/components/tools/jwt/jwtCrypto.ts
new file mode 100644
index 0000000..295142c
--- /dev/null
+++ b/src/components/tools/jwt/jwtCrypto.ts
@@ -0,0 +1,504 @@
+// JWT signing / verification core — every JWS algorithm the Web Crypto API
+// in a webview can actually do, plus the unsecured `none`.
+//
+// Built on `jose`, the same library the API Client's `jsonwebtoken` shim uses
+// (see apiclient/jsonwebtokenShim.ts for why the real npm package can't run
+// here: Tauri's frontend is an OS webview, not a Node process, so there is no
+// `crypto`/`Buffer` to back it). Everything below is a pure async function of
+// its arguments — no React, no SDK, no persisted state — so the UI, the MCP
+// bridge and the tests all drive the exact same code path.
+//
+// Two deliberate positions, both about not lying to the user:
+//
+// 1. **The algorithm is always chosen by the caller, never read from the
+// token.** Trusting a token's own `alg` header is the algorithm-confusion
+// attack (hand an RS256 verifier a token signed HS256 using the RSA
+// PUBLIC key as the HMAC secret, and a naive verifier says "valid"). A
+// debugger that reproduced that bug would teach it.
+// 2. **`none` is offered, and always labelled as no signature at all.** It is
+// genuinely needed to test how a server reacts to an unsigned token, and
+// hiding it wouldn't stop anyone — hand-assembling one is three lines. It
+// never verifies under any other algorithm, and never "passes" silently.
+
+import {
+ SignJWT, jwtVerify, UnsecuredJWT,
+ importPKCS8, importSPKI, importX509, importJWK,
+ exportPKCS8, exportSPKI, generateKeyPair,
+ decodeJwt, decodeProtectedHeader,
+ errors as joseErrors,
+ type JWTPayload, type JWK,
+} from 'jose';
+
+export type JwtAlgorithm =
+ | 'HS256' | 'HS384' | 'HS512'
+ | 'RS256' | 'RS384' | 'RS512'
+ | 'PS256' | 'PS384' | 'PS512'
+ | 'ES256' | 'ES384' | 'ES512'
+ | 'EdDSA'
+ | 'none';
+
+export type JwtFamily = 'hmac' | 'rsa' | 'rsa-pss' | 'ecdsa' | 'eddsa' | 'unsecured';
+
+export interface AlgorithmSpec {
+ alg: JwtAlgorithm;
+ family: JwtFamily;
+ /** What the group is called in the picker. */
+ familyLabel: string;
+ /** One line under the key box: what to paste for THIS algorithm. */
+ keyHint: string;
+}
+
+/** Every algorithm, in the order the picker shows them. */
+export const JWT_ALGORITHMS: AlgorithmSpec[] = [
+ { alg: 'HS256', family: 'hmac', familyLabel: 'HMAC', keyHint: 'Shared secret — any text, or bytes in base64 / base64url / hex.' },
+ { alg: 'HS384', family: 'hmac', familyLabel: 'HMAC', keyHint: 'Shared secret — any text, or bytes in base64 / base64url / hex.' },
+ { alg: 'HS512', family: 'hmac', familyLabel: 'HMAC', keyHint: 'Shared secret — any text, or bytes in base64 / base64url / hex.' },
+ { alg: 'RS256', family: 'rsa', familyLabel: 'RSA PKCS#1 v1.5', keyHint: 'PKCS#8 private key to sign; SPKI public key, certificate or JWK to verify.' },
+ { alg: 'RS384', family: 'rsa', familyLabel: 'RSA PKCS#1 v1.5', keyHint: 'PKCS#8 private key to sign; SPKI public key, certificate or JWK to verify.' },
+ { alg: 'RS512', family: 'rsa', familyLabel: 'RSA PKCS#1 v1.5', keyHint: 'PKCS#8 private key to sign; SPKI public key, certificate or JWK to verify.' },
+ { alg: 'PS256', family: 'rsa-pss', familyLabel: 'RSA-PSS', keyHint: 'Same RSA keys as RS*, signed with PSS padding.' },
+ { alg: 'PS384', family: 'rsa-pss', familyLabel: 'RSA-PSS', keyHint: 'Same RSA keys as RS*, signed with PSS padding.' },
+ { alg: 'PS512', family: 'rsa-pss', familyLabel: 'RSA-PSS', keyHint: 'Same RSA keys as RS*, signed with PSS padding.' },
+ { alg: 'ES256', family: 'ecdsa', familyLabel: 'ECDSA', keyHint: 'EC key on P-256 — the curve is fixed by the algorithm, not by the key.' },
+ { alg: 'ES384', family: 'ecdsa', familyLabel: 'ECDSA', keyHint: 'EC key on P-384 — the curve is fixed by the algorithm, not by the key.' },
+ { alg: 'ES512', family: 'ecdsa', familyLabel: 'ECDSA', keyHint: 'EC key on P-521 (not P-512 — the name is the hash, the curve is 521).' },
+ { alg: 'EdDSA', family: 'eddsa', familyLabel: 'EdDSA', keyHint: 'Ed25519 key. Not every webview ships Ed25519 in Web Crypto — unsupported ones are greyed out.' },
+ { alg: 'none', family: 'unsecured', familyLabel: 'Unsecured', keyHint: 'No key: the token carries an empty signature and proves nothing.' },
+];
+
+const BY_ALG = new Map(JWT_ALGORITHMS.map((s) => [s.alg, s]));
+
+export function algorithmSpec(alg: string): AlgorithmSpec | undefined {
+ return BY_ALG.get(alg as JwtAlgorithm);
+}
+
+export function isJwtAlgorithm(alg: string): alg is JwtAlgorithm {
+ return BY_ALG.has(alg as JwtAlgorithm);
+}
+
+export function isHmac(alg: JwtAlgorithm): boolean {
+ return BY_ALG.get(alg)?.family === 'hmac';
+}
+
+/** True when the algorithm signs with a private key and verifies with a public one. */
+export function isAsymmetric(alg: JwtAlgorithm): boolean {
+ const family = BY_ALG.get(alg)?.family;
+ return family === 'rsa' || family === 'rsa-pss' || family === 'ecdsa' || family === 'eddsa';
+}
+
+// ── Secrets ────────────────────────────────────────────────────────────────
+
+export type SecretEncoding = 'utf8' | 'base64' | 'base64url' | 'hex';
+
+export const SECRET_ENCODINGS: { value: SecretEncoding; label: string }[] = [
+ { value: 'utf8', label: 'Plain text' },
+ { value: 'base64', label: 'base64' },
+ { value: 'base64url', label: 'base64url' },
+ { value: 'hex', label: 'hex' },
+];
+
+/**
+ * A shared secret is BYTES, and how those bytes were written down changes the
+ * signature completely. A secret copied out of a Kubernetes Secret or an
+ * `openssl rand -base64 32` is base64 — HMAC-ing its 44 printable characters
+ * instead of its 32 bytes produces a token the real service rejects, with no
+ * hint as to why. Hence an explicit encoding rather than a guess.
+ */
+export function decodeSecret(value: string, encoding: SecretEncoding): Uint8Array {
+ if (encoding === 'utf8') return new TextEncoder().encode(value);
+
+ if (encoding === 'hex') {
+ const clean = value.replace(/\s+/g, '');
+ if (clean.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(clean)) {
+ throw new Error('Secret is not valid hex — expected an even number of 0-9 a-f characters.');
+ }
+ const out = new Uint8Array(clean.length / 2);
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
+ return out;
+ }
+
+ let b64 = value.replace(/\s+/g, '');
+ if (encoding === 'base64url') b64 = b64.replace(/-/g, '+').replace(/_/g, '/');
+ b64 = b64.padEnd(Math.ceil(b64.length / 4) * 4, '=');
+ let binary: string;
+ try {
+ binary = atob(b64);
+ } catch {
+ throw new Error(`Secret is not valid ${encoding}.`);
+ }
+ const out = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
+ return out;
+}
+
+// ── Key material ───────────────────────────────────────────────────────────
+
+export type KeyPurpose = 'sign' | 'verify';
+
+export interface KeyInput {
+ /** PEM, JWK/JWKS JSON, or a raw shared secret — whatever is in the key box. */
+ material: string;
+ /** Only read for HMAC algorithms. */
+ encoding?: SecretEncoding;
+ /** `kid` to pick out of a JWK Set; usually taken from the token's header. */
+ kid?: string;
+}
+
+/** PEM types Web Crypto cannot import, with the one-liner that converts them. */
+const LEGACY_PEM: { match: string; explain: string }[] = [
+ {
+ match: '-----BEGIN RSA PRIVATE KEY-----',
+ explain: 'This is a PKCS#1 RSA key. Web Crypto only imports PKCS#8 — convert it with:\n'
+ + ' openssl pkcs8 -topk8 -nocrypt -in key.pem -out key.pk8.pem',
+ },
+ {
+ match: '-----BEGIN EC PRIVATE KEY-----',
+ explain: 'This is a SEC1 EC key. Web Crypto only imports PKCS#8 — convert it with:\n'
+ + ' openssl pkcs8 -topk8 -nocrypt -in key.pem -out key.pk8.pem',
+ },
+ {
+ match: '-----BEGIN ENCRYPTED PRIVATE KEY-----',
+ explain: 'This private key is passphrase-encrypted. Decrypt it first:\n'
+ + ' openssl pkcs8 -topk8 -nocrypt -in key.pem -out key.pk8.pem',
+ },
+];
+
+function pickFromJwks(parsed: unknown, alg: JwtAlgorithm, kid?: string): unknown {
+ if (!parsed || typeof parsed !== 'object') return parsed;
+ const keys = (parsed as { keys?: unknown }).keys;
+ if (!Array.isArray(keys)) return parsed;
+
+ const candidates = keys as Record[];
+ const byKid = kid ? candidates.find((k) => k.kid === kid) : undefined;
+ const byAlg = candidates.find((k) => k.alg === alg);
+ const picked = byKid ?? byAlg ?? (candidates.length === 1 ? candidates[0] : undefined);
+ if (!picked) {
+ throw new Error(
+ `This JWK Set has ${candidates.length} keys and none matches`
+ + `${kid ? ` kid "${kid}" or` : ''} alg ${alg} — paste the single JWK you want instead.`,
+ );
+ }
+ return picked;
+}
+
+/**
+ * Turn whatever is in the key box into something `jose` can sign or verify
+ * with, or throw an error that says what to paste instead.
+ */
+export async function resolveKey(input: KeyInput, alg: JwtAlgorithm, purpose: KeyPurpose) {
+ const material = input.material.trim();
+ if (!material) {
+ throw new Error(isHmac(alg) ? 'Enter the shared secret.' : `Paste the ${purpose === 'sign' ? 'private' : 'public'} key.`);
+ }
+
+ if (isHmac(alg)) {
+ const bytes = decodeSecret(material, input.encoding ?? 'utf8');
+ if (bytes.length === 0) throw new Error('The secret decoded to zero bytes.');
+ return bytes;
+ }
+
+ if (material.startsWith('{')) {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(material);
+ } catch {
+ throw new Error('Key looks like JSON (JWK) but does not parse.');
+ }
+ const jwk = pickFromJwks(parsed, alg, input.kid) as JWK;
+ if (purpose === 'sign' && !jwk.d) {
+ throw new Error('This JWK has no "d" — it is a public key, which cannot sign.');
+ }
+ return importJWK(jwk, alg);
+ }
+
+ const legacy = LEGACY_PEM.find((l) => material.includes(l.match));
+ if (legacy) throw new Error(legacy.explain);
+
+ if (purpose === 'sign') {
+ if (!material.includes('-----BEGIN PRIVATE KEY-----')) {
+ throw new Error('Signing needs a PKCS#8 private key ("-----BEGIN PRIVATE KEY-----") or a private JWK.');
+ }
+ return importPKCS8(material, alg);
+ }
+
+ if (material.includes('-----BEGIN CERTIFICATE-----')) return importX509(material, alg);
+ if (material.includes('-----BEGIN PUBLIC KEY-----')) return importSPKI(material, alg);
+ throw new Error('Verifying needs an SPKI public key ("-----BEGIN PUBLIC KEY-----"), an X.509 certificate, or a JWK.');
+}
+
+// ── Algorithm availability ─────────────────────────────────────────────────
+
+const supportCache = new Map>();
+
+/**
+ * Whether THIS webview can do the algorithm at all.
+ *
+ * Web Crypto coverage is not uniform: Ed25519 (EdDSA) landed in WebKit,
+ * Chromium and Gecko at very different times, so the same build of this app
+ * can offer it on one desktop and not on another. Asking the engine beats
+ * shipping a table that is wrong somewhere — and beats a signing button that
+ * fails with "Unrecognized name" after the user has pasted a key.
+ */
+export function isAlgorithmSupported(alg: JwtAlgorithm): Promise {
+ if (isHmac(alg) || alg === 'none') return Promise.resolve(true);
+ const cached = supportCache.get(alg);
+ if (cached) return cached;
+ const probe = generateKeyPair(alg, { extractable: true }).then(() => true, () => false);
+ supportCache.set(alg, probe);
+ return probe;
+}
+
+/** Fresh key pair as PEM, so the tool is usable without hunting for a key first. */
+export async function generateKeyPairPem(alg: JwtAlgorithm): Promise<{ privateKey: string; publicKey: string }> {
+ if (!isAsymmetric(alg)) throw new Error(`${alg} does not use a key pair.`);
+ const { privateKey, publicKey } = await generateKeyPair(alg, { extractable: true });
+ return { privateKey: await exportPKCS8(privateKey), publicKey: await exportSPKI(publicKey) };
+}
+
+// ── Decode ─────────────────────────────────────────────────────────────────
+
+export interface DecodedToken {
+ header: Record;
+ payload: JWTPayload;
+ /** Base64url signature segment, empty for an unsecured token. */
+ signature: string;
+}
+
+export function decodeToken(token: string): DecodedToken {
+ const trimmed = token.trim();
+ return {
+ header: decodeProtectedHeader(trimmed) as Record,
+ payload: decodeJwt(trimmed),
+ signature: trimmed.split('.')[2] ?? '',
+ };
+}
+
+export type ClaimTone = 'neutral' | 'ok' | 'warn' | 'bad';
+
+export interface ClaimRow {
+ claim: string;
+ label: string;
+ value: string;
+ /** Absolute time plus "in 3 minutes" / "12 days ago" for the time claims. */
+ detail?: string;
+ tone: ClaimTone;
+}
+
+function formatRelative(deltaMs: number): string {
+ const abs = Math.abs(deltaMs);
+ const units: [number, string][] = [
+ [86_400_000, 'day'], [3_600_000, 'hour'], [60_000, 'minute'], [1000, 'second'],
+ ];
+ for (const [ms, name] of units) {
+ if (abs >= ms) {
+ const n = Math.round(abs / ms);
+ return deltaMs < 0 ? `${n} ${name}${n === 1 ? '' : 's'} ago` : `in ${n} ${name}${n === 1 ? '' : 's'}`;
+ }
+ }
+ return 'just now';
+}
+
+function timeRow(claim: string, label: string, seconds: unknown, now: number, tone: (deltaMs: number) => ClaimTone): ClaimRow | null {
+ if (typeof seconds !== 'number' || !Number.isFinite(seconds)) return null;
+ const when = seconds * 1000;
+ return {
+ claim,
+ label,
+ value: new Date(when).toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC'),
+ detail: formatRelative(when - now),
+ tone: tone(when - now),
+ };
+}
+
+export interface ClaimInspection {
+ rows: ClaimRow[];
+ expired: boolean;
+ notYetValid: boolean;
+}
+
+/**
+ * The registered claims, rendered as time rather than as Unix seconds.
+ *
+ * "Is this token expired?" is the single most common reason to open a JWT,
+ * and `exp: 1758604262` does not answer it. This is presentation only — it
+ * never decides whether a token is valid; `verifyToken` does that.
+ */
+export function inspectClaims(payload: JWTPayload, now: number = Date.now()): ClaimInspection {
+ const rows: ClaimRow[] = [];
+ const exp = timeRow('exp', 'Expires', payload.exp, now, (d) => (d <= 0 ? 'bad' : d < 300_000 ? 'warn' : 'ok'));
+ const nbf = timeRow('nbf', 'Not before', payload.nbf, now, (d) => (d > 0 ? 'warn' : 'ok'));
+ const iat = timeRow('iat', 'Issued at', payload.iat, now, () => 'neutral');
+ for (const row of [exp, nbf, iat]) if (row) rows.push(row);
+
+ for (const [claim, label] of [['iss', 'Issuer'], ['sub', 'Subject'], ['aud', 'Audience'], ['jti', 'JWT ID']] as const) {
+ const value = payload[claim];
+ if (value === undefined) continue;
+ rows.push({ claim, label, value: Array.isArray(value) ? value.join(', ') : String(value), tone: 'neutral' });
+ }
+
+ return {
+ rows,
+ expired: typeof payload.exp === 'number' && payload.exp * 1000 <= now,
+ notYetValid: typeof payload.nbf === 'number' && payload.nbf * 1000 > now,
+ };
+}
+
+// ── Sign ───────────────────────────────────────────────────────────────────
+
+export interface SignInput {
+ algorithm: JwtAlgorithm;
+ /** Claims, as the JSON text from the editor or an object. */
+ payload: string | JWTPayload;
+ /** Omitted only for `none`, which signs nothing. */
+ key?: KeyInput;
+ /** Extra protected-header fields (kid, typ, cty…). `alg` is always overwritten. */
+ header?: Record;
+ /** Set `iat` to now. On by default. */
+ issuedAt?: boolean;
+ /** jose duration ("2h", "7d") or seconds from now. Left alone when empty. */
+ expiresIn?: string | number;
+ notBefore?: string | number;
+}
+
+function parsePayload(payload: string | JWTPayload): JWTPayload {
+ if (typeof payload !== 'string') return { ...payload };
+ const text = payload.trim();
+ if (!text) return {};
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(text);
+ } catch (e) {
+ throw new Error(`Payload is not valid JSON: ${(e as Error).message}`);
+ }
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ throw new Error('Payload must be a JSON object.');
+ }
+ return parsed as JWTPayload;
+}
+
+// jose reads a NUMBER passed to setExpirationTime/setNotBefore as an absolute
+// Unix timestamp, so `3600` would mean "expired in 1970", not "in an hour" —
+// the same trap jsonwebtokenShim.ts documents. A bare number here means
+// seconds from now, which is what anyone typing "3600" into a TTL box means.
+function toDuration(value: string | number): string {
+ if (typeof value === 'number') return `${value}s`;
+ const text = value.trim();
+ return /^\d+$/.test(text) ? `${text}s` : text;
+}
+
+export async function signToken(input: SignInput): Promise {
+ const { algorithm } = input;
+ if (!isJwtAlgorithm(algorithm)) throw new Error(`Unknown algorithm "${algorithm}".`);
+ const claims = parsePayload(input.payload);
+
+ if (algorithm === 'none') {
+ let unsecured = new UnsecuredJWT(claims);
+ if (input.issuedAt !== false) unsecured = unsecured.setIssuedAt();
+ if (input.expiresIn) unsecured = unsecured.setExpirationTime(toDuration(input.expiresIn));
+ if (input.notBefore) unsecured = unsecured.setNotBefore(toDuration(input.notBefore));
+ return unsecured.encode();
+ }
+
+ if (!input.key) throw new Error(`${algorithm} needs a key to sign with.`);
+ const key = await resolveKey(input.key, algorithm, 'sign');
+ // `alg` last: a stale `alg` left in the header box must not be able to
+ // disagree with the key actually used to sign.
+ let jwt = new SignJWT(claims).setProtectedHeader({ typ: 'JWT', ...input.header, alg: algorithm });
+ if (input.issuedAt !== false) jwt = jwt.setIssuedAt();
+ if (input.expiresIn) jwt = jwt.setExpirationTime(toDuration(input.expiresIn));
+ if (input.notBefore) jwt = jwt.setNotBefore(toDuration(input.notBefore));
+ return jwt.sign(key as never);
+}
+
+// ── Verify ─────────────────────────────────────────────────────────────────
+
+export type VerifyFailure = 'format' | 'key' | 'alg' | 'signature' | 'expired' | 'nbf' | 'claim';
+
+export interface VerifyInput {
+ token: string;
+ algorithm: JwtAlgorithm;
+ key: KeyInput;
+ /** Optional registered-claim checks, skipped when empty. */
+ issuer?: string;
+ audience?: string;
+ subject?: string;
+ clockToleranceSec?: number;
+}
+
+export interface VerifyOutcome {
+ valid: boolean;
+ /** Why it failed. Absent when valid. */
+ code?: VerifyFailure;
+ message: string;
+ header?: Record;
+ payload?: JWTPayload;
+}
+
+export async function verifyToken(input: VerifyInput): Promise {
+ const token = input.token.trim();
+ if (!token) return { valid: false, code: 'format', message: 'Paste a token first.' };
+
+ let header: Record;
+ try {
+ header = decodeProtectedHeader(token) as Record;
+ } catch (e) {
+ return { valid: false, code: 'format', message: `Not a JWT: ${(e as Error).message}` };
+ }
+
+ // Reported, never obeyed — see the algorithm-confusion note at the top.
+ if (typeof header.alg === 'string' && header.alg !== input.algorithm) {
+ return {
+ valid: false,
+ code: 'alg',
+ message: `Token header says alg "${header.alg}" but you are verifying as ${input.algorithm}. `
+ + 'Pick the algorithm you expect this issuer to use — a token is never trusted to name its own.',
+ header,
+ };
+ }
+
+ if (input.algorithm === 'none') {
+ try {
+ const { payload } = UnsecuredJWT.decode(token);
+ return {
+ valid: false,
+ code: 'signature',
+ message: 'This is an unsecured token (alg "none"): it carries no signature, so nothing about it is verified.',
+ header,
+ payload,
+ };
+ } catch (e) {
+ return { valid: false, code: 'format', message: (e as Error).message, header };
+ }
+ }
+
+ let key: Awaited>;
+ try {
+ key = await resolveKey(input.key, input.algorithm, 'verify');
+ } catch (e) {
+ return { valid: false, code: 'key', message: (e as Error).message, header };
+ }
+
+ try {
+ const { payload } = await jwtVerify(token, key as never, {
+ algorithms: [input.algorithm],
+ issuer: input.issuer || undefined,
+ audience: input.audience || undefined,
+ subject: input.subject || undefined,
+ clockTolerance: input.clockToleranceSec,
+ });
+ return { valid: true, message: 'Signature is valid and every claim checked passed.', header, payload };
+ } catch (e) {
+ const payload = (e as { payload?: JWTPayload }).payload;
+ if (e instanceof joseErrors.JWTExpired) {
+ return { valid: false, code: 'expired', message: 'Signature is valid, but the token has expired (exp is in the past).', header, payload };
+ }
+ if (e instanceof joseErrors.JWTClaimValidationFailed) {
+ const code: VerifyFailure = e.claim === 'nbf' ? 'nbf' : 'claim';
+ return { valid: false, code, message: e.message, header, payload };
+ }
+ if (e instanceof joseErrors.JWSSignatureVerificationFailed) {
+ return { valid: false, code: 'signature', message: 'Signature does not match — wrong key, or the token was altered.', header };
+ }
+ return { valid: false, code: 'key', message: (e as Error).message || 'Verification failed.', header };
+ }
+}
diff --git a/src/components/tools/jwt/jwtParts.tsx b/src/components/tools/jwt/jwtParts.tsx
new file mode 100644
index 0000000..b867ba3
--- /dev/null
+++ b/src/components/tools/jwt/jwtParts.tsx
@@ -0,0 +1,179 @@
+import { useEffect, useState } from 'react';
+import { KeyRound, Loader2, Sparkles } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Textarea } from '@/components/ui/textarea';
+import { CopyButton } from '@/components/ui/copy-button';
+import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { cn } from '@/lib/utils';
+import {
+ JWT_ALGORITHMS, SECRET_ENCODINGS, algorithmSpec, isAlgorithmSupported, isAsymmetric, isHmac,
+ type ClaimRow, type JwtAlgorithm, type SecretEncoding,
+} from './jwtCrypto';
+
+/**
+ * Which algorithms this webview can actually perform.
+ *
+ * Asked once per mount and answered by the engine itself — see
+ * `isAlgorithmSupported`. Until the probe resolves everything reads as
+ * available, so the picker never flickers items out from under the cursor on
+ * the overwhelmingly common path where they all work.
+ */
+export function useAlgorithmSupport(): Record {
+ const [support, setSupport] = useState>({});
+
+ useEffect(() => {
+ let cancelled = false;
+ void Promise.all(
+ JWT_ALGORITHMS.map(async ({ alg }) => [alg, await isAlgorithmSupported(alg)] as const),
+ ).then((pairs) => {
+ if (!cancelled) setSupport(Object.fromEntries(pairs));
+ });
+ return () => { cancelled = true; };
+ }, []);
+
+ return support;
+}
+
+export function AlgorithmSelect({ value, onChange, support }: {
+ value: JwtAlgorithm;
+ onChange: (alg: JwtAlgorithm) => void;
+ support: Record;
+}) {
+ // Grouped by family: fourteen flat entries is a wall, and the grouping is
+ // the thing that answers "which one do I even want" (shared secret vs key
+ // pair) before the digest size does.
+ const families = [...new Map(JWT_ALGORITHMS.map((a) => [a.family, a.familyLabel])).entries()];
+
+ return (
+ onChange(v as JwtAlgorithm)}>
+
+
+
+
+ {families.map(([family, label]) => (
+
+ {label}
+ {JWT_ALGORITHMS.filter((a) => a.family === family).map(({ alg }) => {
+ const unsupported = support[alg] === false;
+ return (
+
+ {alg}{unsupported ? ' — unavailable here' : ''}
+
+ );
+ })}
+
+ ))}
+
+
+ );
+}
+
+export function KeyField({
+ algorithm, purpose, value, onChange, encoding, onEncodingChange, onGenerate, generating,
+}: {
+ algorithm: JwtAlgorithm;
+ purpose: 'sign' | 'verify';
+ value: string;
+ onChange: (v: string) => void;
+ encoding: SecretEncoding;
+ onEncodingChange: (e: SecretEncoding) => void;
+ /** Only offered for key-pair algorithms. */
+ onGenerate?: () => void;
+ generating?: boolean;
+}) {
+ const spec = algorithmSpec(algorithm);
+ const hmac = isHmac(algorithm);
+ const label = hmac ? 'Shared secret' : purpose === 'sign' ? 'Private key' : 'Public key or certificate';
+
+ if (algorithm === 'none') {
+ return (
+
+ alg: none takes no key — the token is left unsigned.
+
+ );
+ }
+
+ return (
+
+
+ {label}
+ {hmac && (
+ onEncodingChange(v as SecretEncoding)}>
+
+
+
+
+ {SECRET_ENCODINGS.map((e) => (
+ {e.label}
+ ))}
+
+
+ )}
+
+ {value && }
+ {isAsymmetric(algorithm) && onGenerate && (
+
+ {generating ? : }
+ Generate pair
+
+ )}
+
+
+
+ );
+}
+
+const TONE_CLASS: Record = {
+ neutral: 'text-fg',
+ ok: 'text-ok',
+ warn: 'text-warn',
+ bad: 'text-bad',
+};
+
+export function ClaimTable({ rows }: { rows: ClaimRow[] }) {
+ if (rows.length === 0) return null;
+ return (
+
+
Registered claims
+
+ {rows.map((row, i) => (
+
0 && 'border-t border-line')}
+ >
+ {row.claim}
+ {row.label}
+ {row.value}
+ {row.detail && ({row.detail}) }
+
+ ))}
+
+
+ );
+}
+
+export function EmptyHint({ ready }: { ready: boolean }) {
+ return (
+
+
+
{ready ? 'Paste a JWT to decode it' : 'Loading…'}
+
+ Verify and sign with HS/RS/PS/ES256-512, EdDSA — or unsigned none
+
+
+ );
+}
diff --git a/src/components/tools/jwtMcpBridge.test.ts b/src/components/tools/jwtMcpBridge.test.ts
index f571e26..aed58c3 100644
--- a/src/components/tools/jwtMcpBridge.test.ts
+++ b/src/components/tools/jwtMcpBridge.test.ts
@@ -1,3 +1,9 @@
+// @vitest-environment node
+//
+// jose compares key material with `instanceof Uint8Array`, which jsdom's
+// second realm breaks — same reason jwt/jwtCrypto.test.ts and
+// apiclient/jsonwebtokenShim.test.ts run under Node.
+
import { describe, expect, it } from 'vitest';
import { buildJwtHandlers } from './jwtMcpBridge';
@@ -23,3 +29,80 @@ describe('jwtMcpBridge — jwt_decode', () => {
await expect(handlers.jwt_decode({})).rejects.toThrow(/"token" is required/);
});
});
+
+describe('jwtMcpBridge — jwt_decode answers "is it expired?"', () => {
+ it('reports expiry rather than leaving epoch math to the caller', async () => {
+ const past = Math.floor(Date.now() / 1000) - 60;
+ const { token } = await handlers.jwt_sign({ payload: { exp: past }, algorithm: 'HS256', key: 's' }) as { token: string };
+ const res = await handlers.jwt_decode({ token }) as { expired: boolean; notYetValid: boolean };
+ expect(res.expired).toBe(true);
+ expect(res.notYetValid).toBe(false);
+ });
+});
+
+describe('jwtMcpBridge — jwt_sign', () => {
+ it('signs and round-trips through jwt_verify', async () => {
+ const { token } = await handlers.jwt_sign({ payload: { sub: 'u1' }, algorithm: 'HS256', key: 'secret' }) as { token: string };
+ const res = await handlers.jwt_verify({ token, algorithm: 'HS256', key: 'secret' }) as { valid: boolean; payload: { sub: string } };
+ expect(res.valid).toBe(true);
+ expect(res.payload.sub).toBe('u1');
+ });
+
+ it('takes the payload as a JSON string too', async () => {
+ const { token } = await handlers.jwt_sign({ payload: '{"a":1}', algorithm: 'HS256', key: 'secret' }) as { token: string };
+ expect((await handlers.jwt_decode({ token }) as { payload: { a: number } }).payload.a).toBe(1);
+ });
+
+ it('reads expiresIn as a duration from now', async () => {
+ const { token } = await handlers.jwt_sign({ payload: {}, algorithm: 'HS256', key: 'secret', expiresIn: '1h' }) as { token: string };
+ const { payload } = await handlers.jwt_decode({ token }) as { payload: { exp: number; iat: number } };
+ expect(payload.exp - payload.iat).toBe(3600);
+ });
+
+ it('rejects a missing key, a bad algorithm and a non-object payload', async () => {
+ await expect(handlers.jwt_sign({ payload: {}, algorithm: 'HS256' })).rejects.toThrow(/"key" is required/);
+ await expect(handlers.jwt_sign({ payload: {}, algorithm: 'HS999', key: 's' })).rejects.toThrow(/Unknown algorithm/);
+ await expect(handlers.jwt_sign({ payload: [1], algorithm: 'HS256', key: 's' })).rejects.toThrow(/JSON object/);
+ });
+
+ it('signs alg "none" without a key, and the result verifies as invalid', async () => {
+ const { token } = await handlers.jwt_sign({ payload: { a: 1 }, algorithm: 'none' }) as { token: string };
+ expect(token.split('.')[2]).toBe('');
+ const res = await handlers.jwt_verify({ token, algorithm: 'none', key: '' }) as { valid: boolean };
+ expect(res.valid).toBe(false);
+ });
+});
+
+describe('jwtMcpBridge — jwt_verify', () => {
+ it('returns a failed check as a result, not a thrown error', async () => {
+ const { token } = await handlers.jwt_sign({ payload: { a: 1 }, algorithm: 'HS256', key: 'right' }) as { token: string };
+ const res = await handlers.jwt_verify({ token, algorithm: 'HS256', key: 'wrong' }) as { valid: boolean; reason: string };
+ expect(res.valid).toBe(false);
+ expect(res.reason).toBe('signature');
+ });
+
+ it('will not take the algorithm from the token header', async () => {
+ const res = await handlers.jwt_verify({ token: SAMPLE_TOKEN, algorithm: 'RS256', key: '-----BEGIN PUBLIC KEY-----' }) as { valid: boolean; reason: string };
+ expect(res.valid).toBe(false);
+ expect(res.reason).toBe('alg');
+ });
+
+ it('honours keyEncoding when the secret is base64', async () => {
+ const { token } = await handlers.jwt_sign({ payload: {}, algorithm: 'HS256', key: 'c2VjcmV0', keyEncoding: 'base64' }) as { token: string };
+ expect((await handlers.jwt_verify({ token, algorithm: 'HS256', key: 'secret' }) as { valid: boolean }).valid).toBe(true);
+ expect((await handlers.jwt_verify({ token, algorithm: 'HS256', key: 'c2VjcmV0' }) as { valid: boolean }).valid).toBe(false);
+ });
+
+ it('separates expired from forged, and honours clockTolerance', async () => {
+ const past = Math.floor(Date.now() / 1000) - 30;
+ const { token } = await handlers.jwt_sign({ payload: { exp: past }, algorithm: 'HS256', key: 'secret' }) as { token: string };
+ expect((await handlers.jwt_verify({ token, algorithm: 'HS256', key: 'secret' }) as { reason: string }).reason).toBe('expired');
+ expect((await handlers.jwt_verify({ token, algorithm: 'HS256', key: 'secret', clockTolerance: 300 }) as { valid: boolean }).valid).toBe(true);
+ });
+
+ it('validates its own arguments', async () => {
+ await expect(handlers.jwt_verify({ algorithm: 'HS256', key: 's' })).rejects.toThrow(/"token" is required/);
+ await expect(handlers.jwt_verify({ token: SAMPLE_TOKEN, algorithm: 'HS256', key: 's', clockTolerance: 'lots' }))
+ .rejects.toThrow(/clockTolerance/);
+ });
+});
diff --git a/src/components/tools/jwtMcpBridge.ts b/src/components/tools/jwtMcpBridge.ts
index 547868f..9fe540c 100644
--- a/src/components/tools/jwtMcpBridge.ts
+++ b/src/components/tools/jwtMcpBridge.ts
@@ -1,10 +1,17 @@
-// MCP bridge — JWT Debugger tool (`jwt`). Decode-only, same limitation as
-// the UI (`jwt-decode` cannot verify a signature — there is no signing key
-// to check against). Pure function of its argument — no persisted state —
-// so, like codecMcpBridge.ts, this is mounted unconditionally at the app
-// root (McpUtilityBridge.tsx), gated only by the per-tool MCP toggle.
+// MCP bridge — JWT Debugger tool (`jwt`). Pure functions of their arguments,
+// no persisted state, so — like codecMcpBridge.ts — this is mounted
+// unconditionally at the app root (McpUtilityBridge.tsx), gated only by the
+// per-tool MCP toggle.
+//
+// `jwt_decode` stays decode-only; `jwt_sign` and `jwt_verify` take the key in
+// the call, and run the exact same code the UI does (jwt/jwtCrypto.ts) so an
+// agent and a human get the same answer for the same token — including the
+// refusal to read the algorithm off the token's own header.
-import { jwtDecode } from 'jwt-decode';
+import {
+ decodeToken, inspectClaims, isJwtAlgorithm, signToken, verifyToken,
+ type JwtAlgorithm, type SecretEncoding,
+} from './jwt/jwtCrypto';
type ToolHandler = (args: Record) => Promise;
@@ -13,18 +20,81 @@ function requireString(v: unknown, name: string): string {
return v;
}
+function requireAlgorithm(v: unknown): JwtAlgorithm {
+ const alg = requireString(v, 'algorithm');
+ if (!isJwtAlgorithm(alg)) throw new Error(`Unknown algorithm "${alg}"`);
+ return alg;
+}
+
+function optionalString(v: unknown, name: string): string | undefined {
+ if (v === undefined || v === null) return undefined;
+ if (typeof v !== 'string') throw new Error(`"${name}" must be a string`);
+ return v;
+}
+
export function buildJwtHandlers(): Record {
return {
jwt_decode: async (args) => {
const token = requireString(args.token, 'token');
try {
- return {
- header: jwtDecode(token, { header: true }),
- payload: jwtDecode(token, { header: false }),
- };
+ const { header, payload } = decodeToken(token);
+ const claims = inspectClaims(payload);
+ // `expired` is the question most decode calls are really asking, and
+ // an agent shouldn't have to do epoch arithmetic to answer it.
+ return { header, payload, expired: claims.expired, notYetValid: claims.notYetValid };
} catch (e) {
throw new Error((e as Error).message || 'Invalid JWT token');
}
},
+
+ jwt_sign: async (args) => {
+ const algorithm = requireAlgorithm(args.algorithm);
+ const payload = args.payload;
+ if (typeof payload !== 'string' && (typeof payload !== 'object' || payload === null || Array.isArray(payload))) {
+ throw new Error('"payload" is required and must be a JSON object (or a JSON string)');
+ }
+ const key = optionalString(args.key, 'key');
+ if (algorithm !== 'none' && !key) throw new Error(`"key" is required for ${algorithm}`);
+
+ return {
+ token: await signToken({
+ algorithm,
+ payload: payload as string | Record,
+ expiresIn: optionalString(args.expiresIn, 'expiresIn'),
+ notBefore: optionalString(args.notBefore, 'notBefore'),
+ issuedAt: args.issuedAt !== false,
+ header: optionalString(args.kid, 'kid') ? { kid: args.kid } : undefined,
+ key: key ? { material: key, encoding: (optionalString(args.keyEncoding, 'keyEncoding') ?? 'utf8') as SecretEncoding } : undefined,
+ }),
+ };
+ },
+
+ jwt_verify: async (args) => {
+ const token = requireString(args.token, 'token');
+ const algorithm = requireAlgorithm(args.algorithm);
+ const key = optionalString(args.key, 'key') ?? '';
+ const tolerance = args.clockTolerance;
+ if (tolerance !== undefined && typeof tolerance !== 'number') throw new Error('"clockTolerance" must be a number of seconds');
+
+ // A failed verification is an ANSWER, not a tool error: the caller asked
+ // whether the signature holds, and "no, because it expired" is the
+ // result. Only malformed arguments throw.
+ const outcome = await verifyToken({
+ token,
+ algorithm,
+ key: { material: key, encoding: (optionalString(args.keyEncoding, 'keyEncoding') ?? 'utf8') as SecretEncoding },
+ issuer: optionalString(args.issuer, 'issuer'),
+ audience: optionalString(args.audience, 'audience'),
+ subject: optionalString(args.subject, 'subject'),
+ clockToleranceSec: tolerance,
+ });
+ return {
+ valid: outcome.valid,
+ reason: outcome.code ?? null,
+ message: outcome.message,
+ header: outcome.header ?? null,
+ payload: outcome.payload ?? null,
+ };
+ },
};
}
diff --git a/src/lib/toolGuides.tsx b/src/lib/toolGuides.tsx
index 4530aa5..1e3626a 100644
--- a/src/lib/toolGuides.tsx
+++ b/src/lib/toolGuides.tsx
@@ -265,9 +265,22 @@ export const TOOL_GUIDES: Record = {
}),
jwt: makeGuide({
- use: ['Paste a JWT (header.payload.signature) — the decoded header and payload show as formatted JSON instantly.'],
- know: ['Decoding happens entirely on your machine; nothing is sent anywhere.'],
- caveat: ['This decodes only — it does NOT verify the signature, so don’t trust a token’s contents based on this alone.'],
+ use: [
+ 'Decode: paste a JWT (header.payload.signature) — header and payload show as formatted JSON instantly, with exp / nbf / iat rendered as real times and flagged when expired.',
+ <>Verify: pick the algorithm you expect , paste the key (shared secret for HS* ; PEM public key, X.509 certificate, JWK or a whole JWK Set for the rest), and optionally require an issuer / audience.>,
+ <>Sign: edit the claims, set an expiry like 1h or 7d , paste a private key — or press Generate pair to get a fresh one, which also fills the public key for the Verify side.>,
+ <>Algorithms: HS256/384/512 , RS256/384/512 , PS256/384/512 , ES256/384/512 , EdDSA , and unsigned none .>,
+ ],
+ know: [
+ 'Everything runs on your machine through the OS webview’s own Web Crypto — no key, token or claim is ever sent anywhere.',
+ <>For HS* , pick how the secret is written down (plain text / base64 / base64url / hex). A secret from openssl rand -base64 32 is bytes , and HMAC-ing its printed characters instead produces a token the real service rejects.>,
+ 'Tokens and keys are held in the OS secret store, not in ordinary app storage.',
+ ],
+ caveat: [
+ <>Verify never reads the algorithm off the token’s own alg header — trusting it is exactly how algorithm-confusion attacks work — so a token whose header disagrees with your choice is rejected as a mismatch.>,
+ <>Web Crypto only imports PKCS#8 private keys (-----BEGIN PRIVATE KEY----- ). Convert a PKCS#1 or SEC1 key with openssl pkcs8 -topk8 -nocrypt .>,
+ <>Ed25519 is missing from some webviews; unavailable algorithms are greyed out in the picker. A token signed with none carries no signature and proves nothing.>,
+ ],
}),
regex: makeGuide({
diff --git a/src/plugins/jwt/plugin.ts b/src/plugins/jwt/plugin.ts
index 7c04841..99ccad6 100644
--- a/src/plugins/jwt/plugin.ts
+++ b/src/plugins/jwt/plugin.ts
@@ -6,12 +6,16 @@ export default definePlugin({
label: "JWT Debugger",
icon: KeyRound,
description:
- "Decode and inspect JWT headers and payloads without verification.",
- keywords: ["jwt", "token", "decode", "header", "payload", "claims", "bearer", "auth"],
+ "Decode, verify and sign JWTs — HMAC, RSA, RSA-PSS, ECDSA and EdDSA, with PEM, JWK or JWK Set keys.",
+ keywords: [
+ "jwt", "token", "decode", "verify", "sign", "signature", "header", "payload", "claims",
+ "bearer", "auth", "jws", "jwk", "jwks", "hmac", "hs256", "rs256", "ps256", "es256", "eddsa",
+ "ed25519", "pem", "pkcs8", "spki", "exp", "expired",
+ ],
route: '/jwt',
order: 220,
defaultEnabled: false,
- permissions: ['secrets', 'clipboard:read', 'clipboard:write'],
+ permissions: ['storage', 'secrets', 'clipboard:read', 'clipboard:write'],
sdk: '^1.0.0',
- load: () => import('@/components/tools/JwtDebugger').then((m) => m.JwtDebugger),
+ load: () => import('@/components/tools/jwt/JwtDebugger').then((m) => m.JwtDebugger),
});