blob: 79e1788b760687fa4a5d966af768b28c2cb68ba7 [file] [edit]
/* Public domain, no copyright. Use at your own risk. */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "jwt_tests.h"
/* Tests for ML-DSA (FIPS 204 / RFC 9964) signing. These only do anything in a
* build configured with -DWITH_ML_DSA=ON against a backend that implements it
* (OpenSSL >= 3.5, or a PQC-enabled GnuTLS >= 3.8.10); otherwise
* LIBJWT_HAVE_ML_DSA is undefined and the suite is an intentional no-op so the
* test binary still builds and passes. */
#ifdef LIBJWT_HAVE_ML_DSA
static const struct {
const char *priv; /* private JWK fixture (kty=AKP, pub+priv) */
const char *pub; /* public JWK fixture (kty=AKP, pub only) */
jwt_alg_t alg;
const char *hdr; /* expected base64url JWS protected header */
} variants[] = {
/* hdr is base64url of {"alg":"ML-DSA-NN","typ":"JWT"} */
{ "mldsa_key_44.json", "mldsa_key_44_pub.json", JWT_ALG_ML_DSA_44,
"eyJhbGciOiJNTC1EU0EtNDQiLCJ0eXAiOiJKV1QifQ" },
{ "mldsa_key_65.json", "mldsa_key_65_pub.json", JWT_ALG_ML_DSA_65,
"eyJhbGciOiJNTC1EU0EtNjUiLCJ0eXAiOiJKV1QifQ" },
{ "mldsa_key_87.json", "mldsa_key_87_pub.json", JWT_ALG_ML_DSA_87,
"eyJhbGciOiJNTC1EU0EtODciLCJ0eXAiOiJKV1QifQ" },
};
/* Whether the *active* crypto backend can actually use ML-DSA at runtime.
* Compiled-in support is necessary but not sufficient: GnuTLS only does ML-DSA
* when built against a PQC provider (leancrypto), so probe by loading a known
* AKP private key under the current ops. Lets the success tests run on every
* capable backend (OpenSSL and a PQC-enabled GnuTLS) and skip the rest. */
static int mldsa_supported(void)
{
jwk_set_auto_t *set = NULL;
const jwk_item_t *item;
char *path;
int ok;
if (asprintf(&path, KEYDIR "/mldsa_key_44.json") < 0)
return 0; // LCOV_EXCL_LINE
set = jwks_create_fromfile(path);
free(path);
if (set == NULL)
return 0; // LCOV_EXCL_LINE
item = jwks_item_get(set, 0);
ok = (item != NULL && jwks_item_error(item) == 0 &&
jwks_item_kty(item) == JWK_KEY_TYPE_AKP &&
jwks_item_is_private(item));
return ok;
}
/* Build a token with the private key, verify it with the public key. Runs on
* any backend that can do ML-DSA at runtime (probed via mldsa_supported());
* backends that lack it are exercised by test_mldsa_unsupported below. */
START_TEST(test_mldsa_sign_verify)
{
size_t i;
SET_OPS();
if (!mldsa_supported())
return;
for (i = 0; i < ARRAY_SIZE(variants); i++) {
jwk_set_auto_t *pset = NULL, *vset = NULL;
jwt_builder_auto_t *builder = NULL;
jwt_checker_auto_t *checker = NULL;
const jwk_item_t *priv, *pub;
char_auto *token = NULL;
char *path;
int ret;
/* --- load the private (signing) key --- */
ret = asprintf(&path, KEYDIR "/%s", variants[i].priv);
ck_assert_int_gt(ret, 0);
pset = jwks_create_fromfile(path);
free(path);
ck_assert_ptr_nonnull(pset);
ck_assert_int_eq(jwks_error(pset), 0);
priv = jwks_item_get(pset, 0);
ck_assert_ptr_nonnull(priv);
ck_assert_int_eq(jwks_item_error(priv), 0);
ck_assert_int_eq(jwks_item_kty(priv), JWK_KEY_TYPE_AKP);
ck_assert_int_eq(jwks_item_alg(priv), variants[i].alg);
ck_assert_int_eq(jwks_item_is_private(priv), 1);
/* --- sign --- */
builder = jwt_builder_new();
ck_assert_ptr_nonnull(builder);
ret = jwt_builder_setkey(builder, variants[i].alg, priv);
ck_assert_int_eq(ret, 0);
token = jwt_builder_generate(builder);
if (token == NULL)
fprintf(stderr, "BuildErr[%s]: %s\n",
jwt_alg_str(variants[i].alg),
jwt_builder_error_msg(builder));
ck_assert_ptr_nonnull(token);
/* The emitted protected header must carry the exact JOSE alg
* name: assert the first compact segment, so a wrong/missing
* "alg" string is caught even when both sides agree. */
ck_assert(!strncmp(token, variants[i].hdr,
strlen(variants[i].hdr)));
ck_assert_int_eq(token[strlen(variants[i].hdr)], '.');
/* --- load the public (verifying) key and verify --- */
ret = asprintf(&path, KEYDIR "/%s", variants[i].pub);
ck_assert_int_gt(ret, 0);
vset = jwks_create_fromfile(path);
free(path);
ck_assert_ptr_nonnull(vset);
ck_assert_int_eq(jwks_error(vset), 0);
pub = jwks_item_get(vset, 0);
ck_assert_ptr_nonnull(pub);
ck_assert_int_eq(jwks_item_is_private(pub), 0);
checker = jwt_checker_new();
ck_assert_ptr_nonnull(checker);
ret = jwt_checker_setkey(checker, variants[i].alg, pub);
ck_assert_int_eq(ret, 0);
ret = jwt_checker_verify(checker, token);
if (ret)
fprintf(stderr, "CheckErr[%s]: %s\n",
jwt_alg_str(variants[i].alg),
jwt_checker_error_msg(checker));
ck_assert_int_eq(ret, 0);
}
}
END_TEST
/* A token signed for one variant must not verify against another variant's
* key (anti algorithm-confusion). The configured alg (from the key) differs
* from the token's header alg, so the checker rejects it. */
START_TEST(test_mldsa_cross_variant)
{
jwk_set_auto_t *pset = NULL, *vset = NULL;
jwt_builder_auto_t *builder = NULL;
jwt_checker_auto_t *checker = NULL;
char_auto *token = NULL;
char *path;
int ret;
SET_OPS();
if (!mldsa_supported())
return;
/* Sign with ML-DSA-44. */
ret = asprintf(&path, KEYDIR "/mldsa_key_44.json");
ck_assert_int_gt(ret, 0);
pset = jwks_create_fromfile(path);
free(path);
ck_assert_ptr_nonnull(pset);
builder = jwt_builder_new();
ck_assert_ptr_nonnull(builder);
ret = jwt_builder_setkey(builder, JWT_ALG_ML_DSA_44,
jwks_item_get(pset, 0));
ck_assert_int_eq(ret, 0);
token = jwt_builder_generate(builder);
ck_assert_ptr_nonnull(token);
/* Verify with the ML-DSA-87 public key: must fail. */
ret = asprintf(&path, KEYDIR "/mldsa_key_87_pub.json");
ck_assert_int_gt(ret, 0);
vset = jwks_create_fromfile(path);
free(path);
ck_assert_ptr_nonnull(vset);
checker = jwt_checker_new();
ck_assert_ptr_nonnull(checker);
ret = jwt_checker_setkey(checker, JWT_ALG_ML_DSA_87,
jwks_item_get(vset, 0));
ck_assert_int_eq(ret, 0);
ret = jwt_checker_verify(checker, token);
ck_assert_int_ne(ret, 0);
}
END_TEST
#if defined(HAVE_GNUTLS) || defined(HAVE_MBEDTLS)
/* Backends without ML-DSA (GnuTLS, MbedTLS today) must reject an AKP JWK
* cleanly at parse time via the common null-guard, not crash. Only built when
* such a backend is compiled in, so it never vacuously passes. */
START_TEST(test_mldsa_unsupported)
{
jwk_set_auto_t *set = NULL;
const jwk_item_t *item;
char *path;
int ret;
SET_OPS();
if (mldsa_supported())
return;
ret = asprintf(&path, KEYDIR "/mldsa_key_44.json");
ck_assert_int_gt(ret, 0);
set = jwks_create_fromfile(path);
free(path);
ck_assert_ptr_nonnull(set);
item = jwks_item_get(set, 0);
ck_assert_ptr_nonnull(item);
/* The kty is recognized as AKP, but the active backend cannot use it. */
ck_assert_int_ne(jwks_item_error(item), 0);
ck_assert_ptr_nonnull(strstr(jwks_item_error_msg(item), "ML-DSA"));
}
END_TEST
#endif /* HAVE_GNUTLS || HAVE_MBEDTLS */
/* Malformed AKP JWKs must be rejected with an item error (on any ML-DSA-capable
* backend, i.e. wherever process_mldsa actually runs). */
START_TEST(test_mldsa_parse_errors)
{
static const char *bad[] = {
/* "alg" is required on AKP keys */
"{\"kty\":\"AKP\",\"pub\":\"AAAA\"}",
/* non-string "alg" */
"{\"kty\":\"AKP\",\"alg\":123,\"pub\":\"AAAA\"}",
/* unsupported "alg" */
"{\"kty\":\"AKP\",\"alg\":\"ML-DSA-99\",\"pub\":\"AAAA\"}",
/* neither "pub" nor "priv" */
"{\"kty\":\"AKP\",\"alg\":\"ML-DSA-44\"}",
/* undecodable "pub" */
"{\"kty\":\"AKP\",\"alg\":\"ML-DSA-44\",\"pub\":\"\"}",
/* undecodable "priv" (seed) */
"{\"kty\":\"AKP\",\"alg\":\"ML-DSA-44\",\"priv\":\"\"}",
/* wrong-size "pub" (valid base64url, but not the variant length) */
"{\"kty\":\"AKP\",\"alg\":\"ML-DSA-44\",\"pub\":\"AAAA\"}",
/* wrong-size "priv" seed (valid base64url, but not 32 bytes) */
"{\"kty\":\"AKP\",\"alg\":\"ML-DSA-44\",\"priv\":\"AAAA\"}",
};
size_t i;
SET_OPS();
if (!mldsa_supported())
return;
for (i = 0; i < ARRAY_SIZE(bad); i++) {
jwk_set_auto_t *set = jwks_create(bad[i]);
const jwk_item_t *item;
ck_assert_ptr_nonnull(set);
item = jwks_item_get(set, 0);
ck_assert_ptr_nonnull(item);
ck_assert_int_ne(jwks_item_error(item), 0);
}
}
END_TEST
/* Native key (PEM/DER) -> JWK export, public stripping, and re-import.
* Exercises the key2jwk export path for ML-DSA on any capable backend. */
START_TEST(test_mldsa_export)
{
static const char *pems[] = {
"mldsa_key_44.pem", "mldsa_key_65.pem", "mldsa_key_87.pem",
};
size_t i;
SET_OPS();
if (!mldsa_supported())
return;
for (i = 0; i < ARRAY_SIZE(pems); i++) {
jwk_set_auto_t *set = NULL, *round = NULL;
char_auto *priv_json = NULL, *pub_json = NULL;
const jwk_item_t *item;
char *path;
int ret;
ret = asprintf(&path, KEYDIR "/mldsa-pem/%s", pems[i]);
ck_assert_int_gt(ret, 0);
set = jwks_create_fromkey_file(path, JWK_KEY_NONE);
free(path);
ck_assert_ptr_nonnull(set);
ck_assert_int_eq(jwks_error(set), 0);
item = jwks_item_get(set, 0);
ck_assert_ptr_nonnull(item);
ck_assert_int_eq(jwks_item_error(item), 0);
ck_assert_int_eq(jwks_item_kty(item), JWK_KEY_TYPE_AKP);
ck_assert_int_eq(jwks_item_is_private(item), 1);
/* Private export keeps "pub" and "priv"; public strips "priv". */
priv_json = jwks_item_export(item, 1);
pub_json = jwks_item_export(item, 0);
ck_assert_ptr_nonnull(priv_json);
ck_assert_ptr_nonnull(pub_json);
ck_assert_ptr_nonnull(strstr(priv_json, "\"pub\""));
ck_assert_ptr_nonnull(strstr(priv_json, "\"priv\""));
ck_assert_ptr_nonnull(strstr(pub_json, "\"pub\""));
ck_assert_ptr_null(strstr(pub_json, "\"priv\""));
/* Members must be base64url (no +, /, or = padding). */
ck_assert_ptr_null(strchr(priv_json, '+'));
ck_assert_ptr_null(strchr(priv_json, '/'));
ck_assert_ptr_null(strchr(priv_json, '='));
/* The private export must re-import to a usable private key. */
round = jwks_create(priv_json);
ck_assert_ptr_nonnull(round);
ck_assert_int_eq(jwks_error(round), 0);
item = jwks_item_get(round, 0);
ck_assert_int_eq(jwks_item_kty(item), JWK_KEY_TYPE_AKP);
ck_assert_int_eq(jwks_item_is_private(item), 1);
}
}
END_TEST
/* An ML-DSA private key that retains no FIPS-204 seed (e.g. imported from only
* the expanded private key) cannot be expressed as a private AKP JWK. It must
* downgrade to a clean public export, NOT a private-marked JWK with no "priv". */
START_TEST(test_mldsa_export_seedless)
{
jwk_set_auto_t *set = NULL;
char_auto *priv_json = NULL;
const jwk_item_t *item;
char *path;
int ret;
SET_OPS();
if (!mldsa_supported())
return;
ret = asprintf(&path, KEYDIR "/mldsa-pem/mldsa_key_44_seedless.pem");
ck_assert_int_gt(ret, 0);
set = jwks_create_fromkey_file(path, JWK_KEY_NONE);
free(path);
ck_assert_ptr_nonnull(set);
ck_assert_int_eq(jwks_error(set), 0);
item = jwks_item_get(set, 0);
ck_assert_ptr_nonnull(item);
ck_assert_int_eq(jwks_item_kty(item), JWK_KEY_TYPE_AKP);
/* Downgraded to public: no private material, so not marked private. */
ck_assert_int_eq(jwks_item_is_private(item), 0);
/* Even a "private" export must not invent a "priv" it does not have. */
priv_json = jwks_item_export(item, 1);
ck_assert_ptr_nonnull(priv_json);
ck_assert_ptr_nonnull(strstr(priv_json, "\"pub\""));
ck_assert_ptr_null(strstr(priv_json, "\"priv\""));
}
END_TEST
/* DER and PEM of the same key must export to identical JWK JSON. */
START_TEST(test_mldsa_der_matches_pem)
{
static const char *pairs[][2] = {
{ "mldsa_key_44.pem", "mldsa_key_44.der" },
{ "mldsa_key_44_pub.pem", "mldsa_key_44_pub.der" },
};
size_t i;
SET_OPS();
if (!mldsa_supported())
return;
for (i = 0; i < ARRAY_SIZE(pairs); i++) {
jwk_set_auto_t *pset = NULL, *dset = NULL;
char_auto *pjson = NULL, *djson = NULL;
char *path;
int ret;
ret = asprintf(&path, KEYDIR "/mldsa-pem/%s", pairs[i][0]);
ck_assert_int_gt(ret, 0);
pset = jwks_create_fromkey_file(path, JWK_KEY_NONE);
free(path);
ret = asprintf(&path, KEYDIR "/mldsa-pem/%s", pairs[i][1]);
ck_assert_int_gt(ret, 0);
dset = jwks_create_fromkey_file(path, JWK_KEY_NONE);
free(path);
ck_assert_ptr_nonnull(pset);
ck_assert_ptr_nonnull(dset);
ck_assert_int_eq(jwks_error(pset), 0);
ck_assert_int_eq(jwks_error(dset), 0);
pjson = jwks_export(pset, 1);
djson = jwks_export(dset, 1);
ck_assert_ptr_nonnull(pjson);
ck_assert_ptr_nonnull(djson);
ck_assert_str_eq(pjson, djson);
}
}
END_TEST
#else /* !LIBJWT_HAVE_ML_DSA */
/* Built without ML-DSA support; keep one trivially-passing test so the binary
* is non-empty and clearly reports the feature as not built in. */
START_TEST(test_mldsa_disabled)
{
ck_assert(1);
}
END_TEST
#endif
static Suite *libjwt_suite(const char *title)
{
Suite *s;
TCase *tc_core;
s = suite_create(title);
tc_core = tcase_create("ML-DSA");
#ifdef LIBJWT_HAVE_ML_DSA
{
int i = ARRAY_SIZE(jwt_test_ops);
tcase_add_loop_test(tc_core, test_mldsa_sign_verify, 0, i);
tcase_add_loop_test(tc_core, test_mldsa_cross_variant, 0, i);
#if defined(HAVE_GNUTLS) || defined(HAVE_MBEDTLS)
tcase_add_loop_test(tc_core, test_mldsa_unsupported, 0, i);
#endif
tcase_add_loop_test(tc_core, test_mldsa_parse_errors, 0, i);
tcase_add_loop_test(tc_core, test_mldsa_export, 0, i);
tcase_add_loop_test(tc_core, test_mldsa_export_seedless, 0, i);
tcase_add_loop_test(tc_core, test_mldsa_der_matches_pem, 0, i);
}
#else
tcase_add_test(tc_core, test_mldsa_disabled);
#endif
tcase_set_timeout(tc_core, 30);
suite_add_tcase(s, tc_core);
return s;
}
int main(void)
{
JWT_TEST_MAIN("LibJWT ML-DSA (FIPS 204)");
}