Merge pull request #336 from benmcollins/317-app-profiles
jwt: application profiles + the primitives they need (#317)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 07c969d..7bd3a6f 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -430,6 +430,9 @@
# Cached remote JWKS source (TTL/ETag/cooldown)
list (APPEND UNIT_TESTS jwt_jwks_cache)
+ # Application profiles (#317): the small primitives + worked recipes
+ list (APPEND UNIT_TESTS jwt_hash jwt_require jwt_embedded_jwk jwt_profiles)
+
# ML-DSA (FIPS 204 / RFC 9964). The test is a no-op unless the build
# enabled WITH_ML_DSA against a capable backend.
list (APPEND UNIT_TESTS jwt_mldsa)
diff --git a/README.md b/README.md
index 0bd0fc4..3f095c5 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,8 @@
``cnf`` | :page_facing_up: [RFC-7800](https://datatracker.ietf.org/doc/html/rfc7800) | Proof-of-Possession (confirmation) claim helpers
``Unencoded Payload`` | :page_facing_up: [RFC-7797](https://datatracker.ietf.org/doc/html/rfc7797) | JWS unencoded (``b64=false``) and detached payloads
``BCP 225`` | :page_facing_up: [RFC-8725](https://datatracker.ietf.org/doc/html/rfc8725) | JWT Best Current Practices (``typ`` check, algorithm allowlist)
+``DPoP`` | :page_facing_up: [RFC-9449](https://datatracker.ietf.org/doc/html/rfc9449) | Proof-of-possession: embedded-JWK verify + ``ath`` token hash
+``Application Profiles`` | :page_facing_up: [RFC-9068](https://datatracker.ietf.org/doc/html/rfc9068) | ``at+jwt``, VAPID, PASSporT, OpenID4VCI, DPoP, mTLS, JAdES recipes
> [!NOTE]
> Throughout this documentation you will see links such as the ones
@@ -152,6 +154,19 @@
so random `kid` values cannot amplify into a request flood. Only `http`/`https`
URLs are accepted (an SSRF guard). Requires the `WITH_LIBCURL` build.
+#### Application Profiles
+
+Most real-world JWT specs are *application profiles* — an ordinary signed JWT
+with a particular `typ`, required claims, and key binding — not new crypto. The
+small primitives that complete them are: `jwt_checker_require()` (assert claims
+are present, RFC 9068), `jwt_checker_enable_embedded_jwk()` (verify a
+self-contained token against the header `jwk`, confirmed by thumbprint — DPoP and
+OpenID4VCI), and `jwt_token_hash()` / `jwt_token_hash_half()` (DPoP `ath`, OIDC
+`at_hash`/`c_hash`). The **Application Profiles** page of the docs has worked
+build-and-verify recipes for `at+jwt` (RFC 9068), VAPID, PASSporT, OpenID4VCI,
+DPoP (RFC 9449), mTLS (RFC 8705), and JAdES, each mirrored by a test in
+`tests/jwt_profiles.c`.
+
#### JWE
LibJWT supports JWE (RFC 7516) in both the Compact Serialization and the JSON
diff --git a/doxygen/mainpage.dox b/doxygen/mainpage.dox
index c2e99f7..6d296de 100644
--- a/doxygen/mainpage.dox
+++ b/doxygen/mainpage.dox
@@ -93,6 +93,10 @@
@section docs \emoji :open_book: Docs and Source
+Guides: @ref examples "Usage Examples" walks the API from the simplest token up;
+@ref profiles "Application Profiles" composes the primitives into real-world
+recipes (at+jwt, VAPID, PASSporT, OpenID4VCI, DPoP, mTLS, JAdES).
+
\emoji :link: [Current Docs](https://libjwt.io)
\emoji :link: [Legacy Docs v2.1.1](https://libjwt.io/stable)
diff --git a/doxygen/profiles.dox b/doxygen/profiles.dox
new file mode 100644
index 0000000..cea50e8
--- /dev/null
+++ b/doxygen/profiles.dox
@@ -0,0 +1,187 @@
+@page profiles \emoji :id: Application Profiles
+
+Many real-world specifications are not new cryptography — they are **application
+profiles**: an ordinary signed JWT with a particular media type (@c "typ"), a
+required set of claims, and a key-binding convention. LibJWT's job is to expose
+the primitives; this page shows how to compose them into each profile. Every
+recipe below builds @em and verifies a token with the public API only, and is
+mirrored by a test in @c tests/jwt_profiles.c.
+
+The reusable pieces are:
+
+Primitive | Function(s) | Used by
+:-------- | :---------- | :------
+Media-type pinning | @ref jwt_checker_expect_typ, @ref jwt_builder_settyp | all `typ`-bearing profiles
+Algorithm allowlist | @ref jwt_checker_setalgs | all (blocks `alg` confusion)
+Required claims | @ref jwt_checker_require | at+jwt, VAPID, PASSporT
+Confirmation (`cnf`) | @ref jwt_builder_setcnf_jkt, @ref jwt_builder_setcnf, @ref jwt_get_cnf | DPoP, mTLS
+Embedded-key verify | @ref jwt_checker_enable_embedded_jwk | DPoP, OpenID4VCI
+Token hash | @ref jwt_token_hash | DPoP `ath`
+JWK Thumbprint | @ref jwks_item_thumbprint | DPoP, OpenID4VCI
+X.509 (`x5c`/`x5t#S256`) | @ref jwks_item_x5c, @ref jwks_item_x5t_s256 | PASSporT, JAdES, mTLS
+Detached payload | @ref jwt_builder_set_detached, @ref jwt_checker_verify_detached | JAdES
+
+@note These profiles layer on a signed JWT only. LibJWT is offline: it does not
+implement the HTTP/TLS exchanges of DPoP, mTLS, or OpenID4VCI — only the JWT
+touchpoints. Pair these with the BCP 240 / RFC 8725 hardening guidance.
+
+@tableofcontents
+
+@section prof_atjwt \emoji :ticket: OAuth 2.0 access tokens — `at+jwt` (RFC 9068)
+
+@rfc{9068} profiles a JWT access token: the header @c "typ" is @c "at+jwt" and a
+fixed set of claims (@c iss, @c exp, @c aud, @c sub, @c client_id, @c iat, @c jti)
+is **mandatory**. Pin the type and algorithm, and use @ref jwt_checker_require to
+assert the mandatory claims are present (LibJWT otherwise validates only the
+claims you ask it to compare).
+
+@code{.c}
+/* Issue */
+jwt_builder_settyp(builder, "at+jwt");
+jwt_builder_setkey(builder, JWT_ALG_ES256, signing_key);
+/* ...set iss/sub/aud/client_id/jti and an exp offset... */
+
+/* Verify */
+const char *must[] = { "iss","sub","aud","exp","iat","jti","client_id" };
+const jwt_alg_t algs[] = { JWT_ALG_ES256 };
+
+jwt_checker_expect_typ(checker, "at+jwt");
+jwt_checker_setalgs(checker, algs, 1);
+jwt_checker_require(checker, must, 7);
+jwt_checker_claim_set(checker, JWT_CLAIM_AUD, "https://rs.example");
+jwt_checker_setkey(checker, JWT_ALG_ES256, as_key);
+if (jwt_checker_verify(checker, token) == 0)
+ /* a well-formed RFC 9068 access token */;
+@endcode
+
+@section prof_vapid \emoji :inbox_tray: Web Push — VAPID (RFC 8292)
+
+@rfc{8292} (VAPID) is the most widely deployed plain JWS-over-P-256 profile on
+the web: an @c ES256 token whose @c aud is the push service origin, @c sub a
+contact URI, and @c exp at most 24 hours out. There is no special @c "typ"; the
+discipline is the fixed algorithm and the required claims.
+
+@code{.c}
+const char *must[] = { "aud", "exp", "sub" };
+const jwt_alg_t es256[] = { JWT_ALG_ES256 };
+
+jwt_checker_setalgs(checker, es256, 1); /* ES256 only */
+jwt_checker_require(checker, must, 3);
+jwt_checker_setkey(checker, JWT_ALG_ES256, app_server_pubkey);
+jwt_checker_verify(checker, token);
+@endcode
+
+@section prof_passport \emoji :telephone_receiver: Caller ID — PASSporT (RFC 8225)
+
+@rfc{8225} PASSporT (the token behind STIR/SHAKEN) signs caller-identity claims
+(@c orig, @c dest, @c iat, @c attest) with @c "typ":"passport", typically ES256,
+and carries the signing certificate via @c "x5u" or @c "x5c". Validate the type,
+algorithm, and required claims here; read the certificate chain with
+@ref jwks_item_x5c (chain/trust validation against the SHAKEN CA is the caller's
+PKI policy — see @ref prof_jades for reading `x5c`).
+
+@code{.c}
+const char *must[] = { "iat", "orig", "dest" };
+const jwt_alg_t es256[] = { JWT_ALG_ES256 };
+
+jwt_checker_expect_typ(checker, "passport");
+jwt_checker_setalgs(checker, es256, 1);
+jwt_checker_require(checker, must, 3);
+jwt_checker_setkey(checker, JWT_ALG_ES256, shaken_leaf_key);
+jwt_checker_verify(checker, token);
+@endcode
+
+@section prof_oid4vci \emoji :credit_card: OpenID4VCI key proof
+
+An OpenID4VCI key proof (`"typ":"openid4vci-proof+jwt"`) is **self-contained**:
+the signing key travels in the protected-header @c "jwk" (@rfc{7515,4.1.3}) and
+the issuer binds the credential to that key. Because the header key is supplied
+by the presenter, it must be confirmed — here against the holder-key thumbprint
+the issuer recorded for the request — with @ref jwt_checker_enable_embedded_jwk,
+which refuses to trust an embedded key without a pin.
+
+@code{.c}
+/* jkt is the thumbprint the issuer bound the credential request to. */
+char *jkt = jwks_item_thumbprint(expected_holder_key, JWK_THUMBPRINT_SHA256);
+const jwt_alg_t algs[] = { JWT_ALG_ES256 };
+
+jwt_checker_expect_typ(checker, "openid4vci-proof+jwt");
+jwt_checker_setalgs(checker, algs, 1);
+jwt_checker_enable_embedded_jwk(checker, JWK_THUMBPRINT_SHA256, jkt);
+jwt_checker_verify(checker, proof); /* key is the confirmed header "jwk" */
+free(jkt);
+@endcode
+
+@section prof_dpop \emoji :hand: Proof-of-possession — DPoP (RFC 9449)
+
+@rfc{9449} DPoP binds an access token to a client key. The access token carries a
+@c cnf.jkt (set with @ref jwt_builder_setcnf_jkt); each request is accompanied by
+a @c "dpop+jwt" proof that carries the client's public key in its header @c "jwk"
+and an @c "ath" claim hashing the access token. To verify a proof: pin the
+embedded key to the access token's @c cnf.jkt, then check that @c ath matches
+@ref jwt_token_hash of the presented access token (and that @c htm/@c htu match
+the request).
+
+@code{.c}
+/* The access token was bound to the client key: */
+jwt_builder_setcnf_jkt(at_builder, client_key); /* -> cnf.jkt */
+
+/* The proof carries the client key in its header and hashes the AT: */
+char *ath = jwt_token_hash(access_token, JWK_THUMBPRINT_SHA256);
+/* ...set proof header "jwk", claims htm/htu/jti/ath, sign with client_key... */
+
+/* Verify the proof at the resource server: */
+char *jkt = jwt_get_cnf(verified_access_token, "jkt"); /* the binding */
+jwt_checker_expect_typ(checker, "dpop+jwt");
+jwt_checker_enable_embedded_jwk(checker, JWK_THUMBPRINT_SHA256, jkt);
+jwt_checker_verify(checker, proof);
+/* In a jwt_checker_setcb() callback, compare the proof's "ath" to
+ * jwt_token_hash(access_token, JWK_THUMBPRINT_SHA256) and check htm/htu. */
+free(ath); free(jkt);
+@endcode
+
+@note The confirmation is not a shortcut around the signature: even when the
+embedded key's thumbprint matches the pin, the proof's signature must still
+verify against that key, so a proof that embeds the victim's key but is signed by
+another is rejected.
+
+@section prof_mtls \emoji :lock_with_ink_pen: mTLS-bound tokens (RFC 8705)
+
+@rfc{8705} binds an access token to a client TLS certificate via a @c cnf
+@c "x5t#S256" member (the certificate's SHA-256 thumbprint). Set it with
+@ref jwt_builder_setcnf; at the resource server, read it with @ref jwt_get_cnf
+and compare against the thumbprint of the certificate presented in the TLS
+handshake (which your TLS terminator provides).
+
+@code{.c}
+/* Issue: bind to the client certificate thumbprint. */
+jwt_builder_setcnf(builder, "x5t#S256", client_cert_sha256_b64url);
+
+/* Verify (inside a jwt_checker_setcb() callback): */
+char *bound = jwt_get_cnf(jwt, "x5t#S256");
+if (bound && !strcmp(bound, presented_cert_thumbprint))
+ /* the token is being used over the bound mTLS connection */;
+free(bound);
+@endcode
+
+@section prof_jades \emoji :pen: Detached signatures — JAdES (ETSI 119 182-1)
+
+JAdES (the eIDAS JWS profile) commonly signs a **detached** document — the
+payload is conveyed out of band, not inside the token — with the signing
+certificate chain in the @c "x5c" header. Sign opaque bytes unencoded
+(@rfc{7797}) and detached, then verify with @ref jwt_checker_verify_detached,
+supplying the document. Read the chain with @ref jwks_item_x5c (parsed from a
+JWK's @c x5c); certificate-path validation is the caller's PKI policy.
+
+@code{.c}
+/* Sign a detached, unencoded document with the cert chain in x5c. */
+jwt_builder_setpayload(builder, document, document_len);
+jwt_builder_setb64(builder, 0); /* opaque bytes, not JSON claims */
+jwt_builder_set_detached(builder, 1); /* payload conveyed out of band */
+/* ...set the "x5c" header to the certificate chain array... */
+token = jwt_builder_generate(builder);
+
+/* Verify with the document supplied out of band. */
+jwt_checker_setkey(checker, JWT_ALG_ES256, signer_key);
+jwt_checker_verify_detached(checker, token, document, document_len);
+@endcode
diff --git a/include/jwt.h b/include/jwt.h
index 23c283a..7a2562c 100644
--- a/include/jwt.h
+++ b/include/jwt.h
@@ -12,6 +12,7 @@
*
* @include{doc} mainpage.dox
* @include{doc} examples.dox
+ * @include{doc} profiles.dox
*/
#ifndef JWT_H
@@ -90,6 +91,23 @@
} jwt_alg_t;
/** @ingroup jwt_alg_grp
+ * @brief Hash algorithm for a JWK Thumbprint and related digests
+ *
+ * Selects the digest used by jwks_item_thumbprint() /
+ * jwks_item_thumbprint_uri(), the embedded-JWK pin
+ * (jwt_checker_enable_embedded_jwk()), and jwt_token_hash(). SHA-256 is the
+ * value 0, so it is the default for a zero-initialized argument and is what
+ * virtually all deployments use.
+ *
+ * @since 3.6.0
+ */
+typedef enum {
+ JWK_THUMBPRINT_SHA256 = 0, /**< SHA-256 (default) */
+ JWK_THUMBPRINT_SHA384, /**< SHA-384 */
+ JWK_THUMBPRINT_SHA512, /**< SHA-512 */
+} jwk_thumbprint_alg_t;
+
+/** @ingroup jwt_alg_grp
* @brief JWE key management algorithm types
*
* These are the supported JWE ``"alg"`` (key management) algorithm types for
@@ -1044,6 +1062,85 @@
int jwt_checker_setalgs(jwt_checker_t *checker, const jwt_alg_t *algs, size_t n);
/**
+ * @brief Require that a set of claims is present
+ *
+ * @rfc{9068,4}
+ *
+ * When set, jwt_checker_verify() rejects a token that is missing any of the
+ * named claims, independent of any value match (a value check is a separate
+ * concern -- see jwt_checker_claim_set()). LibJWT otherwise validates only the
+ * claims it is told to compare, and silently tolerates an absent one; this
+ * asserts the mandatory-claims discipline that profiles such as RFC 9068
+ * (@c at+jwt) require -- e.g. @c {"iss","exp","aud","sub","client_id","jti"}.
+ * The names are copied. Passing @p count as 0 (or @p claims as NULL) clears the
+ * requirement.
+ *
+ * @param checker Pointer to a checker object
+ * @param claims An array of claim names that must be present (copied)
+ * @param count The number of names in @p claims
+ * @return 0 on success, non-zero otherwise with error set in the checker
+ * @since 3.6.0
+ */
+JWT_EXPORT
+int jwt_checker_require(jwt_checker_t *checker, const char **claims,
+ unsigned int count);
+
+/**
+ * @brief Verify using the key embedded in the header, pinned by thumbprint
+ *
+ * @rfc{7515,4.1.3} @rfc{9449}
+ *
+ * Enables taking the verification key from the token's protected-header @c "jwk"
+ * (a self-contained token, as used by DPoP and OpenID4VCI key proofs). Because
+ * that key is supplied by whoever made the token, it is **never trusted on its
+ * own**: it is accepted only if its JWK Thumbprint (@p alg) equals @p
+ * expected_jkt -- the value you obtained out of band and are pinning to, such as
+ * the @c cnf.jkt of a presented access token (read with jwt_get_cnf()). The
+ * confirmed key is still held to the usual key-type/algorithm binding. Combine
+ * with jwt_checker_setalgs() to bound the acceptable algorithms.
+ *
+ * Passing a NULL or empty @p expected_jkt fails: there is no "trust whatever is
+ * embedded" mode. Applies to the Compact Serialization (what DPoP/OpenID4VCI
+ * use). Mutually exclusive with the keyring form; the last call wins.
+ *
+ * @param checker Pointer to a checker object
+ * @param alg The hash for the thumbprint comparison (see @ref jwk_thumbprint_alg_t)
+ * @param expected_jkt The pinned base64url JWK Thumbprint the embedded key must match
+ * @return 0 on success, non-zero otherwise with error set in the checker
+ * @since 3.6.0
+ */
+JWT_EXPORT
+int jwt_checker_enable_embedded_jwk(jwt_checker_t *checker,
+ jwk_thumbprint_alg_t alg,
+ const char *expected_jkt);
+
+/**
+ * @brief Verify using the key embedded in the header, allowed by a keyring
+ *
+ * @rfc{7515,4.1.3}
+ *
+ * As jwt_checker_enable_embedded_jwk(), but the protected-header @c "jwk" is
+ * accepted only if its JWK Thumbprint (@p alg) matches that of some key in @p
+ * allowed (via jwks_find_bythumbprint()). Use this when the set of acceptable
+ * holder keys is known in advance rather than pinned per token. The keyring is
+ * borrowed: the caller retains ownership and must keep it valid for the
+ * lifetime of the checker.
+ *
+ * Passing a NULL @p allowed fails. Mutually exclusive with the pinned form; the
+ * last call wins.
+ *
+ * @param checker Pointer to a checker object
+ * @param alg The hash for the thumbprint comparison (see @ref jwk_thumbprint_alg_t)
+ * @param allowed A JWKS of acceptable keys (borrowed, not freed by the checker)
+ * @return 0 on success, non-zero otherwise with error set in the checker
+ * @since 3.6.0
+ */
+JWT_EXPORT
+int jwt_checker_enable_embedded_jwk_keyring(jwt_checker_t *checker,
+ jwk_thumbprint_alg_t alg,
+ const jwk_set_t *allowed);
+
+/**
* @brief Set a callback for generating tokens
*
* When verifying a token, this callback will be run after jwt_t has been
@@ -1801,6 +1898,46 @@
char *jwt_get_cnf(const jwt_t *jwt, const char *member);
/**
+ * @brief Compute a base64url token hash (full digest)
+ *
+ * @rfc{9449,4.1}
+ *
+ * Returns @c base64url(SHA-x(@p value)) over the @em full digest. This is the
+ * DPoP @c "ath" (access token hash): bind a DPoP proof to an access token with
+ * @c jwt_token_hash(access_token, ::JWK_THUMBPRINT_SHA256) and set the result as
+ * the proof's @c "ath" claim (RFC 9449 pins SHA-256). It is also the generic
+ * "hash this string and base64url it" helper.
+ *
+ * @param value The string to hash (e.g. the access token), nil-terminated
+ * @param alg The digest to use (see @ref jwk_thumbprint_alg_t); RFC 9449 @c ath
+ * uses @ref JWK_THUMBPRINT_SHA256
+ * @return A newly allocated, nil-terminated base64url string the caller must
+ * free with free(), or NULL on error
+ * @since 3.6.0
+ */
+JWT_EXPORT
+char *jwt_token_hash(const char *value, jwk_thumbprint_alg_t alg);
+
+/**
+ * @brief Compute a base64url token hash (left half of the digest)
+ *
+ * Returns @c base64url(left-half(SHA-x(@p value))), where the digest width is
+ * the one the JWS algorithm @p alg signs with (HS/RS/ES/PS-256 -> SHA-256, -384
+ * -> SHA-384, -512 and EdDSA -> SHA-512). This is the OpenID Connect @c "at_hash"
+ * and @c "c_hash" construction (OIDC Core 3.1.3.6 / 3.3.2.11): take the
+ * left-most half of the hash @em bytes before base64url. Distinct from
+ * jwt_token_hash(), which uses the full digest and is keyed to a hash selector.
+ *
+ * @param value The string to hash (an access token or authorization code)
+ * @param alg The signing ::jwt_alg_t whose hash width is used
+ * @return A newly allocated, nil-terminated base64url string the caller must
+ * free with free(), or NULL on error (including an @p alg with no SHA-2 width)
+ * @since 3.6.0
+ */
+JWT_EXPORT
+char *jwt_token_hash_half(const char *value, jwt_alg_t alg);
+
+/**
* @}
* @noop jwt_object_grp
*/
@@ -3123,21 +3260,6 @@
char *jwks_export(const jwk_set_t *jwk_set, int priv);
/**
- * @brief Hash algorithm for a JWK Thumbprint
- *
- * Selects the digest used by jwks_item_thumbprint() and
- * jwks_item_thumbprint_uri(). SHA-256 is the value 0, so it is the default for
- * a zero-initialized argument and is what virtually all deployments use.
- *
- * @since 3.6.0
- */
-typedef enum {
- JWK_THUMBPRINT_SHA256 = 0, /**< SHA-256 (default) */
- JWK_THUMBPRINT_SHA384, /**< SHA-384 */
- JWK_THUMBPRINT_SHA512, /**< SHA-512 */
-} jwk_thumbprint_alg_t;
-
-/**
* @brief Compute the JWK Thumbprint of a key
*
* @rfc{7638,3}
diff --git a/libjwt/jwt-common.c b/libjwt/jwt-common.c
index 8027acd..7f2fd70 100644
--- a/libjwt/jwt-common.c
+++ b/libjwt/jwt-common.c
@@ -59,6 +59,19 @@
jwt_freemem(__cmd->c.expected_typ);
jwt_freemem(__cmd->c.alg_allowlist);
+ /* @rfc{9068} Free the required-claims list (the embedded_keyring is
+ * borrowed, like the verify keyring, so it is never freed here). */
+ if (__cmd->c.require != NULL) {
+ size_t i;
+
+ for (i = 0; i < __cmd->c.n_require; i++)
+ jwt_freemem(__cmd->c.require[i]);
+ jwt_freemem(__cmd->c.require);
+ }
+ jwt_freemem(__cmd->c.embedded_jkt);
+ if (__cmd->c.embedded_owned != NULL)
+ jwks_free(__cmd->c.embedded_owned);
+
memset(__cmd, 0, sizeof(*__cmd));
jwt_freemem(__cmd);
@@ -568,6 +581,13 @@
}
__cmd->c.n_signatures = 0;
__cmd->c.last_sig_count = 0;
+
+ /* @rfc{7515,4.1.3} Drop a confirmed embedded key from a prior
+ * verify before it can be borrowed by this one. */
+ if (__cmd->c.embedded_owned != NULL) {
+ jwks_free(__cmd->c.embedded_owned);
+ __cmd->c.embedded_owned = NULL;
+ }
}
/* @rfc{7515,7.2} A token whose first non-whitespace byte is '{' is a
@@ -599,6 +619,25 @@
config.alg = __cmd->c.alg;
config.ctx = __cmd->c.cb_ctx;
+ /* @rfc{7515,4.1.3} Embedded-JWK verify: seed the key from the protected
+ * header "jwk", but only after confirming it against the pinned
+ * thumbprint or the allowlist. The callback (below) still sees and may
+ * override it. The owning keyring lives on the checker (freed next verify
+ * or at checker free) so jwt_checker_sig_key() can borrow the key. */
+ if (__cmd->c.embedded_jwk) {
+ const jwk_item_t *ek = NULL;
+
+ __cmd->c.embedded_owned =
+ jwt_embedded_jwk_key(&__cmd->c, jwt->headers, &ek);
+ if (ek == NULL) {
+ jwt_write_error(__cmd,
+ "Embedded JWK is missing or not confirmed");
+ return 1;
+ }
+ config.key = ek;
+ config.alg = jwt->alg;
+ }
+
/* Let the user handle this and update config */
if (__cmd->c.cb && __cmd->c.cb(jwt, &config)) {
jwt_write_error(__cmd, "User callback returned error");
@@ -722,6 +761,137 @@
return 0;
}
+int jwt_checker_require(jwt_checker_t *checker, const char **claims,
+ unsigned int count)
+{
+ char **copy = NULL;
+ unsigned int i;
+
+ if (checker == NULL)
+ return 1;
+
+ if (claims != NULL && count > 0) {
+ for (i = 0; i < count; i++) {
+ if (claims[i] == NULL || claims[i][0] == '\0') {
+ jwt_write_error(checker,
+ "A required claim name is empty");
+ return 1;
+ }
+ }
+
+ copy = jwt_malloc(count * sizeof(char *));
+ if (copy == NULL) {
+ jwt_write_error(checker, "Error allocating memory"); // LCOV_EXCL_LINE
+ return 1; // LCOV_EXCL_LINE
+ }
+
+ for (i = 0; i < count; i++) {
+ size_t len = strlen(claims[i]) + 1;
+
+ copy[i] = jwt_malloc(len);
+ if (copy[i] == NULL) {
+ // LCOV_EXCL_START
+ while (i-- > 0)
+ jwt_freemem(copy[i]);
+ jwt_freemem(copy);
+ jwt_write_error(checker, "Error allocating memory");
+ return 1;
+ // LCOV_EXCL_STOP
+ }
+ memcpy(copy[i], claims[i], len);
+ }
+ }
+
+ /* Replace any previous requirement set. */
+ if (checker->c.require != NULL) {
+ for (i = 0; i < checker->c.n_require; i++)
+ jwt_freemem(checker->c.require[i]);
+ jwt_freemem(checker->c.require);
+ }
+ checker->c.require = copy;
+ checker->c.n_require = copy ? count : 0;
+
+ return 0;
+}
+
+/* Reject an out-of-range thumbprint selector at configure time (jwks_item_thumbprint
+ * also rejects it later, but failing here gives a clearer error). */
+static int thumbprint_alg_ok(jwt_checker_t *checker, jwk_thumbprint_alg_t alg)
+{
+ if (alg != JWK_THUMBPRINT_SHA256 && alg != JWK_THUMBPRINT_SHA384 &&
+ alg != JWK_THUMBPRINT_SHA512) {
+ jwt_write_error(checker, "Invalid thumbprint algorithm");
+ return 0;
+ }
+ return 1;
+}
+
+int jwt_checker_enable_embedded_jwk(jwt_checker_t *checker,
+ jwk_thumbprint_alg_t alg,
+ const char *expected_jkt)
+{
+ char *copy;
+ size_t n;
+
+ if (checker == NULL)
+ return 1;
+
+ if (!thumbprint_alg_ok(checker, alg))
+ return 1;
+
+ /* @rfc{7515,4.1.3} The header "jwk" is attacker-supplied, so a key
+ * confirmation is mandatory: refuse to enable a "trust whatever is
+ * embedded" mode. */
+ if (expected_jkt == NULL || expected_jkt[0] == '\0') {
+ jwt_write_error(checker,
+ "A pinned thumbprint is required to trust an embedded JWK");
+ return 1;
+ }
+
+ n = strlen(expected_jkt) + 1;
+ copy = jwt_malloc(n);
+ if (copy == NULL) {
+ jwt_write_error(checker, "Error allocating memory"); // LCOV_EXCL_LINE
+ return 1; // LCOV_EXCL_LINE
+ }
+ memcpy(copy, expected_jkt, n);
+
+ jwt_freemem(checker->c.embedded_jkt);
+ checker->c.embedded_jkt = copy;
+ checker->c.embedded_keyring = NULL;
+ checker->c.embedded_alg = alg;
+ checker->c.embedded_jwk = 1;
+
+ return 0;
+}
+
+int jwt_checker_enable_embedded_jwk_keyring(jwt_checker_t *checker,
+ jwk_thumbprint_alg_t alg,
+ const jwk_set_t *allowed)
+{
+ if (checker == NULL)
+ return 1;
+
+ if (!thumbprint_alg_ok(checker, alg))
+ return 1;
+
+ if (allowed == NULL) {
+ jwt_write_error(checker,
+ "An allowlist keyring is required to trust an embedded JWK");
+ return 1;
+ }
+
+ /* The keyring is borrowed (like the verify keyring); the caller keeps
+ * ownership and must keep it valid for the checker's lifetime. */
+ jwt_freemem(checker->c.embedded_jkt);
+ checker->c.embedded_jkt = NULL;
+ checker->c.embedded_keyring = allowed;
+ checker->c.embedded_alg = alg;
+ checker->c.embedded_jwk = 1;
+
+ return 0;
+}
+
static const struct jwt_signature *checker_sig_at(const jwt_checker_t *checker,
unsigned int index)
{
diff --git a/libjwt/jwt-private.h b/libjwt/jwt-private.h
index 88dd151..5b7ad5f 100644
--- a/libjwt/jwt-private.h
+++ b/libjwt/jwt-private.h
@@ -130,6 +130,28 @@
char *expected_typ;
jwt_alg_t *alg_allowlist;
size_t n_alg_allowlist;
+
+ /* --- @rfc{9068} Required-claims-present (checker) ---
+ * @require: a copied array of claim names (e.g. "iss","exp","jti") that
+ * must be PRESENT in the token, independent of any value match. */
+ char **require;
+ size_t n_require;
+
+ /* --- @rfc{7515,4.1.3} Embedded-JWK header verify (checker) ---
+ * When @embedded_jwk is set, the verification key is taken from the
+ * token's protected-header "jwk" — but only after the key is CONFIRMED
+ * against either @embedded_jkt (a pinned thumbprint) or @embedded_keyring
+ * (a borrowed allowlist), using @embedded_alg as the thumbprint hash.
+ * Exactly one of @embedded_jkt / @embedded_keyring is set. The header key
+ * is attacker-supplied, so it is never trusted without this confirmation. */
+ int embedded_jwk;
+ jwk_thumbprint_alg_t embedded_alg;
+ char *embedded_jkt;
+ const jwk_set_t *embedded_keyring;
+ /* The confirmed header key is parsed into a keyring this checker OWNS;
+ * it must outlive the verify (jwt_checker_sig_key() borrows it), so it is
+ * reset at the start of each verify and freed at checker free. */
+ jwk_set_t *embedded_owned;
};
struct jwt_builder {
@@ -676,6 +698,14 @@
JWT_NO_EXPORT
int jwt_verify_json(jwt_checker_t *checker, const char *token);
+/* @rfc{7515,4.1.3} Build + confirm a verification key from the protected
+ * header's "jwk" (embedded-JWK verify). Returns an owned jwk_set_t (caller
+ * frees) with *out set to the contained item, or NULL if disabled/absent/
+ * unconfirmed. See jwt-verify.c. */
+JWT_NO_EXPORT
+jwk_set_t *jwt_embedded_jwk_key(struct jwt_common *c, jwt_json_t *headers,
+ const jwk_item_t **out);
+
/* @rfc{7515,7.2.1} Non-zero if any member of @header also appears in
* @protected (a parameter must not be in both). */
JWT_NO_EXPORT
diff --git a/libjwt/jwt-setget.c b/libjwt/jwt-setget.c
index 02294b4..2fe8388 100644
--- a/libjwt/jwt-setget.c
+++ b/libjwt/jwt-setget.c
@@ -438,3 +438,79 @@
return out;
}
+
+/* Map a thumbprint-hash selector to its SHA-2 bit width. */
+static int thumbprint_sha_bits(jwk_thumbprint_alg_t alg)
+{
+ switch (alg) {
+ case JWK_THUMBPRINT_SHA256:
+ return 256;
+ case JWK_THUMBPRINT_SHA384:
+ return 384;
+ case JWK_THUMBPRINT_SHA512:
+ return 512;
+ default:
+ return 0;
+ }
+}
+
+/* The SHA-2 width a JWS algorithm's signature is built on -- the hash an OIDC
+ * at_hash/c_hash is keyed to. 0 for an algorithm with no defined width. */
+static int alg_sha_bits(jwt_alg_t alg)
+{
+ switch (alg) {
+ case JWT_ALG_HS256:
+ case JWT_ALG_RS256:
+ case JWT_ALG_ES256:
+ case JWT_ALG_ES256K:
+ case JWT_ALG_PS256:
+ return 256;
+ case JWT_ALG_HS384:
+ case JWT_ALG_RS384:
+ case JWT_ALG_ES384:
+ case JWT_ALG_PS384:
+ return 384;
+ case JWT_ALG_HS512:
+ case JWT_ALG_RS512:
+ case JWT_ALG_ES512:
+ case JWT_ALG_PS512:
+ case JWT_ALG_EDDSA:
+ return 512;
+ default:
+ return 0;
+ }
+}
+
+/* base64url(SHA-@bits(@value)), optionally truncated to the left half. Returns
+ * a malloc'd string the caller frees, or NULL on error. */
+static char *token_hash(const char *value, int bits, int half)
+{
+ unsigned char hash[64]; /* SHA-512 is the widest */
+ unsigned int hlen = 0;
+ char *b64 = NULL;
+
+ if (value == NULL || bits == 0 || jwt_ops->sha == NULL)
+ return NULL;
+
+ if (jwt_ops->sha(bits, (const unsigned char *)value, strlen(value),
+ hash, &hlen) || hlen == 0 || hlen > sizeof(hash))
+ return NULL; // LCOV_EXCL_LINE
+
+ if (half)
+ hlen /= 2;
+
+ if (jwt_base64uri_encode(&b64, (char *)hash, (int)hlen) <= 0)
+ return NULL; // LCOV_EXCL_LINE
+
+ return b64;
+}
+
+char *jwt_token_hash(const char *value, jwk_thumbprint_alg_t alg)
+{
+ return token_hash(value, thumbprint_sha_bits(alg), 0);
+}
+
+char *jwt_token_hash_half(const char *value, jwt_alg_t alg)
+{
+ return token_hash(value, alg_sha_bits(alg), 1);
+}
diff --git a/libjwt/jwt-verify.c b/libjwt/jwt-verify.c
index 43898b7..c3c4e72 100644
--- a/libjwt/jwt-verify.c
+++ b/libjwt/jwt-verify.c
@@ -386,6 +386,109 @@
return failed;
}
+/* @rfc{9068} Every claim named via jwt_checker_require() must be PRESENT in the
+ * token, independent of any value match. Returns 0 if all are present, or 1 with
+ * the error set naming the first missing one. */
+static int __verify_required(jwt_t *jwt)
+{
+ jwt_checker_t *checker = jwt->checker;
+ size_t i;
+
+ if (checker == NULL)
+ return 0; // LCOV_EXCL_LINE
+
+ for (i = 0; i < checker->c.n_require; i++) {
+ const char *name = checker->c.require[i];
+ jwt_value_t jval;
+
+ /* Presence is all we assert: a wrong-type claim (a numeric "exp",
+ * an array "aud") still returns a non-NOEXIST error, i.e. present. */
+ jwt_set_GET_STR(&jval, name);
+ if (jwt_claim_get(jwt, &jval) == JWT_VALUE_ERR_NOEXIST) {
+ jwt_write_error(jwt,
+ "Required claim \"%s\" is missing", name);
+ return 1;
+ }
+ }
+
+ return 0;
+}
+
+/* @rfc{7515,4.1.3} Build and CONFIRM the verification key from a protected
+ * header's "jwk". The header key is attacker-supplied, so it is accepted only
+ * after its thumbprint matches the checker's pin (c->embedded_jkt) or is found
+ * in its allowlist (c->embedded_keyring), using c->embedded_alg. On success
+ * returns a jwk_set_t that OWNS the key (the caller frees it) and points *out at
+ * the contained item; returns NULL (with *out NULL) if embedded-jwk is not
+ * enabled, the header carries no usable "jwk", or confirmation fails. */
+jwk_set_t *jwt_embedded_jwk_key(struct jwt_common *c, jwt_json_t *headers,
+ const jwk_item_t **out)
+{
+ jwt_json_t *jwk;
+ char *jwk_str;
+ jwk_set_t *ks;
+ const jwk_item_t *item;
+ char *tp;
+ int ok;
+
+ *out = NULL;
+
+ if (!c->embedded_jwk || headers == NULL)
+ return NULL; // LCOV_EXCL_LINE (callers guarantee both)
+
+ jwk = jwt_json_obj_get(headers, "jwk");
+ if (jwk == NULL || !jwt_json_is_object(jwk))
+ return NULL;
+
+ jwk_str = jwt_json_serialize(jwk, 0);
+ if (jwk_str == NULL)
+ return NULL; // LCOV_EXCL_LINE
+
+ ks = jwks_create(jwk_str);
+ jwt_freemem(jwk_str);
+ if (ks == NULL)
+ return NULL; // LCOV_EXCL_LINE
+ /* A set-level parse error (a malformed key surfaces as an item error and
+ * is caught by the thumbprint check below). Defensive depth. */
+ if (jwks_error(ks)) {
+ // LCOV_EXCL_START
+ jwks_free(ks);
+ return NULL;
+ // LCOV_EXCL_STOP
+ }
+
+ item = jwks_item_get(ks, 0);
+ if (item == NULL) {
+ // LCOV_EXCL_START
+ jwks_free(ks);
+ return NULL;
+ // LCOV_EXCL_STOP
+ }
+
+ /* Confirm the attacker-supplied key against the pin or the allowlist. */
+ tp = jwks_item_thumbprint(item, c->embedded_alg);
+ if (tp == NULL) {
+ jwks_free(ks);
+ return NULL;
+ }
+
+ if (c->embedded_jkt != NULL)
+ ok = (strcmp(tp, c->embedded_jkt) == 0);
+ else
+ ok = (c->embedded_keyring != NULL &&
+ jwks_find_bythumbprint((jwk_set_t *)c->embedded_keyring,
+ c->embedded_alg, tp) != NULL);
+ jwt_freemem(tp);
+
+ if (!ok) {
+ jwks_free(ks);
+ return NULL;
+ }
+
+ *out = item;
+ return ks;
+}
+
/* @rfc{7519,4.1.7} jti: hand the id to the application callback, which
* validates/consumes it (e.g. replay protection). A registered callback means
* a token with no jti is rejected.
@@ -481,6 +584,10 @@
return 1;
}
+ /* @rfc{9068} Required claims must be present (also JSON-claims only). */
+ if (jwt->b64 && __verify_required(jwt))
+ return 1;
+
if (!sig_len) {
if (config->key || config->alg != JWT_ALG_NONE ||
jwt->alg != JWT_ALG_NONE) {
@@ -923,6 +1030,10 @@
n_verified, n_entries);
} else if (jwt->b64 && __verify_claims(jwt)) {
jwt_write_error(checker, "Failed one or more claims");
+ } else if (jwt->b64 && __verify_required(jwt)) {
+ /* @rfc{9068} Required-claims-present, same as the compact path; the
+ * specific "missing" error is written on the jwt. */
+ jwt_copy_error(checker, jwt);
} else if (__verify_jti(jwt)) {
/* jti runs only after signature + claims succeed. */
jwt_write_error(checker, "Failed one or more claims");
diff --git a/tests/jwt_embedded_jwk.c b/tests/jwt_embedded_jwk.c
new file mode 100644
index 0000000..ac2e55e
--- /dev/null
+++ b/tests/jwt_embedded_jwk.c
@@ -0,0 +1,353 @@
+/* Public domain, no copyright. Use at your own risk. */
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "jwt_tests.h"
+
+/* Tests for embedded-JWK verification (RFC 7515 4.1.3): jwt_checker_enable_
+ * embedded_jwk() (thumbprint pin) and _keyring() (allowlist). The header "jwk"
+ * is attacker-supplied, so the security contract is that it is accepted ONLY
+ * after the key is confirmed against a caller-supplied pin/allowlist AND its
+ * signature verifies against that confirmed key. This is the DPoP / OpenID4VCI
+ * key-proof mechanism. The proof carries the P-256 public key in its protected
+ * header and is signed by the matching private key. */
+
+/* The public half of tests/keys/ec_key_prime256v1.json (key "A"). */
+#define PUB_JWK_P256 \
+ "{\"kty\":\"EC\",\"crv\":\"P-256\"," \
+ "\"x\":\"Y--DdSpCZ5oF3j__h-SdNJIwvB5aI4AXzpRErGUjWrM\"," \
+ "\"y\":\"_bSTCXlDeU-pZZbOKDUVLANspSIeuKZfTM8rtXFG_RU\"}"
+
+static jwk_set_t *load_key_a(void)
+{
+ jwk_set_t *set = jwks_create_fromfile(KEYDIR "/ec_key_prime256v1.json");
+
+ ck_assert_ptr_nonnull(set);
+ return set;
+}
+
+/* A self-contained proof: header "jwk" = @jwk_hdr (or none), signed with
+ * @signing_key, typ @typ. Caller frees the token. */
+static char *make_proof(const jwk_item_t *signing_key, char *jwk_hdr,
+ const char *typ)
+{
+ jwt_builder_auto_t *b = jwt_builder_new();
+ jwt_value_t v;
+
+ ck_assert_ptr_nonnull(b);
+ ck_assert_int_eq(jwt_builder_setkey(b, JWT_ALG_ES256, signing_key), 0);
+ if (typ != NULL)
+ ck_assert_int_eq(jwt_builder_settyp(b, typ), 0);
+ if (jwk_hdr != NULL) {
+ jwt_set_SET_JSON(&v, "jwk", jwk_hdr);
+ ck_assert_int_eq(jwt_builder_header_set(b, &v),
+ JWT_VALUE_ERR_NONE);
+ }
+ jwt_set_SET_STR(&v, "htm", "POST");
+ ck_assert_int_eq(jwt_builder_claim_set(b, &v), JWT_VALUE_ERR_NONE);
+
+ return jwt_builder_generate(b);
+}
+
+START_TEST(test_pin_confirms)
+{
+ jwk_set_t *set;
+ const jwk_item_t *key;
+ jwt_checker_auto_t *checker = NULL;
+ char_auto *jkt = NULL;
+ char_auto *token = NULL;
+
+ SET_OPS();
+
+ set = load_key_a();
+ key = jwks_item_get(set, 0);
+ jkt = jwks_item_thumbprint(key, JWK_THUMBPRINT_SHA256);
+ ck_assert_ptr_nonnull(jkt);
+
+ token = make_proof(key, PUB_JWK_P256, "dpop+jwt");
+ ck_assert_ptr_nonnull(token);
+
+ checker = jwt_checker_new();
+ ck_assert_int_eq(jwt_checker_expect_typ(checker, "dpop+jwt"), 0);
+ {
+ const jwt_alg_t algs[] = { JWT_ALG_ES256 };
+ ck_assert_int_eq(jwt_checker_setalgs(checker, algs, 1), 0);
+ }
+ ck_assert_int_eq(jwt_checker_enable_embedded_jwk(checker,
+ JWK_THUMBPRINT_SHA256, jkt), 0);
+
+ ck_assert_int_eq(jwt_checker_verify(checker, token), 0);
+
+ /* The confirmed embedded key is borrowable through the introspection API. */
+ ck_assert_uint_eq(jwt_checker_sig_count(checker), 1);
+ ck_assert_int_eq(jwt_checker_sig_verified(checker, 0), 1);
+ ck_assert_ptr_nonnull(jwt_checker_sig_key(checker, 0));
+
+ jwks_free(set);
+}
+END_TEST
+
+START_TEST(test_pin_mismatch_rejected)
+{
+ jwk_set_t *set;
+ const jwk_item_t *key;
+ jwt_checker_auto_t *checker = NULL;
+ char_auto *token = NULL;
+
+ SET_OPS();
+
+ set = load_key_a();
+ key = jwks_item_get(set, 0);
+ token = make_proof(key, PUB_JWK_P256, "dpop+jwt");
+ ck_assert_ptr_nonnull(token);
+
+ checker = jwt_checker_new();
+ /* Pin to a thumbprint the embedded key does not match. */
+ ck_assert_int_eq(jwt_checker_enable_embedded_jwk(checker,
+ JWK_THUMBPRINT_SHA256,
+ "not-the-right-thumbprint-value-000000000000"), 0);
+ ck_assert_int_ne(jwt_checker_verify(checker, token), 0);
+
+ jwks_free(set);
+}
+END_TEST
+
+START_TEST(test_missing_header_jwk_rejected)
+{
+ jwk_set_t *set;
+ const jwk_item_t *key;
+ jwt_checker_auto_t *checker = NULL;
+ char_auto *jkt = NULL;
+ char_auto *token = NULL;
+
+ SET_OPS();
+
+ set = load_key_a();
+ key = jwks_item_get(set, 0);
+ jkt = jwks_item_thumbprint(key, JWK_THUMBPRINT_SHA256);
+
+ /* A token with no "jwk" header at all. */
+ token = make_proof(key, NULL, "dpop+jwt");
+ ck_assert_ptr_nonnull(token);
+
+ checker = jwt_checker_new();
+ ck_assert_int_eq(jwt_checker_enable_embedded_jwk(checker,
+ JWK_THUMBPRINT_SHA256, jkt), 0);
+ ck_assert_int_ne(jwt_checker_verify(checker, token), 0);
+
+ jwks_free(set);
+}
+END_TEST
+
+/* The crux: even when the embedded key's thumbprint matches the pin, the
+ * signature must verify against THAT key. A proof that embeds key A's public
+ * key but is signed by a different key B must be rejected. */
+START_TEST(test_signature_must_match_embedded)
+{
+ jwk_set_t *set, *bset;
+ const jwk_item_t *akey, *bkey;
+ jwt_checker_auto_t *checker = NULL;
+ char_auto *jkt = NULL;
+ char_auto *token = NULL;
+
+ SET_OPS();
+
+ set = load_key_a();
+ akey = jwks_item_get(set, 0);
+ jkt = jwks_item_thumbprint(akey, JWK_THUMBPRINT_SHA256);
+
+ bset = jwks_create_generate(JWK_KEY_TYPE_EC, "P-256", JWT_ALG_ES256,
+ JWK_KEY_NONE);
+ ck_assert_ptr_nonnull(bset);
+ bkey = jwks_item_get(bset, 0);
+ if (bkey == NULL || jwks_item_error(bkey)) {
+ /* A backend with no EC keygen: nothing to test here. */
+ jwks_free(bset);
+ jwks_free(set);
+ return;
+ }
+
+ /* header jwk = A (matches the pin), but signed by B. */
+ token = make_proof(bkey, PUB_JWK_P256, "dpop+jwt");
+ ck_assert_ptr_nonnull(token);
+
+ checker = jwt_checker_new();
+ ck_assert_int_eq(jwt_checker_enable_embedded_jwk(checker,
+ JWK_THUMBPRINT_SHA256, jkt), 0);
+ ck_assert_int_ne(jwt_checker_verify(checker, token), 0);
+
+ jwks_free(bset);
+ jwks_free(set);
+}
+END_TEST
+
+START_TEST(test_keyring_allowlist)
+{
+ jwk_set_t *set, *other;
+ const jwk_item_t *key;
+ jwt_checker_auto_t *c1 = NULL, *c2 = NULL;
+ char_auto *token = NULL;
+
+ SET_OPS();
+
+ set = load_key_a();
+ key = jwks_item_get(set, 0);
+ token = make_proof(key, PUB_JWK_P256, "dpop+jwt");
+ ck_assert_ptr_nonnull(token);
+
+ /* The allowlist contains key A: accepted. */
+ c1 = jwt_checker_new();
+ ck_assert_int_eq(jwt_checker_enable_embedded_jwk_keyring(c1,
+ JWK_THUMBPRINT_SHA256, set), 0);
+ ck_assert_int_eq(jwt_checker_verify(c1, token), 0);
+
+ /* An allowlist of some other key: rejected. */
+ other = jwks_create_fromfile(KEYDIR "/ec_key_secp256k1.json");
+ ck_assert_ptr_nonnull(other);
+ c2 = jwt_checker_new();
+ ck_assert_int_eq(jwt_checker_enable_embedded_jwk_keyring(c2,
+ JWK_THUMBPRINT_SHA256, other), 0);
+ ck_assert_int_ne(jwt_checker_verify(c2, token), 0);
+
+ jwks_free(other);
+ jwks_free(set);
+}
+END_TEST
+
+/* A malformed/incomplete header "jwk" is rejected, not crashed on (the header
+ * key is fully attacker-controlled). */
+START_TEST(test_malformed_embedded_jwk)
+{
+ jwk_set_t *set;
+ const jwk_item_t *key;
+ char_auto *jkt = NULL;
+ const char *bad[] = {
+ "{\"kty\":\"EC\",\"crv\":\"P-256\"}", /* missing x/y */
+ "{\"kty\":\"frobnicate\"}", /* unknown kty */
+ "{\"kty\":\"EC\",\"crv\":\"P-256\",\"x\":\"@\",\"y\":\"@\"}",
+ "{}", /* empty object */
+ };
+ size_t n;
+
+ SET_OPS();
+
+ set = load_key_a();
+ key = jwks_item_get(set, 0);
+ jkt = jwks_item_thumbprint(key, JWK_THUMBPRINT_SHA256);
+
+ for (n = 0; n < ARRAY_SIZE(bad); n++) {
+ jwt_checker_auto_t *cc = jwt_checker_new();
+ char *t = make_proof(key, (char *)bad[n], "dpop+jwt");
+
+ ck_assert_ptr_nonnull(t);
+ ck_assert_int_eq(jwt_checker_enable_embedded_jwk(cc,
+ JWK_THUMBPRINT_SHA256, jkt), 0);
+ /* A malformed header key is rejected, never crashed on. */
+ ck_assert_int_ne(jwt_checker_verify(cc, t), 0);
+ free(t);
+ }
+
+ jwks_free(set);
+}
+END_TEST
+
+/* One embedded-JWK checker verifies two proofs in a row: the confirmed key from
+ * the first verify must be released before the second borrows its own. */
+START_TEST(test_checker_reuse)
+{
+ jwk_set_t *set;
+ const jwk_item_t *key;
+ jwt_checker_auto_t *checker = NULL;
+ char_auto *jkt = NULL;
+ char_auto *t1 = NULL, *t2 = NULL;
+
+ SET_OPS();
+
+ set = load_key_a();
+ key = jwks_item_get(set, 0);
+ jkt = jwks_item_thumbprint(key, JWK_THUMBPRINT_SHA256);
+
+ t1 = make_proof(key, PUB_JWK_P256, "dpop+jwt");
+ t2 = make_proof(key, PUB_JWK_P256, "dpop+jwt");
+ ck_assert_ptr_nonnull(t1);
+ ck_assert_ptr_nonnull(t2);
+
+ checker = jwt_checker_new();
+ ck_assert_int_eq(jwt_checker_enable_embedded_jwk(checker,
+ JWK_THUMBPRINT_SHA256, jkt), 0);
+ ck_assert_int_eq(jwt_checker_verify(checker, t1), 0);
+ ck_assert_int_eq(jwt_checker_verify(checker, t2), 0);
+
+ jwks_free(set);
+}
+END_TEST
+
+START_TEST(test_enable_errors)
+{
+ jwk_set_t *set;
+ const jwk_item_t *key;
+ jwt_checker_auto_t *checker = NULL;
+ char_auto *jkt = NULL;
+
+ SET_OPS();
+
+ set = load_key_a();
+ key = jwks_item_get(set, 0);
+ jkt = jwks_item_thumbprint(key, JWK_THUMBPRINT_SHA256);
+
+ checker = jwt_checker_new();
+
+ /* No "trust whatever is embedded" mode: a NULL/empty pin must fail. */
+ ck_assert_int_ne(jwt_checker_enable_embedded_jwk(NULL,
+ JWK_THUMBPRINT_SHA256, jkt), 0);
+ ck_assert_int_ne(jwt_checker_enable_embedded_jwk(checker,
+ JWK_THUMBPRINT_SHA256, NULL), 0);
+ ck_assert_int_ne(jwt_checker_enable_embedded_jwk(checker,
+ JWK_THUMBPRINT_SHA256, ""), 0);
+ /* An out-of-range thumbprint selector. */
+ ck_assert_int_ne(jwt_checker_enable_embedded_jwk(checker,
+ (jwk_thumbprint_alg_t)42, jkt), 0);
+ /* The keyring form: NULL checker, bad alg, and a missing keyring all fail. */
+ ck_assert_int_ne(jwt_checker_enable_embedded_jwk_keyring(NULL,
+ JWK_THUMBPRINT_SHA256, set), 0);
+ ck_assert_int_ne(jwt_checker_enable_embedded_jwk_keyring(checker,
+ (jwk_thumbprint_alg_t)42, set), 0);
+ ck_assert_int_ne(jwt_checker_enable_embedded_jwk_keyring(checker,
+ JWK_THUMBPRINT_SHA256, NULL), 0);
+
+ jwks_free(set);
+}
+END_TEST
+
+static Suite *libjwt_suite(const char *title)
+{
+ Suite *s;
+ TCase *tc_core;
+ int i = ARRAY_SIZE(jwt_test_ops);
+
+ s = suite_create(title);
+
+ tc_core = tcase_create("jwt_embedded_jwk");
+
+ tcase_add_loop_test(tc_core, test_pin_confirms, 0, i);
+ tcase_add_loop_test(tc_core, test_pin_mismatch_rejected, 0, i);
+ tcase_add_loop_test(tc_core, test_missing_header_jwk_rejected, 0, i);
+ tcase_add_loop_test(tc_core, test_signature_must_match_embedded, 0, i);
+ tcase_add_loop_test(tc_core, test_keyring_allowlist, 0, i);
+ tcase_add_loop_test(tc_core, test_malformed_embedded_jwk, 0, i);
+ tcase_add_loop_test(tc_core, test_checker_reuse, 0, i);
+ tcase_add_loop_test(tc_core, test_enable_errors, 0, i);
+
+ tcase_set_timeout(tc_core, 30);
+
+ suite_add_tcase(s, tc_core);
+
+ return s;
+}
+
+int main(void)
+{
+ JWT_TEST_MAIN("LibJWT embedded-JWK verify (RFC 7515 4.1.3)");
+}
diff --git a/tests/jwt_hash.c b/tests/jwt_hash.c
new file mode 100644
index 0000000..fc79d21
--- /dev/null
+++ b/tests/jwt_hash.c
@@ -0,0 +1,147 @@
+/* Public domain, no copyright. Use at your own risk. */
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "jwt_tests.h"
+
+/* Tests for jwt_token_hash() (DPoP "ath", the full digest) and
+ * jwt_token_hash_half() (OIDC "at_hash"/"c_hash", the left half keyed to the
+ * signing alg). The anchors are the published OpenID Connect Core 3.1.3.6
+ * at_hash example and a full SHA-256 vector. SET_OPS() runs each across every
+ * compiled crypto backend, so each backend's one-shot SHA is exercised. */
+
+/* OIDC Core 3.1.3.6: at_hash of this access token under an RS256 (SHA-256) ID
+ * token. The value is the left half of the digest, base64url. */
+#define OIDC_AT "jHkWEdUXMU1BwAsC4vtUsZwnNvTIxEl0z9K3vx5KF0Y"
+#define OIDC_ATHASH "77QmUPtjPfzWtF2AnpK9RQ"
+
+START_TEST(test_at_hash_oidc_vector)
+{
+ char_auto *h = NULL;
+
+ SET_OPS();
+
+ h = jwt_token_hash_half(OIDC_AT, JWT_ALG_RS256);
+ ck_assert_ptr_nonnull(h);
+ ck_assert_str_eq(h, OIDC_ATHASH);
+}
+END_TEST
+
+/* DPoP "ath" = FULL base64url(SHA-256(access_token)). */
+START_TEST(test_ath_full_vector)
+{
+ char_auto *h = NULL;
+
+ SET_OPS();
+
+ h = jwt_token_hash("Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU",
+ JWK_THUMBPRINT_SHA256);
+ ck_assert_ptr_nonnull(h);
+ ck_assert_str_eq(h, "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo");
+}
+END_TEST
+
+START_TEST(test_full_lengths_and_determinism)
+{
+ char_auto *a = NULL, *b = NULL;
+ char_auto *c256 = NULL, *c384 = NULL, *c512 = NULL;
+
+ SET_OPS();
+
+ /* base64url of a 32/48/64-byte digest = 43/64/86 chars (no padding). */
+ c256 = jwt_token_hash("hello", JWK_THUMBPRINT_SHA256);
+ c384 = jwt_token_hash("hello", JWK_THUMBPRINT_SHA384);
+ c512 = jwt_token_hash("hello", JWK_THUMBPRINT_SHA512);
+ ck_assert_ptr_nonnull(c256);
+ ck_assert_ptr_nonnull(c384);
+ ck_assert_ptr_nonnull(c512);
+ ck_assert_int_eq((int)strlen(c256), 43);
+ ck_assert_int_eq((int)strlen(c384), 64);
+ ck_assert_int_eq((int)strlen(c512), 86);
+
+ /* Deterministic; different inputs differ. */
+ a = jwt_token_hash("hello", JWK_THUMBPRINT_SHA256);
+ b = jwt_token_hash("world", JWK_THUMBPRINT_SHA256);
+ ck_assert_str_eq(a, c256);
+ ck_assert_str_ne(a, b);
+}
+END_TEST
+
+START_TEST(test_half_is_left_half)
+{
+ char_auto *full = NULL, *half = NULL;
+
+ SET_OPS();
+
+ /* RS256 -> SHA-256; the half is 16 bytes -> 22 chars and is the leading
+ * bytes of the full digest, so the first five base64url groups (15 bytes
+ * -> 20 chars) are shared with the full encoding. */
+ full = jwt_token_hash("hello", JWK_THUMBPRINT_SHA256);
+ half = jwt_token_hash_half("hello", JWT_ALG_RS256);
+ ck_assert_ptr_nonnull(half);
+ ck_assert_int_eq((int)strlen(half), 22);
+ ck_assert(!strncmp(full, half, 20));
+}
+END_TEST
+
+START_TEST(test_half_alg_widths)
+{
+ char_auto *h256 = NULL, *h384 = NULL, *h512 = NULL, *hed = NULL;
+
+ SET_OPS();
+
+ h256 = jwt_token_hash_half("hello", JWT_ALG_ES256);
+ h384 = jwt_token_hash_half("hello", JWT_ALG_ES384);
+ h512 = jwt_token_hash_half("hello", JWT_ALG_ES512);
+ hed = jwt_token_hash_half("hello", JWT_ALG_EDDSA);
+ ck_assert_int_eq((int)strlen(h256), 22); /* 16 bytes */
+ ck_assert_int_eq((int)strlen(h384), 32); /* 24 bytes */
+ ck_assert_int_eq((int)strlen(h512), 43); /* 32 bytes */
+ ck_assert_int_eq((int)strlen(hed), 43); /* EdDSA -> SHA-512 -> 32 */
+}
+END_TEST
+
+START_TEST(test_errors)
+{
+ SET_OPS();
+
+ ck_assert_ptr_null(jwt_token_hash(NULL, JWK_THUMBPRINT_SHA256));
+ ck_assert_ptr_null(jwt_token_hash_half(NULL, JWT_ALG_ES256));
+ /* An alg with no SHA-2 width -> NULL. */
+ ck_assert_ptr_null(jwt_token_hash_half("hello", JWT_ALG_NONE));
+ ck_assert_ptr_null(jwt_token_hash_half("hello", JWT_ALG_INVAL));
+ /* An out-of-range thumbprint selector -> NULL. */
+ ck_assert_ptr_null(jwt_token_hash("hello", (jwk_thumbprint_alg_t)999));
+}
+END_TEST
+
+static Suite *libjwt_suite(const char *title)
+{
+ Suite *s;
+ TCase *tc_core;
+ int i = ARRAY_SIZE(jwt_test_ops);
+
+ s = suite_create(title);
+
+ tc_core = tcase_create("jwt_hash");
+
+ tcase_add_loop_test(tc_core, test_at_hash_oidc_vector, 0, i);
+ tcase_add_loop_test(tc_core, test_ath_full_vector, 0, i);
+ tcase_add_loop_test(tc_core, test_full_lengths_and_determinism, 0, i);
+ tcase_add_loop_test(tc_core, test_half_is_left_half, 0, i);
+ tcase_add_loop_test(tc_core, test_half_alg_widths, 0, i);
+ tcase_add_loop_test(tc_core, test_errors, 0, i);
+
+ tcase_set_timeout(tc_core, 30);
+
+ suite_add_tcase(s, tc_core);
+
+ return s;
+}
+
+int main(void)
+{
+ JWT_TEST_MAIN("LibJWT token hash (ath / at_hash / c_hash)");
+}
diff --git a/tests/jwt_profiles.c b/tests/jwt_profiles.c
new file mode 100644
index 0000000..19b3a5b
--- /dev/null
+++ b/tests/jwt_profiles.c
@@ -0,0 +1,443 @@
+/* Public domain, no copyright. Use at your own risk. */
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "jwt_tests.h"
+
+/* Worked, end-to-end recipes for the real-world JWT application profiles of
+ * issue #317. Each builds AND verifies a token using only the public primitives
+ * (no per-profile API): at+jwt (RFC 9068), VAPID (RFC 8292), PASSporT
+ * (RFC 8225), OpenID4VCI key-proof, DPoP (RFC 9449), OAuth mTLS (RFC 8705), and
+ * JAdES (ETSI 119 182-1). One EC P-256 key (ES256, every backend) is used. */
+
+/* The public half of tests/keys/ec_key_prime256v1.json. */
+#define PUB_JWK_P256 \
+ "{\"kty\":\"EC\",\"crv\":\"P-256\"," \
+ "\"x\":\"Y--DdSpCZ5oF3j__h-SdNJIwvB5aI4AXzpRErGUjWrM\"," \
+ "\"y\":\"_bSTCXlDeU-pZZbOKDUVLANspSIeuKZfTM8rtXFG_RU\"}"
+
+static jwk_set_t *load_key(void)
+{
+ jwk_set_t *set = jwks_create_fromfile(KEYDIR "/ec_key_prime256v1.json");
+
+ ck_assert_ptr_nonnull(set);
+ return set;
+}
+
+static void set_str(jwt_builder_t *b, const char *name, const char *val)
+{
+ jwt_value_t v;
+
+ jwt_set_SET_STR(&v, name, val);
+ ck_assert_int_eq(jwt_builder_claim_set(b, &v), JWT_VALUE_ERR_NONE);
+}
+
+/* ---- RFC 9068 OAuth 2.0 JWT access token (typ "at+jwt") ---------------- */
+START_TEST(test_at_jwt)
+{
+ jwk_set_t *set;
+ const jwk_item_t *key;
+ jwt_builder_auto_t *b = NULL;
+ jwt_checker_auto_t *c = NULL;
+ char_auto *token = NULL;
+ const char *required[] = { "iss", "sub", "aud", "exp", "iat",
+ "jti", "client_id" };
+ const jwt_alg_t algs[] = { JWT_ALG_ES256 };
+
+ SET_OPS();
+
+ set = load_key();
+ key = jwks_item_get(set, 0);
+
+ /* Issue: typ=at+jwt and the RFC 9068 mandatory claims. */
+ b = jwt_builder_new();
+ ck_assert_int_eq(jwt_builder_setkey(b, JWT_ALG_ES256, key), 0);
+ ck_assert_int_eq(jwt_builder_settyp(b, "at+jwt"), 0);
+ set_str(b, "iss", "https://as.example");
+ set_str(b, "sub", "user-1");
+ set_str(b, "aud", "https://rs.example");
+ set_str(b, "client_id", "client-42");
+ set_str(b, "jti", "wU3ifM");
+ ck_assert_int_eq(jwt_builder_time_offset(b, JWT_CLAIM_EXP, 300), 0);
+ token = jwt_builder_generate(b);
+ ck_assert_ptr_nonnull(token);
+
+ /* Verify: pin typ + alg, and assert the mandatory claims are present. */
+ c = jwt_checker_new();
+ ck_assert_int_eq(jwt_checker_setkey(c, JWT_ALG_ES256, key), 0);
+ ck_assert_int_eq(jwt_checker_expect_typ(c, "at+jwt"), 0);
+ ck_assert_int_eq(jwt_checker_setalgs(c, algs, 1), 0);
+ ck_assert_int_eq(jwt_checker_require(c, required, ARRAY_SIZE(required)), 0);
+ ck_assert_int_eq(jwt_checker_claim_set(c, JWT_CLAIM_AUD, "https://rs.example"), 0);
+ ck_assert_int_eq(jwt_checker_verify(c, token), 0);
+
+ jwks_free(set);
+}
+END_TEST
+
+/* ---- RFC 8292 VAPID (Web Push) ---------------------------------------- */
+START_TEST(test_vapid)
+{
+ jwk_set_t *set;
+ const jwk_item_t *key;
+ jwt_builder_auto_t *b = NULL;
+ jwt_checker_auto_t *c = NULL;
+ char_auto *token = NULL;
+ const char *required[] = { "aud", "exp", "sub" };
+ const jwt_alg_t algs[] = { JWT_ALG_ES256 };
+
+ SET_OPS();
+
+ set = load_key();
+ key = jwks_item_get(set, 0);
+
+ /* A plain ES256 JWS over P-256: aud=push origin, sub=contact, short exp. */
+ b = jwt_builder_new();
+ ck_assert_int_eq(jwt_builder_setkey(b, JWT_ALG_ES256, key), 0);
+ set_str(b, "aud", "https://push.example.com");
+ set_str(b, "sub", "mailto:admin@example.com");
+ ck_assert_int_eq(jwt_builder_time_offset(b, JWT_CLAIM_EXP, 12 * 3600), 0);
+ token = jwt_builder_generate(b);
+ ck_assert_ptr_nonnull(token);
+
+ c = jwt_checker_new();
+ ck_assert_int_eq(jwt_checker_setkey(c, JWT_ALG_ES256, key), 0);
+ ck_assert_int_eq(jwt_checker_setalgs(c, algs, 1), 0); /* ES256 only */
+ ck_assert_int_eq(jwt_checker_require(c, required, ARRAY_SIZE(required)), 0);
+ ck_assert_int_eq(jwt_checker_verify(c, token), 0);
+
+ jwks_free(set);
+}
+END_TEST
+
+/* ---- RFC 8225 PASSporT / STIR-SHAKEN (typ "passport") ------------------ */
+START_TEST(test_passport)
+{
+ jwk_set_t *set;
+ const jwk_item_t *key;
+ jwt_builder_auto_t *b = NULL;
+ jwt_checker_auto_t *c = NULL;
+ char_auto *token = NULL;
+ const char *required[] = { "iat", "orig", "dest" };
+ const jwt_alg_t algs[] = { JWT_ALG_ES256 };
+ jwt_value_t v;
+
+ SET_OPS();
+
+ set = load_key();
+ key = jwks_item_get(set, 0);
+
+ b = jwt_builder_new();
+ ck_assert_int_eq(jwt_builder_setkey(b, JWT_ALG_ES256, key), 0);
+ ck_assert_int_eq(jwt_builder_settyp(b, "passport"), 0);
+ set_str(b, "attest", "A");
+ /* orig/dest are JSON objects per RFC 8225. */
+ jwt_set_SET_JSON(&v, "orig", "{\"tn\":\"12155551212\"}");
+ ck_assert_int_eq(jwt_builder_claim_set(b, &v), JWT_VALUE_ERR_NONE);
+ jwt_set_SET_JSON(&v, "dest", "{\"tn\":[\"12155551213\"]}");
+ ck_assert_int_eq(jwt_builder_claim_set(b, &v), JWT_VALUE_ERR_NONE);
+ token = jwt_builder_generate(b);
+ ck_assert_ptr_nonnull(token);
+
+ c = jwt_checker_new();
+ ck_assert_int_eq(jwt_checker_setkey(c, JWT_ALG_ES256, key), 0);
+ ck_assert_int_eq(jwt_checker_expect_typ(c, "passport"), 0);
+ ck_assert_int_eq(jwt_checker_setalgs(c, algs, 1), 0);
+ ck_assert_int_eq(jwt_checker_require(c, required, ARRAY_SIZE(required)), 0);
+ ck_assert_int_eq(jwt_checker_verify(c, token), 0);
+
+ jwks_free(set);
+}
+END_TEST
+
+/* ---- OpenID4VCI key proof (typ "openid4vci-proof+jwt") ----------------- */
+START_TEST(test_openid4vci_proof)
+{
+ jwk_set_t *set;
+ const jwk_item_t *key;
+ jwt_builder_auto_t *b = NULL;
+ jwt_checker_auto_t *c = NULL;
+ char_auto *jkt = NULL;
+ char_auto *token = NULL;
+ jwt_value_t v;
+ const jwt_alg_t algs[] = { JWT_ALG_ES256 };
+
+ SET_OPS();
+
+ set = load_key();
+ key = jwks_item_get(set, 0);
+ jkt = jwks_item_thumbprint(key, JWK_THUMBPRINT_SHA256);
+
+ /* The proof's signing key IS the header "jwk"; the credential request
+ * binds the credential to that key (its thumbprint here). */
+ b = jwt_builder_new();
+ ck_assert_int_eq(jwt_builder_setkey(b, JWT_ALG_ES256, key), 0);
+ ck_assert_int_eq(jwt_builder_settyp(b, "openid4vci-proof+jwt"), 0);
+ jwt_set_SET_JSON(&v, "jwk", PUB_JWK_P256);
+ ck_assert_int_eq(jwt_builder_header_set(b, &v), JWT_VALUE_ERR_NONE);
+ set_str(b, "iss", "client-42");
+ set_str(b, "aud", "https://issuer.example");
+ set_str(b, "nonce", "c_nonce_from_issuer");
+ token = jwt_builder_generate(b);
+ ck_assert_ptr_nonnull(token);
+
+ c = jwt_checker_new();
+ ck_assert_int_eq(jwt_checker_expect_typ(c, "openid4vci-proof+jwt"), 0);
+ ck_assert_int_eq(jwt_checker_setalgs(c, algs, 1), 0);
+ ck_assert_int_eq(jwt_checker_enable_embedded_jwk(c,
+ JWK_THUMBPRINT_SHA256, jkt), 0);
+ ck_assert_int_eq(jwt_checker_verify(c, token), 0);
+
+ jwks_free(set);
+}
+END_TEST
+
+/* ---- RFC 9449 DPoP (typ "dpop+jwt") ----------------------------------- */
+struct dpop_ctx {
+ const char *access_token;
+ int ok;
+};
+
+static int dpop_cb(jwt_t *jwt, jwt_config_t *config)
+{
+ struct dpop_ctx *d = config->ctx;
+ jwt_value_t v;
+ char *ath;
+ const char *htm, *got_ath;
+
+ jwt_set_GET_STR(&v, "htm");
+ if (jwt_claim_get(jwt, &v) != JWT_VALUE_ERR_NONE)
+ return 1;
+ htm = v.str_val;
+
+ jwt_set_GET_STR(&v, "ath");
+ if (jwt_claim_get(jwt, &v) != JWT_VALUE_ERR_NONE)
+ return 1;
+ got_ath = v.str_val;
+
+ /* ath = base64url(SHA-256(access_token)). */
+ ath = jwt_token_hash(d->access_token, JWK_THUMBPRINT_SHA256);
+ d->ok = (ath != NULL && !strcmp(ath, got_ath) && !strcmp(htm, "POST"));
+ free(ath);
+
+ return 0;
+}
+
+START_TEST(test_dpop)
+{
+ jwk_set_t *set;
+ const jwk_item_t *key;
+ jwt_builder_auto_t *atb = NULL, *pb = NULL;
+ jwt_checker_auto_t *c = NULL;
+ char_auto *jkt = NULL;
+ char_auto *access_token = NULL;
+ char_auto *ath = NULL;
+ char_auto *proof = NULL;
+ jwt_value_t v;
+ struct dpop_ctx d;
+
+ SET_OPS();
+
+ set = load_key();
+ key = jwks_item_get(set, 0);
+ jkt = jwks_item_thumbprint(key, JWK_THUMBPRINT_SHA256);
+
+ /* The AS issues an access token bound to the holder key via cnf.jkt. */
+ atb = jwt_builder_new();
+ ck_assert_int_eq(jwt_builder_setkey(atb, JWT_ALG_ES256, key), 0);
+ ck_assert_int_eq(jwt_builder_setcnf_jkt(atb, key), 0);
+ set_str(atb, "sub", "user-1");
+ access_token = jwt_builder_generate(atb);
+ ck_assert_ptr_nonnull(access_token);
+
+ ath = jwt_token_hash(access_token, JWK_THUMBPRINT_SHA256);
+ ck_assert_ptr_nonnull(ath);
+
+ /* The client makes a DPoP proof carrying its key in the header. */
+ pb = jwt_builder_new();
+ ck_assert_int_eq(jwt_builder_setkey(pb, JWT_ALG_ES256, key), 0);
+ ck_assert_int_eq(jwt_builder_settyp(pb, "dpop+jwt"), 0);
+ jwt_set_SET_JSON(&v, "jwk", PUB_JWK_P256);
+ ck_assert_int_eq(jwt_builder_header_set(pb, &v), JWT_VALUE_ERR_NONE);
+ set_str(pb, "htm", "POST");
+ set_str(pb, "htu", "https://rs.example/resource");
+ set_str(pb, "jti", "Xy123");
+ set_str(pb, "ath", ath);
+ proof = jwt_builder_generate(pb);
+ ck_assert_ptr_nonnull(proof);
+
+ /* The RS confirms the proof's self-key against the AT's cnf.jkt and that
+ * its ath binds to the presented access token. */
+ memset(&d, 0, sizeof(d));
+ d.access_token = access_token;
+
+ c = jwt_checker_new();
+ ck_assert_int_eq(jwt_checker_expect_typ(c, "dpop+jwt"), 0);
+ {
+ const jwt_alg_t algs[] = { JWT_ALG_ES256 };
+ ck_assert_int_eq(jwt_checker_setalgs(c, algs, 1), 0);
+ }
+ ck_assert_int_eq(jwt_checker_enable_embedded_jwk(c,
+ JWK_THUMBPRINT_SHA256, jkt), 0);
+ ck_assert_int_eq(jwt_checker_setcb(c, dpop_cb, &d), 0);
+ ck_assert_int_eq(jwt_checker_verify(c, proof), 0);
+ ck_assert_int_eq(d.ok, 1);
+
+ jwks_free(set);
+}
+END_TEST
+
+/* ---- RFC 8705 OAuth 2.0 mutual-TLS certificate-bound token ------------- */
+struct mtls_ctx {
+ const char *presented_x5t; /* SHA-256 thumbprint of the client cert */
+ int ok;
+};
+
+static int mtls_cb(jwt_t *jwt, jwt_config_t *config)
+{
+ struct mtls_ctx *m = config->ctx;
+ char *bound = jwt_get_cnf(jwt, "x5t#S256");
+
+ /* The token is accepted only if its cnf.x5t#S256 equals the thumbprint
+ * of the certificate presented in the TLS handshake (computed by the
+ * caller / TLS terminator). */
+ m->ok = (bound != NULL && !strcmp(bound, m->presented_x5t));
+ free(bound);
+
+ return 0;
+}
+
+START_TEST(test_mtls)
+{
+ jwk_set_t *set;
+ const jwk_item_t *key;
+ jwt_builder_auto_t *b = NULL;
+ jwt_checker_auto_t *c = NULL;
+ char_auto *token = NULL;
+ struct mtls_ctx m;
+ const char *cert_thumb = "bwcK0esc3ACC3DB2Y5_lESsXE8u9ie-9UWlCEx4dyk8";
+
+ SET_OPS();
+
+ set = load_key();
+ key = jwks_item_get(set, 0);
+
+ /* The AS binds the access token to the client certificate. */
+ b = jwt_builder_new();
+ ck_assert_int_eq(jwt_builder_setkey(b, JWT_ALG_ES256, key), 0);
+ ck_assert_int_eq(jwt_builder_settyp(b, "at+jwt"), 0);
+ ck_assert_int_eq(jwt_builder_setcnf(b, "x5t#S256", cert_thumb), 0);
+ set_str(b, "iss", "https://as.example");
+ set_str(b, "sub", "user-1");
+ token = jwt_builder_generate(b);
+ ck_assert_ptr_nonnull(token);
+
+ memset(&m, 0, sizeof(m));
+ m.presented_x5t = cert_thumb;
+
+ c = jwt_checker_new();
+ ck_assert_int_eq(jwt_checker_setkey(c, JWT_ALG_ES256, key), 0);
+ ck_assert_int_eq(jwt_checker_expect_typ(c, "at+jwt"), 0);
+ ck_assert_int_eq(jwt_checker_setcb(c, mtls_cb, &m), 0);
+ ck_assert_int_eq(jwt_checker_verify(c, token), 0);
+ ck_assert_int_eq(m.ok, 1);
+
+ jwks_free(set);
+}
+END_TEST
+
+/* ---- ETSI 119 182-1 JAdES: detached payload + x5c cert chain ----------- */
+struct jades_ctx {
+ int has_x5c;
+};
+
+static int jades_cb(jwt_t *jwt, jwt_config_t *config)
+{
+ struct jades_ctx *j = config->ctx;
+ jwt_value_t v;
+
+ /* The signing certificate chain rides in the protected "x5c" header. */
+ jwt_set_GET_JSON(&v, "x5c");
+ if (jwt_header_get(jwt, &v) == JWT_VALUE_ERR_NONE) {
+ j->has_x5c = (v.json_val != NULL &&
+ strstr(v.json_val, "MII") != NULL);
+ free(v.json_val);
+ }
+
+ return 0;
+}
+
+START_TEST(test_jades)
+{
+ jwk_set_t *set;
+ const jwk_item_t *key;
+ jwt_builder_auto_t *b = NULL;
+ jwt_checker_auto_t *c = NULL;
+ char_auto *token = NULL;
+ jwt_value_t v;
+ struct jades_ctx j;
+ const unsigned char payload[] = "JAdES signs this detached document";
+
+ SET_OPS();
+
+ set = load_key();
+ key = jwks_item_get(set, 0);
+
+ /* Sign a detached payload; carry the cert chain in x5c. */
+ b = jwt_builder_new();
+ ck_assert_int_eq(jwt_builder_setkey(b, JWT_ALG_ES256, key), 0);
+ ck_assert_int_eq(jwt_builder_setpayload(b, payload,
+ sizeof(payload) - 1), 0);
+ /* The document is opaque, not JSON claims: sign it unencoded (RFC 7797). */
+ ck_assert_int_eq(jwt_builder_setb64(b, 0), 0);
+ ck_assert_int_eq(jwt_builder_set_detached(b, 1), 0);
+ jwt_set_SET_JSON(&v, "x5c", "[\"MIIBdummyLeafCertBase64==\"]");
+ ck_assert_int_eq(jwt_builder_header_set(b, &v), JWT_VALUE_ERR_NONE);
+ token = jwt_builder_generate(b);
+ ck_assert_ptr_nonnull(token);
+
+ memset(&j, 0, sizeof(j));
+
+ c = jwt_checker_new();
+ ck_assert_int_eq(jwt_checker_setkey(c, JWT_ALG_ES256, key), 0);
+ ck_assert_int_eq(jwt_checker_setcb(c, jades_cb, &j), 0);
+ /* The detached payload is supplied out of band. */
+ ck_assert_int_eq(jwt_checker_verify_detached(c, token, payload,
+ sizeof(payload) - 1), 0);
+ ck_assert_int_eq(j.has_x5c, 1);
+
+ jwks_free(set);
+}
+END_TEST
+
+static Suite *libjwt_suite(const char *title)
+{
+ Suite *s;
+ TCase *tc_core;
+ int i = ARRAY_SIZE(jwt_test_ops);
+
+ s = suite_create(title);
+
+ tc_core = tcase_create("jwt_profiles");
+
+ tcase_add_loop_test(tc_core, test_at_jwt, 0, i);
+ tcase_add_loop_test(tc_core, test_vapid, 0, i);
+ tcase_add_loop_test(tc_core, test_passport, 0, i);
+ tcase_add_loop_test(tc_core, test_openid4vci_proof, 0, i);
+ tcase_add_loop_test(tc_core, test_dpop, 0, i);
+ tcase_add_loop_test(tc_core, test_mtls, 0, i);
+ tcase_add_loop_test(tc_core, test_jades, 0, i);
+
+ tcase_set_timeout(tc_core, 30);
+
+ suite_add_tcase(s, tc_core);
+
+ return s;
+}
+
+int main(void)
+{
+ JWT_TEST_MAIN("LibJWT application profiles (#317)");
+}
diff --git a/tests/jwt_require.c b/tests/jwt_require.c
new file mode 100644
index 0000000..2ce355b
--- /dev/null
+++ b/tests/jwt_require.c
@@ -0,0 +1,251 @@
+/* Public domain, no copyright. Use at your own risk. */
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "jwt_tests.h"
+
+/* Tests for jwt_checker_require(): assert a set of claims is PRESENT in a token,
+ * independent of any value match (the RFC 9068 mandatory-claims discipline). A
+ * token is built with iss/aud/sub/client_id/iat/exp but NO jti; requiring a
+ * present set verifies, requiring an absent claim (jti) is rejected. */
+
+static jwk_set_t *load_key(void)
+{
+ jwk_set_t *set = jwks_create_fromfile(KEYDIR "/ec_key_prime256v1.json");
+
+ ck_assert_ptr_nonnull(set);
+ return set;
+}
+
+/* An ES256 token carrying iss/aud/sub/client_id/iat/exp, optionally jti. */
+static char *make_token(const jwk_item_t *key, int with_jti)
+{
+ jwt_builder_auto_t *b = jwt_builder_new();
+ jwt_value_t v;
+
+ ck_assert_ptr_nonnull(b);
+ ck_assert_int_eq(jwt_builder_setkey(b, JWT_ALG_ES256, key), 0);
+
+ jwt_set_SET_STR(&v, "iss", "https://issuer.example");
+ ck_assert_int_eq(jwt_builder_claim_set(b, &v), JWT_VALUE_ERR_NONE);
+ jwt_set_SET_STR(&v, "aud", "https://api.example");
+ ck_assert_int_eq(jwt_builder_claim_set(b, &v), JWT_VALUE_ERR_NONE);
+ jwt_set_SET_STR(&v, "sub", "user-1");
+ ck_assert_int_eq(jwt_builder_claim_set(b, &v), JWT_VALUE_ERR_NONE);
+ jwt_set_SET_STR(&v, "client_id", "client-42");
+ ck_assert_int_eq(jwt_builder_claim_set(b, &v), JWT_VALUE_ERR_NONE);
+ if (with_jti) {
+ jwt_set_SET_STR(&v, "jti", "id-1");
+ ck_assert_int_eq(jwt_builder_claim_set(b, &v), JWT_VALUE_ERR_NONE);
+ }
+
+ /* A future exp (an int claim, to prove presence is type-agnostic). */
+ ck_assert_int_eq(jwt_builder_time_offset(b, JWT_CLAIM_EXP, 3600), 0);
+
+ return jwt_builder_generate(b);
+}
+
+START_TEST(test_require_present)
+{
+ jwk_set_t *set;
+ const jwk_item_t *key;
+ jwt_checker_auto_t *checker = NULL;
+ char_auto *token = NULL;
+ const char *names[] = { "iss", "exp", "aud", "sub", "client_id", "iat" };
+
+ SET_OPS();
+
+ set = load_key();
+ key = jwks_item_get(set, 0);
+ token = make_token(key, 0);
+ ck_assert_ptr_nonnull(token);
+
+ checker = jwt_checker_new();
+ ck_assert_int_eq(jwt_checker_setkey(checker, JWT_ALG_ES256, key), 0);
+ ck_assert_int_eq(jwt_checker_require(checker, names, ARRAY_SIZE(names)), 0);
+ ck_assert_int_eq(jwt_checker_verify(checker, token), 0);
+
+ jwks_free(set);
+}
+END_TEST
+
+START_TEST(test_require_missing_rejected)
+{
+ jwk_set_t *set;
+ const jwk_item_t *key;
+ jwt_checker_auto_t *checker = NULL;
+ char_auto *token = NULL;
+ const char *names[] = { "iss", "jti" }; /* jti is absent */
+
+ SET_OPS();
+
+ set = load_key();
+ key = jwks_item_get(set, 0);
+ token = make_token(key, 0);
+ ck_assert_ptr_nonnull(token);
+
+ checker = jwt_checker_new();
+ ck_assert_int_eq(jwt_checker_setkey(checker, JWT_ALG_ES256, key), 0);
+ ck_assert_int_eq(jwt_checker_require(checker, names, ARRAY_SIZE(names)), 0);
+ ck_assert_int_ne(jwt_checker_verify(checker, token), 0);
+ /* The error names the missing claim. */
+ ck_assert_ptr_nonnull(strstr(jwt_checker_error_msg(checker), "jti"));
+
+ jwks_free(set);
+}
+END_TEST
+
+START_TEST(test_require_present_with_jti)
+{
+ jwk_set_t *set;
+ const jwk_item_t *key;
+ jwt_checker_auto_t *checker = NULL;
+ char_auto *token = NULL;
+ const char *names[] = { "jti" };
+
+ SET_OPS();
+
+ set = load_key();
+ key = jwks_item_get(set, 0);
+ token = make_token(key, 1); /* with jti */
+ ck_assert_ptr_nonnull(token);
+
+ checker = jwt_checker_new();
+ ck_assert_int_eq(jwt_checker_setkey(checker, JWT_ALG_ES256, key), 0);
+ ck_assert_int_eq(jwt_checker_require(checker, names, ARRAY_SIZE(names)), 0);
+ ck_assert_int_eq(jwt_checker_verify(checker, token), 0);
+
+ jwks_free(set);
+}
+END_TEST
+
+START_TEST(test_require_clear)
+{
+ jwk_set_t *set;
+ const jwk_item_t *key;
+ jwt_checker_auto_t *checker = NULL;
+ char_auto *token = NULL;
+ const char *names[] = { "jti" };
+
+ SET_OPS();
+
+ set = load_key();
+ key = jwks_item_get(set, 0);
+ token = make_token(key, 0); /* no jti */
+ ck_assert_ptr_nonnull(token);
+
+ checker = jwt_checker_new();
+ ck_assert_int_eq(jwt_checker_setkey(checker, JWT_ALG_ES256, key), 0);
+
+ /* Require jti, then clear the requirement: the absent jti no longer fails. */
+ ck_assert_int_eq(jwt_checker_require(checker, names, 1), 0);
+ ck_assert_int_eq(jwt_checker_require(checker, NULL, 0), 0);
+ ck_assert_int_eq(jwt_checker_verify(checker, token), 0);
+
+ jwks_free(set);
+}
+END_TEST
+
+START_TEST(test_require_errors)
+{
+ jwk_set_t *set;
+ const jwk_item_t *key;
+ jwt_checker_auto_t *checker = NULL;
+ char_auto *token = NULL;
+ const char *good[] = { "iss" };
+ const char *bad[] = { "iss", "" }; /* empty name */
+
+ SET_OPS();
+
+ set = load_key();
+ key = jwks_item_get(set, 0);
+ token = make_token(key, 0);
+ ck_assert_ptr_nonnull(token);
+
+ ck_assert_int_ne(jwt_checker_require(NULL, good, 1), 0);
+
+ checker = jwt_checker_new();
+ ck_assert_int_eq(jwt_checker_setkey(checker, JWT_ALG_ES256, key), 0);
+
+ /* An empty name in the list is rejected and leaves no requirement set. */
+ ck_assert_int_ne(jwt_checker_require(checker, bad, ARRAY_SIZE(bad)), 0);
+ ck_assert_int_eq(jwt_checker_verify(checker, token), 0);
+
+ jwks_free(set);
+}
+END_TEST
+
+/* Required-claims must be enforced on the JSON Serialization too, not only the
+ * compact form (else a token could dodge the check by being JSON-wrapped). */
+START_TEST(test_require_json_serialization)
+{
+ jwk_set_t *set;
+ const jwk_item_t *key;
+ jwt_builder_auto_t *b = NULL;
+ jwt_checker_auto_t *c1 = NULL, *c2 = NULL;
+ char_auto *token = NULL;
+ jwt_value_t v;
+ const char *need_iss[] = { "iss" };
+ const char *need_jti[] = { "jti" };
+
+ SET_OPS();
+
+ set = load_key();
+ key = jwks_item_get(set, 0);
+
+ /* A Flattened JSON token (RFC 7515 7.2.2) with iss but no jti. */
+ b = jwt_builder_new();
+ ck_assert_int_eq(jwt_builder_setkey(b, JWT_ALG_ES256, key), 0);
+ ck_assert_int_eq(jwt_builder_set_format(b, JWT_FORMAT_JSON_FLAT), 0);
+ jwt_set_SET_STR(&v, "iss", "https://issuer.example");
+ ck_assert_int_eq(jwt_builder_claim_set(b, &v), JWT_VALUE_ERR_NONE);
+ token = jwt_builder_generate(b);
+ ck_assert_ptr_nonnull(token);
+ ck_assert_int_eq(token[0], '{'); /* a JSON serialization */
+
+ /* Present passes... */
+ c1 = jwt_checker_new();
+ ck_assert_int_eq(jwt_checker_setkey(c1, JWT_ALG_ES256, key), 0);
+ ck_assert_int_eq(jwt_checker_require(c1, need_iss, 1), 0);
+ ck_assert_int_eq(jwt_checker_verify(c1, token), 0);
+
+ /* ...absent is rejected (the JSON path enforces it). */
+ c2 = jwt_checker_new();
+ ck_assert_int_eq(jwt_checker_setkey(c2, JWT_ALG_ES256, key), 0);
+ ck_assert_int_eq(jwt_checker_require(c2, need_jti, 1), 0);
+ ck_assert_int_ne(jwt_checker_verify(c2, token), 0);
+
+ jwks_free(set);
+}
+END_TEST
+
+static Suite *libjwt_suite(const char *title)
+{
+ Suite *s;
+ TCase *tc_core;
+ int i = ARRAY_SIZE(jwt_test_ops);
+
+ s = suite_create(title);
+
+ tc_core = tcase_create("jwt_require");
+
+ tcase_add_loop_test(tc_core, test_require_present, 0, i);
+ tcase_add_loop_test(tc_core, test_require_missing_rejected, 0, i);
+ tcase_add_loop_test(tc_core, test_require_present_with_jti, 0, i);
+ tcase_add_loop_test(tc_core, test_require_clear, 0, i);
+ tcase_add_loop_test(tc_core, test_require_json_serialization, 0, i);
+ tcase_add_loop_test(tc_core, test_require_errors, 0, i);
+
+ tcase_set_timeout(tc_core, 30);
+
+ suite_add_tcase(s, tc_core);
+
+ return s;
+}
+
+int main(void)
+{
+ JWT_TEST_MAIN("LibJWT required-claims (jwt_checker_require)");
+}