diff --git a/AGENTS.md b/AGENTS.md index ffa217b..79be0a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,14 +11,14 @@ All functionality lives in package `main`, split across six source files: ### `main.go` - CLI, token input, and the JWT/JWS path - `main()` / `newRootCommand()` - Build and execute the Cobra root command with the `--key`/`-k`, `--json`, and `--color` flags; suppress Cobra's automatic usage/error output so runtime errors are rendered once, while invalid-signature details are not duplicated -- `run()` / `readToken()` / `decodeJWTHuman()` - Resolves the token from arguments, stdin pipe, or interactive readline prompt; falls back to `JWTD_KEY` when `--key` is not set; applies the color mode, then dispatches to the JWT/JWE handler or, under `--json`, to the JSON handler. `decodeJWTHuman` wraps `decodeAndPrint` and, when claim validation was requested, prints a Claims section after it; both the signature and claim checks run so their sections show together, and the command exits nonzero if either fails (the signature verdict takes precedence for the returned sentinel). Claim flags on a JWE emit a stderr note and are otherwise skipped +- `run()` / `readToken()` / `decodeJWTHuman()` - Resolves the token from arguments, stdin pipe, or interactive readline prompt. An empty or whitespace-only token *argument* is reported as `errNoToken`, the same as an empty pipe or an empty interactive line, rather than being handed on as a malformed token. A piped stdin is read through an `io.LimitReader` bounded by `maxStdinTokenBytes` (16 MiB) with one byte of headroom, so input past the limit is an explicit error instead of a silent truncation into a different token. Falls back to `JWTD_KEY` when `--key` is not set; applies the color mode, then dispatches to the JWT/JWE handler or, under `--json`, to the JSON handler. `decodeJWTHuman` wraps `decodeAndPrint` and, when claim validation was requested, prints a Claims section after it; both the signature and claim checks run so their sections show together, and the command exits nonzero if either fails (the signature verdict takes precedence for the returned sentinel). Claim flags on a JWE emit a stderr note and are otherwise skipped - `applyColorMode()` - Maps `--color` onto `fatih/color`'s global `NoColor`: `auto` leaves TTY/`NO_COLOR` detection untouched, `always` forces color, `never` disables it; `--json` always forces color off -- `headerKID()` - Extracts the token's `kid` header (or `""`) so JWK Set verification/decryption selects the key the token names +- `headerKID()` - Extracts the token's `kid` header (or `""` when absent) so JWK Set verification/decryption selects the key the token names. **A present but non-string `kid` is the `errNonStringKID` error, not an absent one.** RFC 7515 requires the value to be a string, and returning `""` for `{"kid":123}` made `parseJWK` read it as "no kid named" and select the first JWK Set entry — a signature made with a key the token never pointed at, reported as `VALID`. Every call site (`verifyJWTSignature`, `decodeAndPrintJWE`, `decodeJWEJSON`) propagates the error, so the human, `--json`, and JWE paths fail closed alike; `TestHeaderKID`, `TestVerifySignature_RejectsNonStringKID`, and `TestDecodeJWE_RejectsNonStringKID` pin that (go-jose rejects such a header while parsing a JWE, so the JWE refusal arrives even earlier) - `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 `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()` / `isJSONWhitespace()` - Strictly decode the header, claims, and other displayed JSON with exact `json.Number` values and reject malformed or trailing JSON data. `decodeJSON` decides the ordinary no-trailing-data case with `isJSONWhitespace` over what follows the value, and only hands the input back to the decoder — which is what phrases the rejection — when something else is there. That set is JSON's own four whitespace characters, not Unicode's: a vertical tab after a value is trailing data and must still reach the decoder that says so. `parseUnverifiedJWT` returns a `parsedJWT` (the 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. The allowlist fails closed: `validMethodsForKey` returns an empty list for a key type it does not know (an X25519 key, which parses as a valid JWE key but can verify no JWS), and `verifyJWTSignature` rejects such a key outright instead of skipping the check and leaving the refusal to each `Verify` implementation's type assertion; `TestVerifyJWTSignature_RejectsKeyTypeWithoutJWSAlgorithms` pins that. `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 +- `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` resolves the key before it writes anything**: `loadKeyForKID` runs ahead of the first `printSection`, so an unusable `--key` fails with an error alone instead of three decoded sections followed by one — the JWE path and both `--json` paths already resolved their key up front, and this is what makes the human JWT path agree with them. `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()` / `isJSONWhitespace()` - Strictly decode the header, claims, and other displayed JSON with exact `json.Number` values and reject malformed or trailing JSON data. A header or payload segment that is the literal `null` decodes without error and leaves the target map nil, so both are additionally rejected with an `expected JSON object` error — the guard `jweProtectedHeaderMap` already carried; without it a `null` payload rendered as `{}` with a VALID claim verdict. `decodeJSON` decides the ordinary no-trailing-data case with `isJSONWhitespace` over what follows the value, and only hands the input back to the decoder — which is what phrases the rejection — when something else is there. That set is JSON's own four whitespace characters, not Unicode's: a vertical tab after a value is trailing data and must still reach the decoder that says so. `parseUnverifiedJWT` returns a `parsedJWT` (the 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()` / `verifyLoadedKeySignature()` / `validMethodsForKey()` / `printSignatureVerdict()` - `verifyLoadedKeySignature` is the verification core: it takes a `parsedJWT` and an **already-loaded** key 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. The allowlist fails closed: `validMethodsForKey` returns an empty list for a key type it does not know (an X25519 key, which parses as a valid JWE key but can verify no JWS), and `verifyJWTSignature` rejects such a key outright instead of skipping the check and leaving the refusal to each `Verify` implementation's type assertion; `TestVerifyJWTSignature_RejectsKeyTypeWithoutJWSAlgorithms` pins that. Everything `verifyLoadedKeySignature` can report is a verdict on the signature, so it returns only `valid` plus an invalid-signature `reason`; the one hard failure, an unusable key argument, belongs to whoever loaded the key. `printSignatureVerdict` takes the loaded key too — that is what lets `printParsedJWT` fail before printing — renders `Signature: VALID`/`INVALID`, and returns the `errInvalidSignature` sentinel on failure so the CLI exits nonzero. `verifyJWTSignature` is the key-argument-taking wrapper (`loadKeyForKID` plus the core, returning the separate hard `err`) that the `--json` path and the benchmarks use, so both output paths share one verification. Production code always holds a `parsedJWT` at this point, so there is no string-taking wrapper in the package; the tests keep `verifySignature` and `verifyClaims` in `helpers_test.go` for the cases that start from a compact string - `publicKeyForVerification()` - Extracts the public key from RSA/ECDSA/Ed25519 private keys ### `formatter.go` - Colored JSON rendering @@ -29,39 +29,39 @@ All functionality lives in package `main`, split across six source files: ### `jsonout.go` - Machine-readable `--json` output -- `decodeJWTJSON()` / `decodeJWEJSON()` - Emit one JSON object per token. A JWT carries `header`, `payload`, `signature`, and (with a key) `signatureValid`; an invalid signature still writes the JSON and then returns `errInvalidSignature` for the exit code. A JWE carries `protectedHeader` plus either encrypted part sizes (no key) or `decryptedPayload` (with a key) -- `jsonPayloadValue()` / `base64URLLen()` / `writeJSON()` - Decode a decrypted payload as structured JSON when possible (else a string); report part sizes; and encode with `encoding/json`, which preserves `json.Number` exactly and escapes control characters including ESC. Timestamps are left as raw numeric claim values here — `formatTimestamps` is intentionally not applied — so consumers do their own date math +- `decodeJWTJSON()` / `decodeJWEJSON()` - Emit one JSON object per token. A JWT carries `header`, `payload`, `signature`, and (with a key) `signatureValid`; an invalid signature still writes the JSON and then returns `errInvalidSignature` for the exit code. A JWE carries `protectedHeader` plus either encrypted part sizes (no key) or `decryptedPayload` (with a key). `decryptedPayload` is a **pointer** field: a plaintext that is the JSON literal `null` decodes to a nil interface, which `omitempty` on a plain `any` would drop, leaving an object with neither `encrypted` nor `decryptedPayload` — the pointer keeps presence (a key was given) separate from content (`null`). The `--json` JWE path deliberately does not recurse into a nested token the way the human path does: `jsonPayloadValue` reports it as its compact string, so consumers decode it themselves +- `jsonPayloadValue()` / `base64URLLen()` / `writeJSON()` - Decode a decrypted payload as structured JSON when possible (else a string); report part sizes; and encode with `encoding/json`, which preserves `json.Number` exactly and escapes control characters including ESC. Timestamps are left as raw numeric claim values here — `formatTimestamps` is intentionally not applied — so consumers do their own date math. `base64URLLen` derives the decoded size from the encoded text (alphabet check, CR/LF skipped as `encoding/base64` skips them, a remainder of one character rejected, then `DecodedLen`) instead of decoding the part: a JWE ciphertext is unbounded and this only ever reports its length. `TestBase64URLLenMatchesDecode` holds it to exactly what `base64.RawURLEncoding.DecodeString` answers, invalid input included ### `claims.go` - Opt-in claim validation - `claimChecks` / `requested()` - Holds the `--verify-claims`, `--aud`, and `--iss` flag values. The zero value requests nothing, so the default stays decode-only and the exit code keeps reflecting the signature alone; `requested()` treats an expected audience or issuer as implying validation, so those flags work without also passing `--verify-claims` -- `validateClaimsSet()` - Runs the requested RFC 7519 checks against the already-parsed claims via `jwt.NewValidator` with `jwt.WithTimeFunc(timeNow)` (so the verdict shares the display clock and is deterministic under `pinTime`), returning `valid` plus a reason. Temporal claims (`exp`, `nbf`) that are present are always checked; an expected `aud`/`iss` is additionally required to be present and match. A missing `exp` is not treated as expired. No signature verification happens here — the human and `--json` paths share this core -- `verifyClaims()` / `printClaimsVerdict()` / `claimReason()` - `printClaimsVerdict` runs the core against already-parsed claims and renders `Claims: VALID`/`INVALID` with the reason, returning the `errInvalidClaims` sentinel on failure; it must be given the raw parse, not the display copy `formatTimestamps` rewrites. `verifyClaims` is the wrapper that parses the token first (and returns a hard error on an unparseable one). `claimReason` flattens the validator's newline-joined multi-error onto one `; `-separated line so the dim reason and wrapped error stay readable +- `validateClaimsSet()` / `unrepresentableTimeClaim()` / `truncateClaimValue()` - Runs the requested RFC 7519 checks against the already-parsed claims via `jwt.NewValidator` with `jwt.WithTimeFunc(timeNow)` (so the verdict shares the display clock and is deterministic under `pinTime`), returning `valid` plus a reason. Temporal claims (`exp`, `nbf`) that are present are always checked; an expected `aud`/`iss` is additionally required to be present and match. A missing `exp` is not treated as expired. No signature verification happens here — the human and `--json` paths share this core. **`unrepresentableTimeClaim` must run before the validator.** golang-jwt's `parseNumericDate` converts a claim with a `Float64` whose error it discards and then casts to `int64` (`map_claims.go`), so a value outside `int64` range wraps — typically to `math.MinInt64` — and the validator returns the *opposite* verdict: `"exp":1e400` reads as long expired, `"nbf":1e400` as long since valid, neither visible in the output because `claimTime` declines to annotate a timestamp it cannot represent. So `exp`, `nbf`, and `iat` are pre-checked through `claimTime` (output.go), in that fixed order, and an unrepresentable numeric value becomes an explicit invalid reason naming the claim. The representability rule is `claimTime`'s own, so the verdict and the display cannot drift. Only numeric values are examined — a non-numeric temporal claim is not a timestamp at all and keeps the validator's own message — and `truncateClaimValue` caps the literal quoted in the reason, which is rendered on one line +- `printClaimsVerdict()` / `claimReason()` - `printClaimsVerdict` runs the core against already-parsed claims and renders `Claims: VALID`/`INVALID` with the reason, returning the `errInvalidClaims` sentinel on failure; it must be given the raw parse, not the display copy `formatTimestamps` rewrites. Production code always holds a `parsedJWT` here, so the token-parsing wrapper lives in `helpers_test.go` (`verifyClaims`) alongside `verifySignature`. `claimReason` flattens the validator's newline-joined multi-error onto one `; `-separated line so the dim reason and wrapped error stay readable **Claim validation is display-and-exit-code only, and deliberately separate from signature verification.** It performs no cryptography and runs whether or not a key is given, so `--verify-claims` on an unverified token still reports expiry — the two verdicts (`Signature:` and `Claims:`) are shown independently and either failing exits nonzero. This keeps the pre-existing invariant that a bare decode never fails on expiry intact: nothing validates claims unless a claim flag is passed. ### `jwe.go` - JWE parsing and decryption -- `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 +- `isJWE()` / `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 three read their delimiter counts from the `jweDelimiters`/`jwtDelimiters` constants, so the string and byte forms cannot disagree. There is no string-form `isJWT`: the CLI dispatches on `isJWE` alone and everything else is handed to the JWT parser, so a JWT-shaped string predicate had no production caller. 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 — the key is passed on to `printDecryptedPayload`, so it keeps applying to whatever the plaintext turns out to be - `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 ### `keys.go` - Key loading and format detection -- `loadKey()` / `loadKeyForKID()` / `parseKeyData()` / `parseAnyDER()` / `parseDERKey()` / `parseJWK()` - Resolve `raw:` and `hmac:`, then an existing file path, then base64/base64url; parse loaded data as JWK/JWK Set, PEM, or DER (PKCS#1/PKCS#8/SEC 1/PKIX) keys and X.509 certificates, and error on anything else. `parseAnyDER` holds the one list of understood DER encodings, used both for raw DER and as `parseDERKey`'s fallback for an unrecognized PEM block type, so a format cannot be added to one and missed by the other. `parseKeyData` only detects formats: it never phrases a rejection, because `unsupportedKeyError` is the single place that knows the user-facing subject and the symmetric-form hint. Trailing newlines are trimmed only for ASCII `hmac:` files limited to printable bytes plus tab/CR/LF, while UTF-8/non-ASCII and other binary files remain byte-exact. `loadKeyForKID` threads the token's `kid` so a JWK Set selects the matching entry (`loadKey` is the `kid=""` wrapper); a `kid` that matches nothing returns the `errKIDNotFound` sentinel, which short-circuits the base64/unsupported-format fallbacks so a JWK Set miss fails closed with a clear message instead of degrading into other key material +- `loadKeyForKID()` / `parseKeyData()` / `parseAnyDER()` / `parseDERKey()` / `parseJWK()` / `jwkKey()` - Resolve `raw:` and `hmac:`, then an existing file path, then base64/base64url; parse loaded data as JWK/JWK Set, PEM, or DER (PKCS#1/PKCS#8/SEC 1/PKIX) keys and X.509 certificates, and error on anything else. `parseAnyDER` holds the one list of understood DER encodings, used both for raw DER and as `parseDERKey`'s fallback for an unrecognized PEM block type, so a format cannot be added to one and missed by the other. `parseKeyData` only detects formats: it never phrases a rejection, because `unsupportedKeyError` is the single place that knows the user-facing subject and the symmetric-form hint. Trailing newlines are trimmed only for ASCII `hmac:` files limited to printable bytes plus tab/CR/LF, while UTF-8/non-ASCII and other binary files remain byte-exact. `loadKeyForKID` threads the token's `kid` so a JWK Set selects the matching entry (production code always holds a token header and so always knows the kid, possibly `""`; the tests keep a `loadKey` wrapper in `helpers_test.go` for the cases that do not care). A `kid` that matches nothing returns the `errKIDNotFound` sentinel and empty symmetric material the `errEmptyKey` sentinel; `finalKeyError` marks both as definitive, short-circuiting the base64 retry and the unsupported-format error so understood-but-unusable material fails closed with a clear message instead of degrading into another reading — in particular, an empty `oct` JWK must not be re-offered as "pass it as `hmac:`", which would turn the JWK's own JSON text into a secret. **`jwkKey` is where every JWK path (single key, first-of-set, kid-selected entry) unwraps the key, and it routes an `oct` JWK's `[]byte` through `symmetricKey`.** Without it `{"kty":"oct","k":""}` yielded an empty HMAC key — go-jose sets a typed-nil `[]byte`, so the `jwk.Key != nil` guard passes, `validMethodsForKey` admits `HS*`, and jwt v5's HMAC `Verify` accepts an empty key — so a token forged with the empty secret verified, while `--key raw:` correctly rejected the same material. `TestLoadKeyForKID_RejectsEmptyOctJWK` and `TestVerifySignature_RejectsForgedHMACFromEmptyOctJWK` hold that shut - `unsupportedKeyError()` - Explains a rejection and names the explicit symmetric form to use, substituting the user's own path so the fix is copy-pasteable; SSH keys get a conversion hint instead -- `classifyKeyArg()` / `loadSymmetricKeyFile()` / `loadKeyFile()` / `loadInlineKey()` - `classifyKeyArg` decides which reading applies (`raw:` literal, `hmac:` secret file, existing file, base64, or unusable) and `loadKeyForKID` switches on it to call the matching loader, so the precedence exists once: the reading the CLI hint reports is by construction the reading that happens, not a mirror of it. It uses `Stat` rather than a read, so classifying never consumes the key source; a consequence is that an existing but unreadable file now reports the read error instead of falling through to a base64 attempt on its path +- `classifyKeyArg()` / `loadSymmetricKeyFile()` / `loadKeyFile()` / `loadInlineKey()` - `classifyKeyArg` decides which reading applies (`raw:` literal, `hmac:` secret file, existing file, base64, directory, or unusable) and `loadKeyForKID` switches on it to call the matching loader, so the precedence exists once: the reading the CLI hint reports is by construction the reading that happens, not a mirror of it. It uses `Stat` rather than a read, so classifying never consumes the key source; a consequence is that an existing but unreadable file now reports the read error instead of falling through to a base64 attempt on its path. A **directory** is its own `keySource` and is rejected by name ("is a directory, not a key file") rather than falling through to a base64 reading of its own path text, which reported a nonsense interpretation of a path the user clearly meant as a key file. `printKeyInterpretation` narrates only the literal and base64 readings; a directory, like any other unusable value, is left to `loadKeyForKID`'s error, so the hint and the outcome cannot disagree. Because `classifyKeyArg` has already decoded the argument before returning `keySourceBase64`, `loadInlineKey`'s decode cannot fail and carries no unreachable rejection branch - `decodeBase64Key()` / `symmetricKey()` - Decode whitespace-tolerant base64/base64url key material (applied to text key files as well as inline arguments, so the same bytes mean the same key either way) and gate symmetric secrets, rejecting empty key material - `isTextKey()` / `isSSHPublicKey()` / `isSSHKeyType()` / `sshBlobHasType()` - Distinguish ASCII text from binary for newline trimming, and detect SSH public keys for the targeted error -**Symmetric secrets are explicit, and that is the security boundary.** Before 5.0.0, key material jwtd could not parse became an HMAC secret. Any *public* key reaching that path was forgeable: a public key is a published value, so an attacker who knew its bytes could sign an HS256 token that verified. Three formats hit it in practice (OpenSSH keys, RFC 4716 armor, base64 key material in a file), but the class was open to any format nobody had thought of. Unparseable material is now an error, so no new format can reopen it — verified against PKCS#12, which was never enumerated. `isSSHPublicKey()` exists only to give a better message; it covers OpenSSH one-line keys (verified through the SSH wire-format type prefix, so a secret merely starting with `ssh-rsa` is not misread), `authorized_keys` option prefixes, and RFC 4716 armor, whose four-dash BEGIN marker is not a PEM marker. Empty key material is rejected because the empty secret is known to everyone. `keys_test.go` and `TestVerifySignature_RejectsForgedHMACFromPublishedKeyFile` in `main_test.go` hold these properties down. +**Symmetric secrets are explicit, and that is the security boundary.** Before 5.0.0, key material jwtd could not parse became an HMAC secret. Any *public* key reaching that path was forgeable: a public key is a published value, so an attacker who knew its bytes could sign an HS256 token that verified. Three formats hit it in practice (OpenSSH keys, RFC 4716 armor, base64 key material in a file), but the class was open to any format nobody had thought of. Unparseable material is now an error, so no new format can reopen it — verified against PKCS#12, which was never enumerated. `isSSHPublicKey()` exists only to give a better message; it covers OpenSSH one-line keys (verified through the SSH wire-format type prefix, so a secret merely starting with `ssh-rsa` is not misread), `authorized_keys` option prefixes, and RFC 4716 armor, whose four-dash BEGIN marker is not a PEM marker. Empty key material is rejected because the empty secret is known to everyone — that gate covers **every** symmetric form, `raw:`, `hmac:`, and an `oct` JWK alike, which is why `jwkKey` exists. `keys_test.go` and `TestVerifySignature_RejectsForgedHMACFromPublishedKeyFile` in `main_test.go` hold these properties down. Removing the fallback deleted the heuristics that existed only to decide it (`isStructuredKeyData`, `hasPEMMarker`, `hasJWKMember`, `jsonStringEnd`, `isCompleteDER`) — about 100 lines of the most error-prone code in the package. Do not reintroduce a "looks like a secret" inference to make an unsupported format work; add a parser, or let the user say `hmac:`. ### `output.go` - Formatting, escaping, and colored printing -- `printDecryptedPayload()` / `escapeTerminalText()` / `escapeFormattedJSONControls()` / `isPlainASCIIText()` / `isBelowDEL()` - 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 a byte-level shortcut that returns text needing no escaping 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. **They use different predicates, and that is deliberate.** `escapeTerminalText` requires printable ASCII (plus newline and tab) via `isPlainASCIIText`, because it does escape the C0 controls. `escapeFormattedJSONControls` uses `isBelowDEL`, which admits them: everything it rewrites is DEL or above, and its input is JSON the formatter already rendered, so with color on it always carries the ESC bytes of the formatter's own ANSI codes — reusing the stricter predicate left the fast path unused for every colored render, the interactive default, at about 6x the cost. Both scans are **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. `TestEscapeTerminalText_FastPathMatchesSlowPath` and `TestEscapeFormattedJSONControls_FastPathMatchesSlowPath` pin each shortcut as a pure optimization -- `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. Whole seconds — what every ordinary token carries — are matched against JSON's own integer grammar by `jsonIntegerSeconds` and converted with `strconv.ParseInt`. Every other form is re-validated with `json.Valid` before the exact `big.Rat` path, because a `json.Number` can hold arbitrary text and `big.Rat.SetString` accepts ratios, hex, and binary exponents that JSON does not; that path costs about thirty allocations per claim, and `json.Valid` a copy of the literal, so neither runs for an integer. What `jsonIntegerSeconds` admits is a strict subset of what `json.Valid` would, so the check it skips could not have rejected the value. `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()` / `renderNested()` / `escapeTerminalText()` / `escapeFormattedJSONControls()` / `isPlainASCIIText()` / `isBelowDEL()` - Recursively decode nested JWTs/JWEs and pretty-print JSON objects or arrays. **The key is threaded into the recursion**: a JWE wrapping another token to the same key decrypts all the way down, and a nested JWS gets a real `Signature:` verdict whose `errInvalidSignature` is propagated so the exit code reflects the innermost check. `renderNested` owns that: it buffers the nested render, treats an invalid signature as a verdict rather than a decode failure (the sections are printed, the sentinel only drives the exit), and retries **keyless** when the nested token cannot use the key at all — a different key type, or one that fails to load for it — so such a token keeps exactly the output it had before the key was threaded through. A nested token that does not decode either way falls back to the JSON/raw handling. 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 a byte-level shortcut that returns text needing no escaping 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. **They use different predicates, and that is deliberate.** `escapeTerminalText` requires printable ASCII (plus newline and tab) via `isPlainASCIIText`, because it does escape the C0 controls. `escapeFormattedJSONControls` uses `isBelowDEL`, which admits them: everything it rewrites is DEL or above, and its input is JSON the formatter already rendered, so with color on it always carries the ESC bytes of the formatter's own ANSI codes — reusing the stricter predicate left the fast path unused for every colored render, the interactive default, at about 6x the cost. Both scans are **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. `TestEscapeTerminalText_FastPathMatchesSlowPath` and `TestEscapeFormattedJSONControls_FastPathMatchesSlowPath` pin each shortcut as a pure optimization +- `formatTimestamps()` / `claimTime()` / `representableTime()` / `timestampStatus()` / `secondsBetween()` / `humanizeSeconds()` - 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. Whole seconds — what every ordinary token carries — are matched against JSON's own integer grammar by `jsonIntegerSeconds` and converted with `strconv.ParseInt`. Every other form is re-validated with `json.Valid` before the exact `big.Rat` path, because a `json.Number` can hold arbitrary text and `big.Rat.SetString` accepts ratios, hex, and binary exponents that JSON does not; that path costs about thirty allocations per claim, and `json.Valid` a copy of the literal, so neither runs for an integer. What `jsonIntegerSeconds` admits is a strict subset of what `json.Valid` would, so the check it skips could not have rejected the value. `humanizeSeconds` renders the largest whole unit (s/m/h/d), truncating toward zero for deterministic output. The difference it is given comes from `secondsBetween`, **not `time.Time.Sub`**: a `time.Duration` saturates at roughly ±292 years, so every `exp` further out than that used to be annotated with the same bogus `106751d`. `secondsBetween` subtracts Unix second counts (correcting the sub-second parts so the truncation stays toward zero), which covers the whole year range `representableTime` admits. 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()` / `style` / `newStyle()` / `escapesFor()` - `newFormatter` builds a `jsonFormatter` (see `formatter.go`) from the project color scheme. Each color in that scheme is a `style`, which resolves the ANSI sequences it wraps text in **once** instead of on every use: `fatih/color` rebuilds both sequences with `fmt.Sprintf` per call, which is affordable for a handful of labels but not for a token of JSON. `escapesFor` reads the sequences out of the color package by having it wrap a sentinel byte and splitting on it — the only way to reach sequences it keeps unexported, and the reason jwtd cannot drift from its reset handling. It captures **two** closing sequences, because the library uses two: the `Sprint` family closes with a reset per attribute, the `Fprintf` family with a plain reset around the whole write. `TestStyleMatchesColorPackage` compares both against the library, in both color states; it is what caught the two being conflated - `printSection()` / `printSignature()` / `printVerdict()` / `writeFormattedJSON()` - Formatted output through the styles above. `writeFormattedJSON` writes already-formatted JSON straight to the writer when nothing needs escaping, so the ordinary case does not copy output that can be megabytes. `printSection` takes any JSON-marshalable value, so objects and arrays share one path; `printVerdict` renders the `