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 */} -
- -