Merge pull request #340 from artgins/exact-alg-pin-verify
jwt: pin the exact algorithm in both verify paths (RFC 8725 3.1)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 7bd3a6f..fc7646a 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -446,7 +446,7 @@
list (APPEND UNIT_TESTS jwt_claims jwt_cnf)
# Security
- list (APPEND UNIT_TESTS jwt_security)
+ list (APPEND UNIT_TESTS jwt_security jwt_alg_downgrade)
foreach (TEST ${UNIT_TESTS})
jwt_add_test(NAME ${TEST})
diff --git a/libjwt/jwt-verify.c b/libjwt/jwt-verify.c
index c3c4e72..6db7b50 100644
--- a/libjwt/jwt-verify.c
+++ b/libjwt/jwt-verify.c
@@ -630,6 +630,27 @@
// LCOV_EXCL_STOP
}
+ /* @rfc{8725,3.1} Pin to the EXACT algorithm, not just the key family.
+ * The alg-vs-alg checks above compare config and key to each OTHER, but
+ * on the common pinned path -- both config->alg and config->key->alg set
+ * and equal -- none of them compares the token's own alg (jwt->alg). The
+ * jwt_alg_required_kty() backstop below is only family-granular (every
+ * RSn/PSn alg maps to JWK_KEY_TYPE_RSA, every ESn to EC), so without this
+ * a token presenting e.g. "RS512" verifies against a key the caller
+ * pinned as "RS256": same family, so the kty guard passes. Not a forgery
+ * (the signature must still be valid), but a pinned verifier accepting an
+ * algorithm it never pinned is a policy/conformance gap. Require exact
+ * agreement with whichever alg the caller pinned. Purely additive: no
+ * legitimately-pinned token is newly rejected. */
+ if (config->alg != JWT_ALG_NONE && jwt->alg != config->alg) {
+ jwt_write_error(jwt, "JWT alg does not match pinned config alg");
+ return 1;
+ } else if (config->key->alg != JWT_ALG_NONE &&
+ jwt->alg != config->key->alg) {
+ jwt_write_error(jwt, "JWT alg does not match pinned key alg");
+ return 1;
+ }
+
/* Algorithm is now bound (jwt->alg). Defensively confirm that the
* JWK's actual key type can carry it. This blocks algorithm
* confusion (GHSA-q843-6q5f-w55g) even if a malformed JWK has an
@@ -794,6 +815,16 @@
if (jwt_alg_required_kty(s->alg) != jwks_item_kty(key))
return;
+ /* @rfc{8725,3.1} Exact-alg pin: if the candidate key declares an "alg",
+ * the signature's alg must match it exactly, not merely its family (the
+ * kty gate above is family-granular -- every RSn/PSn maps to RSA). This
+ * is the JSON-path mirror of the Compact pinned-alg check and the keyring
+ * scan's own "kalg != s->alg" skip, applied at the single choke point so
+ * the explicit-key and "kid"-matched branches are covered too: without
+ * it a key declaring "RS256" would verify an "RS512" signature. */
+ if (jwks_item_alg(key) != JWT_ALG_NONE && jwks_item_alg(key) != s->alg)
+ return;
+
jwt->alg = s->alg;
jwt->key = key;
jwt->error = 0;
diff --git a/tests/jwt_alg_downgrade.c b/tests/jwt_alg_downgrade.c
new file mode 100644
index 0000000..95d5891
--- /dev/null
+++ b/tests/jwt_alg_downgrade.c
@@ -0,0 +1,125 @@
+/* Public domain, no copyright. Use at your own risk. */
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "jwt_tests.h"
+
+/* Same-family algorithm downgrade in the Compact verify path.
+ *
+ * On the pinned path of __verify_config_post() -- both config->alg and
+ * config->key->alg set and equal -- the token's own alg (jwt->alg) was never
+ * compared against the pinned alg, and the jwt_alg_required_kty() backstop is
+ * only family-granular (every RSn/PSn alg maps to RSA). So a token presenting
+ * "RS512" verified against a key pinned as "RS256" -- same family, valid
+ * signature -- was accepted, i.e. a pinned verifier accepted an algorithm it
+ * never pinned. Not a forgery; a policy/conformance gap. These tests pin it
+ * shut and guard the legitimate (matching) cases against over-rejection.
+ *
+ * Runs under each crypto backend (SET_OPS); RSA only, so JSON-backend-agnostic.
+ */
+
+static jwk_set_t *load(const char *file)
+{
+ char *path;
+ jwk_set_t *set;
+ int ret;
+
+ ret = asprintf(&path, KEYDIR "/%s", file);
+ ck_assert_int_gt(ret, 0);
+ set = jwks_create_fromfile(path);
+ free(path);
+ ck_assert_ptr_nonnull(set);
+
+ return set;
+}
+
+/* Sign a token with the given alg and serialization using a key that carries no
+ * "alg" hint (so the builder lets us choose any RSA alg for the same modulus). */
+static char *sign(const jwk_item_t *key, jwt_alg_t alg, jwt_serialization_t fmt)
+{
+ jwt_builder_auto_t *b = jwt_builder_new();
+
+ ck_assert_int_eq(jwt_builder_setkey(b, alg, key), 0);
+ ck_assert_int_eq(jwt_builder_set_format(b, fmt), 0);
+
+ return jwt_builder_generate(b);
+}
+
+/* The same downgrade check on both serializations: the Compact path runs
+ * through __verify_config_post(), the JSON path through verify_entry() /
+ * try_candidate(). jwt_checker_verify() auto-detects the format. */
+START_TEST(test_same_family_downgrade)
+{
+ jwk_set_t *sks, *vks;
+ const jwk_item_t *skey, *vkey;
+ jwt_serialization_t fmts[] = { JWT_FORMAT_COMPACT, JWT_FORMAT_JSON_FLAT };
+ size_t i;
+
+ SET_OPS();
+
+ /* Same 2048-bit RSA modulus, two views: one with no "alg" (to sign any
+ * RSA alg), one pinned to RS256 (to verify). */
+ sks = load("rsa_key_2048_no_alg.json");
+ skey = jwks_item_get(sks, 0);
+ ck_assert_ptr_nonnull(skey);
+
+ vks = load("rsa_key_2048.json"); /* carries "alg":"RS256" */
+ vkey = jwks_item_get(vks, 0);
+ ck_assert_ptr_nonnull(vkey);
+
+ for (i = 0; i < ARRAY_SIZE(fmts); i++) {
+ char_auto *rs512 = NULL, *rs256 = NULL;
+ jwt_checker_auto_t *c1 = NULL, *c2 = NULL, *c3 = NULL;
+
+ /* A genuinely RS512-signed token (valid signature on this key). */
+ rs512 = sign(skey, JWT_ALG_RS512, fmts[i]);
+ ck_assert_ptr_nonnull(rs512);
+
+ /* THE GAP: pin RS256, present RS512. The signature is otherwise
+ * valid, so without the alg pin this verifies; it must be rejected. */
+ c1 = jwt_checker_new();
+ ck_assert_int_eq(jwt_checker_setkey(c1, JWT_ALG_RS256, vkey), 0);
+ ck_assert_int_ne(jwt_checker_verify(c1, rs512), 0);
+
+ /* Positive control 1: pin RS256, present RS256 -> accepted. */
+ rs256 = sign(skey, JWT_ALG_RS256, fmts[i]);
+ ck_assert_ptr_nonnull(rs256);
+ c2 = jwt_checker_new();
+ ck_assert_int_eq(jwt_checker_setkey(c2, JWT_ALG_RS256, vkey), 0);
+ ck_assert_int_eq(jwt_checker_verify(c2, rs256), 0);
+
+ /* Positive control 2: legitimately accept RS512 (via the no-alg key,
+ * which pins no alg) -> the pin does not over-reject. */
+ c3 = jwt_checker_new();
+ ck_assert_int_eq(jwt_checker_setkey(c3, JWT_ALG_RS512, skey), 0);
+ ck_assert_int_eq(jwt_checker_verify(c3, rs512), 0);
+ }
+
+ jwks_free(sks);
+ jwks_free(vks);
+}
+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_alg_downgrade");
+
+ tcase_add_loop_test(tc_core, test_same_family_downgrade, 0, i);
+
+ tcase_set_timeout(tc_core, 30);
+ suite_add_tcase(s, tc_core);
+
+ return s;
+}
+
+int main(void)
+{
+ JWT_TEST_MAIN("LibJWT same-family algorithm downgrade in verify");
+}
diff --git a/tests/keys/rsa_key_2048_no_alg.json b/tests/keys/rsa_key_2048_no_alg.json
new file mode 100644
index 0000000..24f1198
--- /dev/null
+++ b/tests/keys/rsa_key_2048_no_alg.json
@@ -0,0 +1,17 @@
+ {
+ "use": "sig",
+ "key_ops": [
+ "verify",
+ "sign"
+ ],
+ "kid": "28c243a4-a9ac-473f-ab61-d3a68f408279",
+ "kty": "RSA",
+ "n": "wtpMAM4l1H995oqlqdMhuqNuffp4-4aUCwuFE9B5s9MJr63gyf8jW0oDr7Mb1Xb8y9iGkWfhouZqNJbMFry-iBs-z2TtJF06vbHQZzajDsdux3XVfXv9v6dDIImyU24MsGNkpNt0GISaaiqv51NMZQX0miOXXWdkQvWTZFXhmsFCmJLE67oQFSar4hzfAaCulaMD-b3Mcsjlh0yvSq7g6swiIasEU3qNLKaJAZEzfywroVYr3BwM1IiVbQeKgIkyPS_85M4Y6Ss_T-OWi1OeK49NdYBvFP-hNVEoeZzJz5K_nd6C35IX0t2bN5CVXchUFmaUMYk2iPdhXdsC720tBw",
+ "e": "AQAB",
+ "d": "D8dTnkETSSjlzhRuI9loAtAXM3Zj86JLPLW7GgaoxEoTn7lJ2bGicFMHB2ROnbOb9vnas82gtOtJsGaBslmoaCckp_C5T1eJWTEb-i-vdpPpwZcmKZovyyRFSE4-NYlU17fEv6DRvuaGBpDcW7QgHJIl45F8QWEM-msee2KE-V4Gz_9vAQ-sOlvsb4mJP1tJIBx9Lb5loVREwCRy2Ha9tnWdDNar8EYkOn8si4snPT-E3ZCy8mlcZyUkZeiS_HdtydxZfoiwrSRYamd1diQpPhWCeRteQ802a7ds0Y2YzgfFUaYjNuRQm7zA__hwbXS7ELPyNMU15N00bajlG0tUOQ",
+ "p": "5y8tNZdtDp3lugNnCAyA8mIcuTu7rpbGhLSUXlJXIxAzJIp_G--u6tlWR2__92R_oqZ0WAclFMmFIXTrNg2ERhngHm5x_MKU0-BQmMVLCFKQ3we0cH3QTjYGinsEyc_xT7MMtWHtG-R9nOr1YpthWd9x0HKGd8LxRashlZzM6Ts",
+ "q": "18S_ecqMVBimvLzM3BNwp9NbM4dHRatLKnBaTPh1i1a_OAiLbhvR-i9CmkrRBCDoibMTlaXF3xRYbCbzhoTAOA0E8B-Frl5l6dT6FTA5VeuT2Kr4c8C-PkIb2Ttm7KxLpyE1zplOwiOpIpZwDdy7j-B26mYKF_ZajyN9VWMy7qU",
+ "dp": "YRhyT3DSz_HHG1H0gu_ldGd6kt2gnNocdH33VooUqNhT8oPskMog1-gCEazbf4cJCEIK2THfBBUDQiL96szQgjS56W4Pl84NfdNXZmJuegdbayCsSxa8VyzfoGe8ghpAym1z5_ZCBJX5n98awphp0bpD7f07tq78cHtIdrLNaSM",
+ "dq": "yC0XOyWn1Old32IFaPN8I6cZSI_rln4ZaRD9JcWoP5JGKvT6bjfPMZ2g28Ynbf4d3opN1BsMnS6h7gyhB56nOhkSCLgl7KRVRn-5V-j6eHTrICtV_wXFObtZXMsYbOBX-4D7C2X9xG0TICyTXrj3Jb8oc8Qg_yQl1gAl6g7zFKU",
+ "qi": "ERMrkFR7KGYZG1eFNRdVmJMq-Ibxyw8ks_CbiI-n3yUyk1U8962ol2Q0T4qjBmb26L5rrhNQhneM4e8mo9FXLlQapYkPvkdrqW0Bp72A_UNAvcGTmN7z5OCJGMUutx2hmEAlrYmpLKS8pM_p9zpKtEOtzsP5GMDYVlEp1jYSjzQ"
+ }