Skip to content

fix: close code-review findings on keys, claims, and output - #32

Merged
bsg62 merged 6 commits into
mainfrom
fix/review-findings-2
Sep 9, 2026
Merged

bsg62 merged 6 commits into
mainfrom
fix/review-findings-2

Conversation

@bsg62

@bsg62 bsg62 commented Sep 8, 2026

Copy link
Copy Markdown
Member

Summary

Closes the fourteen findings from the /code-review medium pass over the code base. Every finding was confirmed against the built binary before and after the fix; each has a test.

Security and correctness

  • Empty oct JWK (keys.go): a JWK or JWK Set entry with "k":"" became an empty HMAC key and verified a forged HS256 token. A new jwkKey helper routes every JWK unwrap through symmetricKey, and the errEmptyKey sentinel stops the rejection from degrading into the base64 fallback and its hmac:<file> hint.
  • Non-string kid (main.go, jwe.go, jsonout.go): a header with "kid":123 or "kid":null collapsed to "" and silently selected the first JWK Set key. headerKID now returns an error (token header "kid" must be a string (RFC 7515)), propagated at all call sites.
  • Overflowing temporal claims (claims.go): golang-jwt discards the Float64 error and casts to int64, so "exp":1e400 reported expired and "nbf":1e400 reported valid. exp/nbf/iat are pre-checked through the display's own claimTime, and an unrepresentable value is an explicit Claims: INVALID reason.
  • Nested tokens ignored --key (output.go, jwe.go): printDecryptedPayload recursed with an empty key. The outer key is now threaded through, a nested JWE decrypts, a nested JWT gets a signature verdict, and errInvalidSignature from it reaches the exit code. A key that does not fit the inner token falls back to the previous keyless render.
  • null header or payload (main.go): rendered as {} with Claims: VALID. Both are rejected with expected JSON object, the guard jweProtectedHeaderMap already had.

CLI and output

  • The human JWT path resolves the key before writing anything, matching the JWE and --json paths (printSignatureVerdict takes a loaded key; verifyLoadedKeySignature is the core).
  • jwtd "" reports no token provided; piped stdin is bounded by maxStdinTokenBytes (16 MiB) with an explicit error instead of silent truncation.
  • A directory passed as --key errors as such (keySourceDirectory) instead of being read as base64 text.
  • --json emits "decryptedPayload": null for a null plaintext and omits the field only without a key (*any instead of any with omitempty).
  • exp/nbf annotations are computed from Unix seconds (secondsBetween / humanizeSeconds), so far-future values no longer saturate at time.Duration's 106751d.
  • base64URLLen measures arithmetically instead of decoding the ciphertext; a test pins it equal to a full decode over valid and invalid inputs.

Cleanup

  • isJWT, verifyClaims, and loadKey left production code (the latter two moved to helpers_test.go); the unreachable !ok branch in loadInlineKey is gone.
  • AGENTS.md updated throughout, including the golang-jwt dependency row and a test-only gopkg.in/yaml.v3 row.

Test plan

  • gofmt -l ., go vet ./..., go test ./... green
  • Each repro from the review rerun against the merged binary
  • CI

🤖 Generated with Claude Code

https://claude.ai/code/session_0125xMARDzdnbkbMTbNxSihH

bsg62 and others added 6 commits September 8, 2026 22:46
Six confirmed review findings:

- Pre-check exp/nbf/iat through claimTime before the golang-jwt validator.
  parseNumericDate discards the Float64 error and casts to int64, so a value
  outside int64 range wrapped and produced the opposite verdict ("nbf":1e400
  reported Claims: VALID). An unrepresentable timestamp is now an explicit
  invalid reason naming the claim, shared by the human and --json paths.
- Reject a null JWT header or payload with "expected JSON object", the guard
  the JWE protected header already had. A null payload previously rendered as
  {} with a VALID claim verdict.
- Resolve the key before printParsedJWT writes anything, so a bad --key emits
  nothing on stdout. Verification splits into verifyLoadedKeySignature (the
  core, taking a loaded key) and verifyJWTSignature (the key-argument wrapper
  the --json path uses); the alg allowlist stays in the core.
- Report an empty or whitespace-only token argument as "no token provided"
  instead of as a malformed token.
- Bound the stdin read with io.LimitReader at maxStdinTokenBytes (16 MiB) and
  fail with a clear message instead of truncating.
- Remove the production-dead isJWT and verifyClaims; verifyClaims moves to
  helpers_test.go beside verifySignature.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125xMARDzdnbkbMTbNxSihH
Four confirmed review findings around key resolution:

- An "oct" JWK bypassed the empty-secret gate: parseJWK returned jwk.Key
  directly, so {"kty":"oct","k":""} yielded an empty HMAC key and a token
  forged with the empty secret verified. Every JWK path now unwraps through
  jwkKey, which routes []byte through symmetricKey. errEmptyKey joins
  errKIDNotFound as a final verdict (finalKeyError), so the rejection cannot
  degrade into a base64 retry or the misleading "pass it as hmac:<file>" hint.
- headerKID collapsed a non-string "kid" to "", which parseJWK read as "no kid
  named" and answered with the first JWK Set entry. RFC 7515 requires a string,
  so a present non-string kid is now errNonStringKID; the JWT, --json, and JWE
  call sites all propagate it.
- A directory passed as --key was misreported as base64 key material. It gets
  its own keySource and a clear "is a directory, not a key file" error;
  printKeyInterpretation still narrates only the readings that actually apply.
- Removed the production-dead loadKey wrapper (kept as a test helper in
  helpers_test.go) and loadInlineKey's unreachable decode-failure branch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125xMARDzdnbkbMTbNxSihH
- thread the key through printDecryptedPayload into the nested JWE/JWT
  decode, so --key keeps applying one level down: a JWE wrapping another
  token to the same key now decrypts all the way, and a nested JWS gets a
  real Signature verdict whose errInvalidSignature drives the exit code.
  A nested token the key does not fit is retried keyless, keeping its
  previous output.
- emit "decryptedPayload": null in --json for a JWE whose plaintext is the
  JSON literal null; it was a nil interface that omitempty dropped, leaving
  neither encrypted nor decryptedPayload in the object.
- compute exp/nbf annotations from Unix seconds instead of time.Time.Sub,
  whose Duration saturates at ~292 years and rendered every distant claim
  as the same bogus 106751d.
- measure a base64url part arithmetically instead of decoding a possibly
  huge ciphertext just to report its length.
- AGENTS.md: describe the changed behaviour, correct the golang-jwt row
  (the segment work is parseUnverifiedJWT's, not jwt.ParseUnverified's),
  and add the test-only gopkg.in/yaml.v3 dependency.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125xMARDzdnbkbMTbNxSihH
@bsg62
bsg62 force-pushed the fix/review-findings-2 branch from 06ef804 to f52fe27 Compare September 9, 2026 19:43
@bsg62
bsg62 merged commit 5c47874 into main Sep 9, 2026
8 checks passed
@bsg62
bsg62 deleted the fix/review-findings-2 branch September 9, 2026 19:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant