Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<pid>/cmdline` is world-readable, `/proc/<pid>/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 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
Expand All @@ -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

Expand All @@ -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 `<label>: VALID`/`INVALID` line with its dim reason, shared by the independent signature and claim verdicts so they cannot drift apart visually

Expand Down
2 changes: 1 addition & 1 deletion jsonout.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func decodeJWTJSON(w io.Writer, tokenStr, keyStr string, checks claimChecks) err
}

out := jwtJSON{
Header: p.token.Header,
Header: p.header,
Payload: map[string]any(p.claims),
Signature: p.parts[2],
}
Expand Down
24 changes: 22 additions & 2 deletions jwe.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"bytes"
"encoding/base64"
"fmt"
"io"
Expand Down Expand Up @@ -41,17 +42,36 @@ var allContentEncryptions = []jose.ContentEncryption{
jose.A256GCM,
}

// Compact serializations are told apart by their delimiter count: a JWE has
// five parts, a JWS/JWT three. The counts live here so the string and byte
// forms of the predicates below cannot disagree about the shape rule.
const (
jweDelimiters = 4
jwtDelimiters = 2
)

// isJWE returns true if the token string looks like a JWE compact serialization
// (5 dot-separated parts) rather than a JWT (3 parts).
func isJWE(token string) bool {
return strings.Count(token, ".") == 4
return strings.Count(token, ".") == jweDelimiters
}

// isJWT returns true if the token string looks like a JWS/JWT compact
// serialization (3 dot-separated parts). It is the counterpart of isJWE, so
// token-shape dispatch has one definition per form.
func isJWT(token string) bool {
return strings.Count(token, ".") == 2
return strings.Count(token, ".") == jwtDelimiters
}

// isJWEBytes and isJWTBytes are the byte forms, used where the candidate is a
// decrypted payload that may be large: testing its shape must not cost a full
// copy of it just to reach the string predicates.
func isJWEBytes(token []byte) bool {
return bytes.Count(token, []byte(".")) == jweDelimiters
}

func isJWTBytes(token []byte) bool {
return bytes.Count(token, []byte(".")) == jwtDelimiters
}

// decodeAndPrintJWE parses a JWE token and prints its contents.
Expand Down
117 changes: 83 additions & 34 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import (
"crypto/ecdsa"
"crypto/ed25519"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"os"
"slices"
"strings"

"github.com/chzyer/readline"
Expand Down Expand Up @@ -259,7 +261,7 @@ func decodeAndPrint(w io.Writer, tokenStr, keyStr string) error {
func printParsedJWT(w io.Writer, p *parsedJWT, keyStr string) error {
f := newFormatter()

if err := printSection(w, f, "Header", p.token.Header); err != nil {
if err := printSection(w, f, "Header", p.header); err != nil {
return err
}
if _, err := fmt.Fprintln(w); err != nil {
Expand Down Expand Up @@ -291,39 +293,46 @@ func printParsedJWT(w io.Writer, p *parsedJWT, keyStr string) error {
return nil
}

// parsedJWT is one strict decode of a compact JWT: the token with its
// display-decoded header, its three segments, and its claims. It is threaded
// through the decode, signature, and claim steps so a single run parses the
// token once rather than once per step.
// parsedJWT is one strict decode of a compact JWT: its three segments, the
// display-decoded header and claims, and the signing method and decoded
// signature bytes the verification step needs. It is threaded through the
// decode, signature, and claim steps so a single run parses the token once
// rather than once per step.
type parsedJWT struct {
raw string
token *jwt.Token
parts []string
claims jwt.MapClaims
header map[string]any
parts []string
claims jwt.MapClaims
method jwt.SigningMethod
signature []byte
}

// parseUnverifiedJWT decodes a compact JWT without checking its signature.
//
// It does the segment work itself rather than calling jwt.ParseUnverified,
// which JSON-decodes the header and claims with plain json.Unmarshal — losing
// number precision and accepting trailing data — so every one of its decodes
// would be thrown away and redone strictly here. The remaining steps mirror
// ParseUnverified exactly, including its rejection of a token whose "alg" is
// missing or unknown, so the set of tokens jwtd decodes is unchanged.
func parseUnverifiedJWT(tokenStr string) (*parsedJWT, error) {
parser := jwt.NewParser(jwt.WithJSONNumber())
token, parts, err := parser.ParseUnverified(tokenStr, jwt.MapClaims{})
if err != nil {
return nil, fmt.Errorf("parsing JWT: %w", err)
parts, ok := splitCompactJWT(tokenStr)
if !ok {
return nil, fmt.Errorf("parsing JWT: %w: token contains an invalid number of segments", jwt.ErrTokenMalformed)
}

headerData, err := parser.DecodeSegment(parts[0])
headerData, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, fmt.Errorf("parsing JWT header: decoding header: %w", err)
}

// Re-decode the header for display with the same strictness as the
// claims: exact json.Number values and no trailing data. ParseUnverified
// decodes it with plain json.Unmarshal, which loses number precision.
// The header is decoded with the same strictness as the claims: exact
// json.Number values and no trailing data.
header := map[string]any{}
if err := decodeJSON(headerData, &header); err != nil {
return nil, fmt.Errorf("parsing JWT header: %w", err)
}
token.Header = header

payload, err := parser.DecodeSegment(parts[1])
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("parsing JWT claims: decoding payload: %w", err)
}
Expand All @@ -332,7 +341,44 @@ func parseUnverifiedJWT(tokenStr string) (*parsedJWT, error) {
if err := decodeJSON(payload, &claims); err != nil {
return nil, fmt.Errorf("parsing JWT claims: %w", err)
}
return &parsedJWT{raw: tokenStr, token: token, parts: parts, claims: claims}, nil

alg, ok := header["alg"].(string)
if !ok {
return nil, fmt.Errorf("parsing JWT: %w: signing method (alg) is unspecified", jwt.ErrTokenUnverifiable)
}
method := jwt.GetSigningMethod(alg)
if method == nil {
return nil, fmt.Errorf("parsing JWT: %w: signing method (alg) is unavailable", jwt.ErrTokenUnverifiable)
}

signature, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return nil, fmt.Errorf("parsing JWT: %w: could not base64 decode signature: %w", jwt.ErrTokenMalformed, err)
}

return &parsedJWT{
header: header,
parts: parts,
claims: claims,
method: method,
signature: signature,
}, nil
}

// splitCompactJWT splits a compact serialization into its three segments,
// reporting false unless there are exactly two delimiters. A token carrying
// extra delimiters is rejected rather than truncated, matching
// jwt.ParseUnverified.
func splitCompactJWT(tokenStr string) ([]string, bool) {
header, remain, ok := strings.Cut(tokenStr, ".")
if !ok {
return nil, false
}
claims, signature, ok := strings.Cut(remain, ".")
if !ok || strings.Contains(signature, ".") {
return nil, false
}
return []string{header, claims, signature}, true
}

// printSignatureVerdict renders the signature verdict for an already-parsed
Expand Down Expand Up @@ -360,27 +406,30 @@ func printSignatureVerdict(w io.Writer, p *parsedJWT, keyStr string) error {
// hard failures (unparseable token, unusable key) that are not a verdict on the
// signature itself.
func verifyJWTSignature(p *parsedJWT, keyStr string) (valid bool, reason error, err error) {
key, err := loadKeyForKID(keyStr, headerKID(p.token.Header))
key, err := loadKeyForKID(keyStr, headerKID(p.header))
if err != nil {
return false, nil, fmt.Errorf("error loading key: %w", err)
}

// Extract the public key from private keys for verification.
key = publicKeyForVerification(key)

// Claims validation is disabled so the result reflects only the
// cryptographic signature, not token expiry. Accepted algorithms are
// restricted to those compatible with the key type to rule out
// algorithm confusion.
opts := []jwt.ParserOption{jwt.WithoutClaimsValidation(), jwt.WithJSONNumber()}
if methods := validMethodsForKey(key); methods != nil {
opts = append(opts, jwt.WithValidMethods(methods))
}
parser := jwt.NewParser(opts...)
if _, perr := parser.Parse(p.raw, func(*jwt.Token) (any, error) {
return key, nil
}); perr != nil {
return false, perr, nil
// Accepted algorithms are restricted to those compatible with the key type
// to rule out algorithm confusion. This check is what jwt.WithValidMethods
// would do inside jwt.Parse; verifying from the already-parsed token means
// it has to be spelled out here, and it must stay ahead of the Verify call
// below — without it an HS256 token signed with a published public key
// verifies against that key.
alg := p.method.Alg()
if methods := validMethodsForKey(key); methods != nil && !slices.Contains(methods, alg) {
return false, fmt.Errorf("%w: signing method %v is invalid", jwt.ErrTokenSignatureInvalid, alg), nil
}

// Only the signature is checked here: the claims are never consulted, so
// the verdict reflects the cryptography alone and not token expiry.
signingInput := p.parts[0] + "." + p.parts[1]
if verr := p.method.Verify(signingInput, p.signature, key); verr != nil {
return false, fmt.Errorf("%w: %w", jwt.ErrTokenSignatureInvalid, verr), nil
}
return true, nil, nil
}
Expand Down
Loading