From 87a608144da79ac8ee3ff9b19b6197f5522460b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 07:22:53 +0000 Subject: [PATCH 1/2] jwt: sign and verify, every algorithm a webview can do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JWT tool decoded and stopped there — the pane even said so — so the two questions people actually open it with went unanswered: is this signature real, and can I mint one like it to test against my service. New `jwt/jwtCrypto.ts` does both on `jose` (already a dependency, already carrying the API Client's `jsonwebtoken` shim), driving the OS webview's own Web Crypto. Nothing leaves the machine. - Algorithms: HS256/384/512, RS256/384/512, PS256/384/512, ES256/384/512, EdDSA, plus unsigned `none` for testing how a server reacts to one. Availability is probed from the engine rather than tabulated, because Ed25519 landed in WebKit, Chromium and Gecko at different times and the same build ships to all three; unavailable entries grey out instead of failing after the user has pasted a key. - Keys: PKCS#8 private, SPKI public, X.509 certificate, JWK, or a whole JWK Set (the key is picked by the token's own `kid`). PKCS#1 and SEC1 keys, which Web Crypto cannot import, are named as such with the `openssl pkcs8 -topk8` line that fixes them. - HMAC secrets carry an explicit encoding (text / base64 / base64url / hex). A secret from `openssl rand -base64 32` is bytes; HMAC-ing its printed characters produces a token the real service rejects, with nothing on screen to say why. - Verify never reads the algorithm off the token's header — that is algorithm confusion, and a debugger that reproduced it would teach it. A mismatch is reported as a mismatch. "Expired" and "wrong audience" are shown as amber, not as a red forgery: the signature held. - Decode gained the claims table — exp / nbf / iat as real times with "in 3 minutes" / "12 days ago", which is what "is this expired?" actually needs. - Generate pair fills both halves, so the Verify side is ready the moment a token is signed. Tokens and keys go to the secret vault (`useSecretState`), never `sdk.storage`. `jwt_sign` and `jwt_verify` join `jwt_decode` over MCP, running the same module, with a failed verification returned as a result rather than thrown — the caller asked a question and "no, it expired" is the answer. 56 new tests cover a round trip per algorithm, tampering, algorithm confusion, clock tolerance, JWK Set selection, secret encodings, and the `none` cases. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DvjATyCpgyTf5gzkhZ3nP9 --- src/components/mcpUtilityTools.ts | 38 +- src/components/tools/JwtDebugger.tsx | 93 ---- src/components/tools/jwt/JwtDebugger.tsx | 323 +++++++++++++ src/components/tools/jwt/jwtCrypto.test.ts | 284 ++++++++++++ src/components/tools/jwt/jwtCrypto.ts | 504 +++++++++++++++++++++ src/components/tools/jwt/jwtParts.tsx | 179 ++++++++ src/components/tools/jwtMcpBridge.test.ts | 83 ++++ src/components/tools/jwtMcpBridge.ts | 90 +++- src/lib/toolGuides.tsx | 19 +- src/plugins/jwt/plugin.ts | 12 +- 10 files changed, 1514 insertions(+), 111 deletions(-) delete mode 100644 src/components/tools/JwtDebugger.tsx create mode 100644 src/components/tools/jwt/JwtDebugger.tsx create mode 100644 src/components/tools/jwt/jwtCrypto.test.ts create mode 100644 src/components/tools/jwt/jwtCrypto.ts create mode 100644 src/components/tools/jwt/jwtParts.tsx 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 */} -
- -