From fa598c0a6ccd7a1db5539a5c5b69a59248013376 Mon Sep 17 00:00:00 2001 From: David Henning Date: Thu, 27 Aug 2026 21:09:14 +0200 Subject: [PATCH 1/6] refactor: remove duplicated logic across decode, key, and output paths Extract parseAnyDER for the DER parser cascade that was written out twice, and drop parseKeyData's unreachable SSH branch so unsupportedKeyError is the only place that phrases a rejection. Share the JWE part splitting and sizing between the human and --json paths, collapse printEncryptedParts' four copy-pasted blocks into a loop, and add printVerdict for the VALID/INVALID rendering the signature and claim checks had in duplicate. The verdicts stay independent; only the rendering is shared. Decode the decrypted payload once and type-switch instead of attempting an object parse and then an array parse, widening printSection to any so the array case stops re-implementing it. Add isJWT next to isJWE so token-shape dispatch has one definition per form, and give the escape helpers a fast path so unescaped output allocates nothing. In tests, delete signHS256 (a twin of signJWTWithHMAC), collapse six near-identical "unparseable key must not become an HMAC secret" tests into one table keeping every input, and move the cross-file pinTime and decodeJWTJSONMap fixtures into helpers_test.go per AGENTS.md. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Q26LwBBSBPUpbf1kd4Ep3z --- AGENTS.md | 8 +-- claims.go | 12 ++-- claims_test.go | 20 +++--- helpers_test.go | 42 +++++++++---- jsonout.go | 4 +- jsonout_test.go | 18 +----- jwe.go | 49 +++++++++------ keys.go | 65 +++++++++----------- main.go | 8 +-- main_test.go | 157 ++++++++---------------------------------------- output.go | 65 +++++++++++++------- output_test.go | 9 --- 12 files changed, 179 insertions(+), 278 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8ebe883..c3c511d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,13 +36,13 @@ All functionality lives in package `main`, split across six source files: ### `jwe.go` - JWE parsing and decryption -- `isJWE()` - Detects JWE compact serialization (5 dot-separated parts vs. 3 for a JWT) +- `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 - `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 -- `printEncryptedParts()` / `partSize()` - Encrypted part metadata shown when no key is provided +- `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()` / `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; trim trailing newlines 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 +- `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 - `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()` - Reports which reading `loadKey` will apply (`raw:` literal, `hmac:` secret file, existing file, base64, or unusable), mirroring its precedence so the CLI hint cannot drift from actual behavior; uses `Stat` rather than a read, so classifying never consumes the key source - `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 @@ -57,7 +57,7 @@ Removing the fallback deleted the heuristics that existed only to decide it (`is - `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 - `newFormatter()` - Creates a `go-prettyjson` formatter with the project color scheme -- `printSection()` / `printSignature()` - Formatted output using `fatih/color` +- `printSection()` / `printSignature()` / `printVerdict()` - Formatted output using `fatih/color`. `printSection` takes any JSON-marshalable value, so objects and arrays share one path; `printVerdict` renders the `