blob: c6632c09556f8a935a011c388f470f64eb334622 [file] [view]
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
LibJWT is a C library implementing RFC 7515-7519 for JSON Web Tokens (JWT), JSON Web Keys (JWK), and JWK Sets (JWKS). It supports multiple cryptographic backends (OpenSSL, GnuTLS, MBedTLS) and JSON backends (Jansson, json-c).
## Issue Workflow
Follow this pipeline for every piece of work:
1. **Issue** Work must map to a GitHub issue. If none exists, create one first describing the task.
2. **Branch** `git checkout -b <issue#>-<short-summary>` (e.g. `157-crit-header`). Never work on `master`.
3. **Plan** Plan the implementation, then post the plan as a comment on the issue.
4. **Implement** Do the work, including tests for all new code (see Testing) and coverage (no new uncovered lines). Verify under both JSON backends when JSON handling is touched.
5. **PR** Open a PR (`closes #<issue>`), then watch CI to completion (`gh pr checks <#> --watch` or the GitHub check-runs API) and fix any failures.
Commit messages: `git commit -s` (Signed-off-by). Don't commit/push unless asked.
## Release Process
A release is a distinct workflow from the per-issue pipeline above: it needs **no
issue** (the release PR is the artifact). Mirror the most recent release
commit/PR/tag (`git show v3.5.0`, PR #304) when in doubt.
1. **Version + SONAME** — `cmake/LibJWTVersions.cmake` is the single source of truth
(there is no NEWS/changelog file). Bump `LIBJWT_VERSION_SET` (e.g. `3 4 0` ->
`3 5 0`) and the libtool SONAME triple `LIBJWT_SO_CRA` (current:revision:age). The
triple is **symbol-counted, not feature-counted**: per the [libtool
rules](http://www.gnu.org/software/libtool/manual/html_node/Updating-version-info.html),
build the library at the prior tag and at HEAD, `nm` each shared library, and diff
the exported `jwt*`/`jwk*`/`jwe*` symbol set.
- No symbols added/removed/changed (only source changed) -> **revision++**
(current/age untouched). New `jwt_alg_t`/`jwk_key_type_t` enumerators and
`#cmakedefine` macros are **not** linker symbols, so an enum-only addition is
still revision-only — v3.5.0 added `JWT_ALG_ML_DSA_*` / `JWK_KEY_TYPE_AKP` +
`LIBJWT_HAVE_ML_DSA` yet went `17:0:3` -> `17:1:3`.
- Symbols added, none removed/changed -> `current++, revision=0, age++` (v3.4.0:
38 new JWE/JWK symbols, `16:7:2` -> `17:0:3`).
- Any symbol removed or changed -> `current++, revision=0, age=0` (SONAME-major
break). SONAME major = `current - age`, and has stayed `14` across all of 3.x.
The **same** symbol/enum/macro diff also drives the Doxygen `@since` tags in
`include/jwt.h` — maintain them in this same review (the baseline
`@since 3.0.0`..`3.5.0` pass was PR #305). Anything new at the release — a
function, typedef/enum/struct, enum value, documented macro, or
`*_freep`/`*_auto_t` helper — must carry `@since X.Y.Z` for the version being
released. Note the asymmetry with the triple: new enum values and `#cmakedefine`
macros are **not** linker symbols (so they don't move `current`/`age`) but they
**still** need `@since` e.g. v3.5.0's `JWT_ALG_ML_DSA_*` / `JWK_KEY_TYPE_AKP`
are `@since 3.5.0` despite the revision-only bump.
2. **Branch + commit** — `release-X.Y.Z` off master; edit **only**
`LibJWTVersions.cmake` (plus any now-stale doc note, as v3.4.0 dropped the "v3
overhaul" warning). `git commit -s` titled `Release vX.Y.Z` with a body documenting
the SONAME-bump rationale. Smoke-test locally (`make check`).
3. **PR + merge** — `gh pr create --base master --title "Release vX.Y.Z"` (no
`closes`); watch CI to green, then `gh pr merge --merge --delete-branch` (merge
commit; release branches are deleted after merge).
4. **Tag** — annotated, on the merge commit, message = the bare version:
`git tag -a vX.Y.Z -m "vX.Y.Z" <merge-commit> && git push origin vX.Y.Z` (tagger is
`git config user.*` = `Ben Collins <bcollins@libjwt.io>`).
5. **Source tarball** — built **manually**, NOT via CPack (despite
`cmake/CPackConfig.cmake`). Recipe: every git-tracked file at the tag **minus
`.github/` and `.gitignore`** (the only "repo-related artifacts" stripped; `.git/`
isn't tracked), rooted under `libjwt-X.Y.Z/`. `CLAUDE.md` and `images/` ARE included.
```bash
git archive --format=tar --prefix=libjwt-X.Y.Z/ vX.Y.Z \
-- . ':(exclude).github' ':(exclude).gitignore' | xz -9 -c > libjwt-X.Y.Z.tar.xz
```
6. **GitHub Release** one published Release per version, marked Latest, with the
tarball attached: `gh release create vX.Y.Z --title "vX.Y.Z" --notes-file NOTES
--latest --verify-tag libjwt-X.Y.Z.tar.xz`. Notes use emoji section headers and end
with `**Full Changelog**: https://github.com/benmcollins/libjwt/compare/vPREV...vX.Y.Z`.
Tagging and publishing the GitHub Release are the maintainer's call and require CI
green first.
## Build Commands
```bash
# Standard build
mkdir build && cd build
cmake ..
make
# Build with options
cmake -DWITH_GNUTLS=ON -DWITH_MBEDTLS=ON -DWITH_LIBCURL=ON -DWITH_JSON_C=ON ..
# Switching backends in an existing build/ resets unspecified flags (e.g.
# ENABLE_COVERAGE -> OFF). Re-pass all flags and `make clean` after switching,
# since stale .gcno/.gcda from the prior backend linger.
# Only `/build/**` is gitignored. Name throwaway build trees `build/...` or
# remove them before `git add -A` — a stray `build-foo/` will otherwise be
# staged and committed.
# Run all tests
make check
# Run a single test (from build directory)
ctest -R jwt_builder -V
# Run tests with valgrind memory checking
ctest -T memcheck
# Code coverage (must configure with ENABLE_COVERAGE)
cmake -DENABLE_COVERAGE=YES ..
make check-code-coverage
All new code MUST be covered by tests cases. Do not commit ANY code
changes without running coverage first, and ensure there are no new
missing lines covered.
# NOTE: `make check-code-coverage` always exits non-zero — its final genhtml
# step fails ("no valid records"). The lcov capture still succeeds; read
# per-line coverage from build/check-code-coverage.capture (lcov DA: records).
# In that file, jwt-common.c appears ~4x (builder/checker x lib/test-harness);
# the real library coverage is the jwt.dir record, not the per-filename union.
#
# Code that is #ifdef'd out (e.g. an off-by-default feature) is invisible to
# gcov, so it never counts as uncovered. Enabling such a feature in a
# coverage/Codecov job makes its lines visible — only do that if a test
# actually exercises them, or codecov/patch will drop. Genuinely unreachable
# defensive branches use // LCOV_EXCL_LINE or // LCOV_EXCL_START/STOP.
# Build documentation (requires Doxygen >= 1.9.8)
# Docs are built automatically if Doxygen is found
```
**Doc encoding.** Doxygen processes `include/jwt.h` (public-header doc comments)
and `doxygen/mainpage.dox`. `DOXYGEN_INPUT_ENCODING` in
`cmake/LibJWTDoxyfile.cmake` is **UTF-8** to match the UTF-8 sources, so
non-ASCII (em-dashes, curly quotes, symbols) renders correctly in the generated
HTML. It was previously `ISO-8859-1`, which decoded the UTF-8 bytes as Latin-1
and produced mojibake (a UTF-8 em-dash showed up as an `a`-prefixed
box-character blob); do not revert it.
## Key CMake Options
| Option | Default | Description |
|--------|---------|-------------|
| `WITH_JSON_C` | OFF | Use json-c instead of Jansson |
| `WITH_OPENSSL` | ON | Enable OpenSSL backend |
| `WITH_GNUTLS` | auto-detect | Enable GnuTLS backend |
| `WITH_MBEDTLS` | OFF | Enable MBedTLS backend |
| `WITH_LIBCURL` | OFF | Enable libcurl for remote JWKS |
| `WITH_TESTS` | ON | Build test suite |
| `WITH_KCAPI_MD` | OFF | Linux Kernel Crypto API for HMAC |
| `ENABLE_COVERAGE` | OFF | LCOV code coverage |
| `EXCLUDE_DEPRECATED` | OFF | Exclude deprecated API |
| `WITH_ML_DSA` | OFF | Experimental ML-DSA (FIPS 204/RFC 9964); needs OpenSSL >= 3.5 or GnuTLS >= 3.8.10 |
## Architecture
### Abstraction Layers
The library has two key abstraction interfaces that allow swapping implementations:
1. **Crypto backend abstraction** (`jwt-crypto-ops.c`, `jwt-private.h:jwt_crypto_ops`): Each crypto provider (OpenSSL, GnuTLS, MBedTLS) implements the `jwt_crypto_ops` struct with function pointers for sign/verify, JWK parsing (`process_*`), and native-key→JWK conversion (`key2jwk_params`, assembled by the common `jwk-export.c`). All three backends are optional and interchangeable; the build requires at least one but works with any combination (default `WITH_OPENSSL=ON`). A GnuTLS-only build (no OpenSSL) requires GnuTLS >= 3.8.4, since older GnuTLS has no native JWK/JWE path and falls back to OpenSSL. The MbedTLS backend targets the MbedTLS **3.6.x LTS** API (`mbedcrypto>=3.6.0`); MbedTLS **4.x will not build** — its PSA rewrite drops the legacy `mbedtls_pk_*` API the backend uses — so pin 3.6.x (the CI image builds it from source for this reason). The active provider is selected at runtime via `jwt_set_crypto_ops()`; the default is the first compiled backend (OpenSSL > GnuTLS > MBedTLS).
2. **JSON backend abstraction** (`jwt-json-ops.h`): Jansson and json-c each implement the same JSON operations interface. Selected at compile time via `WITH_JSON_C`.
- **json-c asserts (aborts)** where Jansson returns gracefully: e.g. `json_object_array_length()`/`_get_idx()` abort on a non-array (Jansson returns 0/NULL). Type-check in the json-c wrapper. Test any JSON handling under BOTH backends.
- `jwt_json_obj_set`/`jwt_json_arr_append` STEAL the value reference in both backends — never free what you set/append.
### Experimental & version-gated features (ML-DSA)
ML-DSA (FIPS 204 / RFC 9964 post-quantum signatures `ML-DSA-44/65/87`, JWK
`kty="AKP"`) is the template for an optional, off-by-default, version-gated
feature:
- Gated behind `WITH_ML_DSA` (default OFF). CMake sets `LIBJWT_HAVE_ML_DSA` only
when a *capable* backend is present (OpenSSL >= 3.5 **or** GnuTLS >= 3.8.10).
That macro is emitted into the public `jwt_export.h` via `#cmakedefine` in
`include/jwt_export.h.in`, so the same macro gates library and downstream code.
- The `jwt_alg_t` / `jwk_key_type_t` enum values exist **unconditionally** (ABI
stability); only recognition (`jwt_str_alg`), dispatch, the `jwt_alg_required_kty`
anti-confusion gate, and backend code are gated. The CLI tools' `-l` loops skip
algs whose `jwt_alg_str()` returns NULL so a compiled-out alg isn't listed.
- Each backend's ML-DSA code carries its OWN version guard in addition to the
macro — `#if defined(LIBJWT_HAVE_ML_DSA) && OPENSSL_VERSION_NUMBER >= 0x30500000L`
(OpenSSL) / `&& GNUTLS_VERSION_NUMBER >= 0x03080a` (GnuTLS) — so a multi-backend
build with one capable and one too-old backend still compiles.
- AKP keys: `pub` = encoded public key, `priv` = the **32-byte FIPS-204 seed**
(not the expanded key), `alg` REQUIRED, no `crv`. The variant is pinned to the
key at sign time to prevent algorithm confusion. A private key with no seed
(expanded-only) cannot be a private AKP JWK; both backends downgrade it to a
public export rather than emit a broken key.
- **GnuTLS** needs a build with a PQC provider (`--with-leancrypto`); a stock
GnuTLS returns `-106` at runtime. There is no raw ML-DSA import, so
`gnutls/jwk-parse.c` hand-builds SubjectPublicKeyInfo / seed-PKCS#8 DER and
imports that; `gnutls_x509_privkey_export2_pkcs8(..., GNUTLS_PKCS_MLDSA_SEED,
...)` SEGFAULTS on a seedless key, so export probes for the seed via a plain
PKCS#8 export first.
**Dynamic (runtime) version gating.** When the defect is in the *shared* crypto
library rather than libjwt, gate on the RUNTIME version so a library upgrade
fixes it without rebuilding libjwt. Use a hybrid: a build-time
`#if GNUTLS_VERSION_NUMBER < 0xMMmmpp` (keeps the code absent — and
coverage-invisible — when built against a fixed version) wrapping a runtime
`gnutls_check_version("M.m.p")` check that decides per call. `gnutls/jwk-parse.c`
(`gnutls_process_eddsa`) rejects two **GnuTLS < 3.8.13** OKP defects this way:
(1) `gnutls_privkey_import_ecc_raw()` **SEGFAULTS** deriving the public key for a
*seed-only* OKP private JWK — `d` present, **no `x`** — for any curve
(Ed25519/Ed448/X25519/X448); (2) X25519/X448 **ECDH-ES** derive yields nothing.
Keys that carry `x`, all public keys, and the PEM/DER path (whose JWK export
always includes `x`) are unaffected, as are OpenSSL and MbedTLS. A library must
*error*, not crash, on a key it can't handle.
### Source Organization
- `libjwt/` Core library source
- `jwt.c` Main JWT creation, parsing, and lifecycle
- `jwt-common.c` Shared builder/checker logic (compiled twice with `-DJWT_BUILDER` and `-DJWT_CHECKER` preprocessor flags via custom CMake commands)
- `jwt-setget.c` Claims get/set operations using `jwt_value_t`
- `jwt-verify.c` JWT signature verification
- `jwt-encode.c` JWT encoding/signing
- `jwks.c` JWK/JWKS parsing and management
- `jwt-private.h` Internal structures (`jwt`, `jwk_set`, `jwk_item`, `jwt_crypto_ops`)
- `openssl/`, `gnutls/`, `mbedtls/` Crypto backend implementations
- `jansson/`, `json-c/` JSON backend implementations
- `include/jwt.h` Complete public API (the only public header)
- `tools/` CLI utilities: `jwt-generate`, `jwt-verify`, `jwk2key`, `key2jwk`
- `tests/` Check framework unit tests and BATS integration tests
### Key Patterns
- **Memory management**: Custom allocators (`jwt_malloc`, `__jwt_freemem`) with GCC `__attribute__((cleanup()))` auto-cleanup via `jwt_auto_t`, `jwt_builder_auto_t`, `jwt_checker_auto_t`, `jwt_json_auto_t` typedefs.
- **Error handling**: Objects carry `error` flag and `error_msg[256]` buffer. Use `jwt_write_error()` macro for consistent error reporting.
- **Builder/Checker pattern**: `jwt_builder_t` creates tokens, `jwt_checker_t` verifies them. Both share `jwt_common` struct internals. Support callback-based claim generation/verification.
- **Linked list**: Internal `ll.h` provides a doubly-linked list for JWK sets.
## Testing
Tests use the [Check](https://libcheck.github.io/check/) C unit testing framework. Each test file defines a `libjwt_suite()` function and uses the `JWT_TEST_MAIN()` macro.
- Tests iterate over all compiled crypto providers using the `jwt_test_ops[]` array and `SET_OPS()` macro via Check's loop tests (`_i` index variable).
- Test keys are in `tests/keys/` — referenced via the `KEYDIR` compile-time macro.
- A constant timestamp `TS_CONST` (1475980545L) is used for reproducible time-based tests.
- BATS tests in `tests/jwt-cli.bats` cover the CLI tools.
- For features that depend on the runtime (not just compile-time) capability of a
backend, probe at runtime and skip incapable backends rather than hardcoding
which provider supports what — e.g. `mldsa_supported()` in `jwt_mldsa.c` loads a
known AKP key under the active ops, so the success tests run on OpenSSL and a
PQC-enabled GnuTLS and skip a GnuTLS without leancrypto (and MbedTLS).
`gnutls_okp_jwk_broken()` in `jwt_tests.h` is a second example — it mirrors the
library's hybrid build/runtime gate (`gnutls_check_version("3.8.13")`) to skip
the OKP keys an old GnuTLS can't load, so the suite passes on GnuTLS 3.8.9 and
3.8.13 alike.
## Continuous Integration
`.github/workflows/build-and-test.yml`. Codecov is **informational only** (never
fails CI), and the in-CI coverage step runs `check-code-coverage` under `|| true`
and just uploads the `.info` — it does **not** fail on a coverage regression. The
"no new uncovered lines in `libjwt/`+`tools/`" rule is a developer discipline you
enforce locally before committing (see Build Commands / Issue Workflow), not an
automatic CI gate. Still, do NOT add compiled-but-unexercised code to a coverage
job (see the gcov/`#ifdef` note under Build Commands) — it lowers `codecov/patch`.
- **CI base image** — `build-linux-combos` runs inside a custom image,
`ghcr.io/benmcollins/libjwt/gnutls-leancrypto-mbedtls`, built from
`.github/docker/Dockerfile` and pushed by `.github/workflows/ci-image.yml`
(GHCR via the built-in `GITHUB_TOKEN`; the package must be **public** so
`container:` jobs and fork PRs can pull it). It is `debian:forky` with GnuTLS
built `--with-leancrypto` (so the ML-DSA **and** Ed448/X25519/X448 success
paths are real — forky's apt GnuTLS lacks both) and the latest MbedTLS 3.6.x
LTS, both from source; everything else (and every build-dep) is native Debian
apt. See `.github/docker/README.md`. Respin by bumping the Dockerfile `ARG`
pins and re-running the workflow. A CI change that *references* the image must
land only after the image is pushed and made public.
- `build-linux-combos` runs the seven backend combinations in that image. The
renamed **`all`** row (all three backends + `WITH_ML_DSA=ON`) is the **only**
row that collects coverage Codecov **and** runs `ctest -T memcheck`; on the
leancrypto image its ML-DSA/Ed448/X-curve lines are exercised, not just
compiled. The other rows just build + `ctest`.
- `build-linux` is a vendor-compatibility matrix: Ubuntu 22.04/24.04/26.04 and
Debian stable/oldstable, each built with **both** JSON backends (jansson +
json-c) OpenSSL only. GnuTLS is OFF on every row: the GnuTLS these distros
ship is either below the `gnutls>=3.8.8` CMake floor or `<= 3.8.12` (which hits
the GnuTLS < 3.8.13 OKP defects see the version-gated note), so none can fully
exercise it. json-c is excluded on Ubuntu 22.04 (jammy ships json-c
0.15 < the 0.16 floor). Debian has no hosted runner, so stable/oldstable run as
`container:`; `ubuntu-26.04` is a preview runner (`continue-on-error`).
- `build-linux-mbedtls` and `build-linux-json-c` were **removed** folded into
the `all` row and the compat matrix respectively.
- For same-repo PRs the workflow runs from the PR branch's copy of the YAML; for
forks it uses the base branch's. `paths-ignore` skips runs whose changes are
entirely docs/`.github`/images so a `.github`-only push won't trigger it (use
`workflow_dispatch`).
## Compiler Flags
The project compiles with `-Wall -Werror -Wextra -Wunused`. All warnings are errors.