diff --git a/AGENTS.md b/AGENTS.md index 8ebe883..875dfdd 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()` - Parses the JWT with `golang-jwt/jwt` (`ParseUnverified`) and orchestrates output; verifies the signature when a key is provided -- `parseUnverifiedJWT()` / `decodeJSON()` - Strictly decode the header, claims, and other displayed JSON with exact `json.Number` values and reject malformed or trailing JSON data -- `verifyJWTSignature()` / `verifySignature()` - `verifyJWTSignature` 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` (unparseable token/unusable key). `verifySignature` 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 +- `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 - `publicKeyForVerification()` - Extracts the public key from RSA/ECDSA/Ed25519 private keys ### `jsonout.go` - Machine-readable `--json` output @@ -30,21 +30,21 @@ All functionality lives in package `main`, split across six source files: - `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()` / `claimReason()` - `verifyClaims` parses the token, calls the core, and renders `Claims: VALID`/`INVALID` with the reason, returning the `errInvalidClaims` sentinel on failure (and a hard error on an unparseable token). `claimReason` flattens the validator's newline-joined multi-error onto one `; `-separated line so the dim reason and wrapped error stay readable +- `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 **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()` - 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 +- `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 - `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 @@ -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 `