From a35582e74c027fad84c09887f50614b6ba550949 Mon Sep 17 00:00:00 2001 From: David Henning Date: Thu, 27 Aug 2026 22:39:03 +0200 Subject: [PATCH 1/2] perf: parse and verify each token once The decode path decoded a token's header and claims three times on a --key run: jwt.ParseUnverified decoded both segments, parseUnverifiedJWT threw those away and redid them strictly, and verifyJWTSignature parsed the whole compact string again just to reach the cryptography. parseUnverifiedJWT now does the segment work itself and carries the signing method and decoded signature bytes forward, so verification calls SigningMethod.Verify over the segments it already has. The algorithm allowlist jwt.WithValidMethods used to apply is now spelled out explicitly and runs before Verify -- without it an HS256 token signed with a published public key would verify against that key. Both text escapers open with a byte-level plain-ASCII shortcut, the shape predicates gained byte forms so a decrypted payload is not copied just to be measured, and whole-second timestamps skip the big.Rat path. On a 36 KB token: parse 346us -> 170us, verify 196us -> 19us, full --key decode 1.15ms -> 0.74ms, and the escapers go from 287/664 MB/s to about 4.1 GB/s on clean text. Behaviour is unchanged: stdout and stderr are byte-identical across signed JWT, JWE, and malformed-token cases, and only the wording of four malformed-JSON errors differs, now naming the failing segment. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Q26LwBBSBPUpbf1kd4Ep3z --- AGENTS.md | 12 ++--- jsonout.go | 2 +- jwe.go | 24 ++++++++- main.go | 119 ++++++++++++++++++++++++++++++------------ main_test.go | 137 +++++++++++++++++++++++++++++++++++++++++++++++++ output.go | 115 ++++++++++++++++++++++++++++++----------- output_test.go | 62 ++++++++++++++++++++++ 7 files changed, 399 insertions(+), 72 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 875dfdd..e7ed04f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,9 +16,9 @@ All functionality lives in package `main`, split across six source files: - `headerKID()` - Extracts the token's `kid` header (or `""`) so JWK Set verification/decryption selects the key the token names - `printKeyInterpretation()` - Notes on stderr how a key argument was read when it was not read as a file, so precedence-based detection cannot silently take a value the user meant one way and use it another; adds the process-list exposure warning for `--key` values, which `JWTD_KEY` does not carry (`/proc//cmdline` is world-readable, `/proc//environ` is owner-only). Diagnostics go to stderr so stdout stays parseable - `readInteractive()` - Prompts for a token interactively using `chzyer/readline` -- `decodeAndPrint()` / `printParsedJWT()` - `decodeAndPrint` parses the JWT with `golang-jwt/jwt` (`ParseUnverified`) and hands the result to `printParsedJWT`, which orchestrates output and verifies the signature when a key is provided. `printParsedJWT` runs `formatTimestamps` on a `maps.Clone` of the claims, so the display rewrite never touches the authoritative parse and the same `parsedJWT` can still be validated -- `parsedJWT` / `parseUnverifiedJWT()` / `decodeJSON()` - Strictly decode the header, claims, and other displayed JSON with exact `json.Number` values and reject malformed or trailing JSON data. `parseUnverifiedJWT` returns a `parsedJWT` (the raw string, token, segments, and claims) that is threaded through the decode, signature, and claim steps, so one run parses the token once instead of once per step -- `verifyJWTSignature()` / `printSignatureVerdict()` - `verifyJWTSignature` takes a `parsedJWT` and does the cryptographic check without printing, with `jwt.WithoutClaimsValidation()` so the result reflects only the signature, not expiry; it returns `valid`, an invalid-signature `reason`, and a separate hard `err` (unusable key). `printSignatureVerdict` renders `Signature: VALID`/`INVALID` from it and returns the `errInvalidSignature` sentinel on failure so the CLI exits nonzero. The `--json` path reuses the same core. Production code always holds a `parsedJWT` at this point, so there is no string-taking wrapper in the package; the tests keep one (`verifySignature` in `helpers_test.go`) for the cases that start from a compact string +- `decodeAndPrint()` / `printParsedJWT()` - `decodeAndPrint` parses the JWT with `parseUnverifiedJWT` and hands the result to `printParsedJWT`, which orchestrates output and verifies the signature when a key is provided. `printParsedJWT` runs `formatTimestamps` on a `maps.Clone` of the claims, so the display rewrite never touches the authoritative parse and the same `parsedJWT` can still be validated +- `parsedJWT` / `parseUnverifiedJWT()` / `splitCompactJWT()` / `decodeJSON()` - Strictly decode the header, claims, and other displayed JSON with exact `json.Number` values and reject malformed or trailing JSON data. `parseUnverifiedJWT` returns a `parsedJWT` (the raw string, segments, decoded header and claims, signing method, and decoded signature bytes) that is threaded through the decode, signature, and claim steps, so one run parses the token once instead of once per step. It does the segment work itself rather than calling `jwt.ParseUnverified`, whose plain `json.Unmarshal` of the header and claims loses number precision and accepts trailing data — every one of its decodes would be discarded and redone strictly here. The remaining steps mirror `ParseUnverified` exactly, `splitCompactJWT`'s rejection of extra delimiters and the refusal of a missing or unknown `alg` included, so the set of tokens jwtd decodes is unchanged +- `verifyJWTSignature()` / `validMethodsForKey()` / `printSignatureVerdict()` - `verifyJWTSignature` takes a `parsedJWT` and does the cryptographic check without printing, calling the parsed token's `SigningMethod.Verify` over its own segments rather than re-parsing the compact string through `jwt.Parse`; the claims are never consulted, so the result reflects only the signature, not expiry. **It must check the header `alg` against `validMethodsForKey` before verifying.** That allowlist is what `jwt.WithValidMethods` applied while verification went through `jwt.Parse`; verifying from an already-parsed token makes it jwtd's own check, and without it an HS256 token signed with the bytes of a published public key verifies against that key. `TestVerifyJWTSignature_RejectsAlgOutsideKeyTypeAllowlist` holds it down across every key type. `verifyJWTSignature` returns `valid`, an invalid-signature `reason`, and a separate hard `err` (unusable key). `printSignatureVerdict` renders `Signature: VALID`/`INVALID` from it and returns the `errInvalidSignature` sentinel on failure so the CLI exits nonzero. The `--json` path reuses the same core. Production code always holds a `parsedJWT` at this point, so there is no string-taking wrapper in the package; the tests keep one (`verifySignature` in `helpers_test.go`) for the cases that start from a compact string - `publicKeyForVerification()` - Extracts the public key from RSA/ECDSA/Ed25519 private keys ### `jsonout.go` - Machine-readable `--json` output @@ -36,7 +36,7 @@ All functionality lives in package `main`, split across six source files: ### `jwe.go` - JWE parsing and decryption -- `isJWE()` / `isJWT()` - Detect JWE (5 dot-separated parts) and JWS/JWT (3 parts) compact serialization. Every token-shape dispatch goes through one of these two, so the shape rules are not restated inline +- `isJWE()` / `isJWT()` / `isJWEBytes()` / `isJWTBytes()` - Detect JWE (5 dot-separated parts) and JWS/JWT (3 parts) compact serialization. Every token-shape dispatch goes through one of these, so the shape rules are not restated inline; all four read their delimiter counts from the `jweDelimiters`/`jwtDelimiters` constants, so the string and byte forms cannot disagree. The byte forms exist for `printDecryptedPayload`, where the candidate is a decrypted payload that may be large and must not be copied into a string just to be measured - `decodeAndPrintJWE()` / `jweProtectedHeaderMap()` - Parse a JWE with `go-jose` and decode every field in the compact protected header for display; without a key print encrypted part metadata, with a key decrypt and print the payload - `jweEncryptedParts()` / `printEncryptedParts()` / `partSize()` - Encrypted part metadata shown when no key is provided. `jweEncryptedParts` splits the compact serialization into its five segments for both the human and `--json` paths; `partSize` renders `base64URLLen`'s count (or its `-1`) as display text, so measuring and formatting are not implemented twice @@ -54,8 +54,8 @@ Removing the fallback deleted the heuristics that existed only to decide it (`is ### `output.go` - Formatting, escaping, and colored printing -- `printDecryptedPayload()` / `escapeTerminalText()` / `escapeFormattedJSONControls()` - Recursively decode nested JWTs/JWEs and pretty-print JSON objects or arrays; raw plaintext escapes C0 controls except newline/tab, DEL, C1 controls, invalid UTF-8 bytes, and targeted bidi controls, while formatted JSON sanitizes C1, DEL, and the same targeted bidi controls -- `formatTimestamps()` / `timestampStatus()` / `humanizeDuration()` - Convert exact `iat`, `exp`, `nbf` Unix numeric values, including fractions, to RFC3339 strings (original value shown in parentheses); `exp` is annotated with the time remaining or elapsed (`expires in 14m` / `expired 2h ago`) and a future `nbf` with `not yet valid, in 5m` (an already-valid `nbf` gets no note). `humanizeDuration` renders the largest whole unit (s/m/h/d), truncating toward zero for deterministic output. This is display-only and never affects verification or the exit code; `timeNow` is a package variable so the annotations are testable. The `--json` path skips this entirely and keeps raw numeric claims +- `printDecryptedPayload()` / `escapeTerminalText()` / `escapeFormattedJSONControls()` / `isPlainASCIIText()` - Recursively decode nested JWTs/JWEs and pretty-print JSON objects or arrays; raw plaintext escapes C0 controls except newline/tab, DEL, C1 controls, invalid UTF-8 bytes, and targeted bidi controls, while formatted JSON sanitizes C1, DEL, and the same targeted bidi controls. Both escapers open with the `isPlainASCIIText` shortcut, which returns text that is entirely printable ASCII (plus newline and tab) as a single copy instead of rebuilding it rune by rune — a decrypted payload is unbounded in size, and this is the only path that prints one verbatim. The scan is deliberately **byte-level, not `bytes.ContainsFunc`**: rune decoding maps every invalid UTF-8 byte to `RuneError`, which no escape predicate matches, so a rune-level fast path would wave malformed bytes through unescaped +- `formatTimestamps()` / `claimTime()` / `representableTime()` / `timestampStatus()` / `humanizeDuration()` - Convert exact `iat`, `exp`, `nbf` Unix numeric values, including fractions, to RFC3339 strings (original value shown in parentheses); `exp` is annotated with the time remaining or elapsed (`expires in 14m` / `expired 2h ago`) and a future `nbf` with `not yet valid, in 5m` (an already-valid `nbf` gets no note). `claimTime` does the conversion: it re-validates the literal as JSON (a `json.Number` can hold arbitrary text, and `big.Rat.SetString` accepts ratios, hex, and binary exponents that JSON does not), then converts whole seconds with `strconv.ParseInt` and falls back to the exact `big.Rat` path only for the fractional and exponent forms, which cost about thirty allocations per claim. `humanizeDuration` renders the largest whole unit (s/m/h/d), truncating toward zero for deterministic output. This is display-only and never affects verification or the exit code; `timeNow` is a package variable so the annotations are testable. The `--json` path skips this entirely and keeps raw numeric claims - `newFormatter()` - Creates a `go-prettyjson` formatter with the project color scheme - `printSection()` / `printSignature()` / `printVerdict()` - Formatted output using `fatih/color`. `printSection` takes any JSON-marshalable value, so objects and arrays share one path; `printVerdict` renders the `