From 4d696ab7ac950044aa7ecaaa9ac8c319189130b7 Mon Sep 17 00:00:00 2001 From: Sergio Correia Date: Fri, 10 Jul 2026 08:48:21 -0300 Subject: [PATCH 1/7] fix bugs across jws, aesgcm, jwk, and io - jws: fix algorithm mismatch check using strcmp < 0 instead of != 0, which only rejected mismatches where halg sorted before kalg - misc: use OPENSSL_cleanse() in zero() instead of bare memset(), which compilers may optimize away, leaving key material in memory - aesgcm: fix AAD feed using protected header length (prtl) instead of actual AAD length (aadl), causing truncated or over-read AAD - jwk: add missing check for private exponent decode failure in RSA key import, preventing silent loss of private key component - io: add integer overflow check in malloc_feed() before computing the new buffer size Assisted-by: Claude Opus 4.6 Signed-off-by: Sergio Correia --- lib/io.c | 3 +++ lib/jws.c | 4 ++-- lib/misc.c | 3 ++- lib/openssl/aesgcm.c | 4 ++-- lib/openssl/jwk.c | 14 ++++++++------ 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/lib/io.c b/lib/io.c index df13413..5582f1a 100644 --- a/lib/io.c +++ b/lib/io.c @@ -83,6 +83,9 @@ malloc_feed(jose_io_t *io, const void *in, size_t len) if (len == 0) return true; + if (SIZE_MAX - *i->len < len) + return false; + tmp = jose_realloc(*i->buf, *i->len + len); if (!tmp) return false; diff --git a/lib/jws.c b/lib/jws.c index 860d289..ba7f7da 100644 --- a/lib/jws.c +++ b/lib/jws.c @@ -316,7 +316,7 @@ jose_jws_ver_io(jose_cfg_t *cfg, const json_t *jws, const json_t *sig, } halg = kalg; - } else if (kalg && strcmp(halg, kalg) < 0) { + } else if (kalg && strcmp(halg, kalg) != 0) { jose_cfg_err(cfg, JOSE_CFG_ERR_JWK_MISMATCH, "Signing algorithm mismatch (%s != %s)", halg, kalg); return NULL; @@ -332,7 +332,7 @@ jose_jws_ver_io(jose_cfg_t *cfg, const json_t *jws, const json_t *sig, if (!jose_jwk_prm(cfg, jwk, false, alg->sign.vprm)) { jose_cfg_err(cfg, JOSE_CFG_ERR_JWK_DENIED, "JWK cannot be used to verify"); - return false; + return NULL; } return prefix(alg->sign.ver(alg, cfg, jws, sig, jwk), sig); diff --git a/lib/misc.c b/lib/misc.c index c8707ee..edc8f3d 100644 --- a/lib/misc.c +++ b/lib/misc.c @@ -19,6 +19,7 @@ #include "misc.h" #include #include +#include #include #include "hooks.h" @@ -42,7 +43,7 @@ encode_protected(json_t *obj) void zero(void *mem, size_t len) { - memset(mem, 0, len); + OPENSSL_cleanse(mem, len); } /* Decode the base64url-encoded protected header and load it as JSON only diff --git a/lib/openssl/aesgcm.c b/lib/openssl/aesgcm.c index 248e95b..a0ccf31 100644 --- a/lib/openssl/aesgcm.c +++ b/lib/openssl/aesgcm.c @@ -79,7 +79,7 @@ setup(const EVP_CIPHER *cph, jose_cfg_t *cfg, const json_t *jwe, if (push(ecc, NULL, &tmp, (uint8_t *) ".", 1) <= 0) goto error; - if (push(ecc, NULL, &tmp, (uint8_t *) aad, prtl) <= 0) + if (push(ecc, NULL, &tmp, (uint8_t *) aad, aadl) <= 0) goto error; } @@ -162,7 +162,7 @@ dec_feed(jose_io_t *io, const void *in, size_t len) if (EVP_DecryptUpdate(i->cctx, pt, &l, &ct[j], 1) <= 0) goto egress; - if (i->next->feed(i->next, pt, l) != (size_t) l) + if (!i->next->feed(i->next, pt, l)) goto egress; } diff --git a/lib/openssl/jwk.c b/lib/openssl/jwk.c index 4db4b5c..4b97a23 100644 --- a/lib/openssl/jwk.c +++ b/lib/openssl/jwk.c @@ -315,7 +315,8 @@ jose_openssl_jwk_to_RSA(jose_cfg_t *cfg, const json_t *jwk) DP = bn_decode_json(dp); DQ = bn_decode_json(dq); QI = bn_decode_json(qi); - if ((!n || N) && (!e || E) && (!p || P) && (!q || Q) && + if ((!n || N) && (!e || E) && (!d || D) && + (!p || P) && (!q || Q) && (!dp || DP) && (!dq || DQ) && (!qi || QI)) { if (RSA_set0_key(rsa, N, E, D) > 0) { N = NULL; @@ -342,11 +343,12 @@ jose_openssl_jwk_to_RSA(jose_cfg_t *cfg, const json_t *jwk) BN_free(N); BN_free(E); - BN_free(P); - BN_free(Q); - BN_free(DP); - BN_free(DQ); - BN_free(QI); + BN_clear_free(D); + BN_clear_free(P); + BN_clear_free(Q); + BN_clear_free(DP); + BN_clear_free(DQ); + BN_clear_free(QI); return NULL; } From 4a62ef8285023ed0499cb6c1e439368597a3c540 Mon Sep 17 00:00:00 2001 From: Sergio Correia Date: Fri, 10 Jul 2026 08:53:22 -0300 Subject: [PATCH 2/7] hooks: add KEM algorithm kind for key encapsulation mechanisms Post-quantum KEMs (like ML-KEM / FIPS 203) use an asymmetric encapsulate/decapsulate model that does not fit the existing JOSE_HOOK_ALG_KIND_EXCH interface, which assumes both sides can independently compute the same shared secret. Add JOSE_HOOK_ALG_KIND_KEM with enc (encapsulate) and dec (decapsulate) callbacks. Add the corresponding public API functions jose_jwk_kem_enc() and jose_jwk_kem_dec(), export them in the linker map, and register "kem" in the CLI algorithm kind table. No algorithm implementations are registered yet; that follows in a subsequent commit with the ML-KEM OpenSSL integration. Assisted-by: Claude Opus 4.6 Signed-off-by: Sergio Correia --- cmd/alg.c | 1 + include/jose/jwk.h | 26 ++++++++++++++++ lib/hooks.h | 19 +++++++++++- lib/jwk.c | 77 ++++++++++++++++++++++++++++++++++++++++++++++ lib/libjose.map | 2 ++ 5 files changed, 124 insertions(+), 1 deletion(-) diff --git a/cmd/alg.c b/cmd/alg.c index 544160a..978f6df 100644 --- a/cmd/alg.c +++ b/cmd/alg.c @@ -37,6 +37,7 @@ static const struct { { "encr", JOSE_HOOK_ALG_KIND_ENCR }, { "comp", JOSE_HOOK_ALG_KIND_COMP }, { "exch", JOSE_HOOK_ALG_KIND_EXCH }, + { "kem", JOSE_HOOK_ALG_KIND_KEM }, {} }; diff --git a/include/jose/jwk.h b/include/jose/jwk.h index 509c350..d5c969b 100644 --- a/include/jose/jwk.h +++ b/include/jose/jwk.h @@ -195,4 +195,30 @@ jose_jwk_thp_buf(jose_cfg_t *cfg, const json_t *jwk, json_t * jose_jwk_exc(jose_cfg_t *cfg, const json_t *lcl, const json_t *rem); +/** + * Performs KEM encapsulation using a public JWK. + * + * Returns a JSON object containing the shared secret as an oct JWK + * ("ss") and the ciphertext as a base64url string ("ct"). + * + * \param cfg The configuration context (optional). + * \param pub The public JWK to encapsulate against. + * \return On success, a JSON object. Otherwise, NULL. + */ +json_t * +jose_jwk_kem_enc(jose_cfg_t *cfg, const json_t *pub); + +/** + * Performs KEM decapsulation using a private JWK and ciphertext. + * + * Returns the shared secret as an oct JWK. + * + * \param cfg The configuration context (optional). + * \param prv The private JWK to decapsulate with. + * \param ct The ciphertext as a base64url JSON string. + * \return On success, a JSON object (oct JWK). Otherwise, NULL. + */ +json_t * +jose_jwk_kem_dec(jose_cfg_t *cfg, const json_t *prv, const json_t *ct); + /** @} */ diff --git a/lib/hooks.h b/lib/hooks.h index 4208c25..8268de6 100644 --- a/lib/hooks.h +++ b/lib/hooks.h @@ -39,7 +39,8 @@ typedef enum { JOSE_HOOK_ALG_KIND_ENCR, JOSE_HOOK_ALG_KIND_COMP, JOSE_HOOK_ALG_KIND_EXCH, - JOSE_HOOK_ALG_KIND_LAST = JOSE_HOOK_ALG_KIND_EXCH + JOSE_HOOK_ALG_KIND_KEM, + JOSE_HOOK_ALG_KIND_LAST = JOSE_HOOK_ALG_KIND_KEM } jose_hook_alg_kind_t; typedef struct jose_hook_jwk jose_hook_jwk_t; @@ -169,6 +170,22 @@ struct jose_hook_alg { (*exc)(const jose_hook_alg_t *alg, jose_cfg_t *cfg, const json_t *prv, const json_t *pub); } exch; + + struct { + const char *prm; + + const char * + (*sug)(const jose_hook_alg_t *alg, jose_cfg_t *cfg, + const json_t *jwk); + + json_t * + (*enc)(const jose_hook_alg_t *alg, jose_cfg_t *cfg, + const json_t *pub); + + json_t * + (*dec)(const jose_hook_alg_t *alg, jose_cfg_t *cfg, + const json_t *prv, const json_t *ct); + } kem; }; }; diff --git a/lib/jwk.c b/lib/jwk.c index 626fc68..d9797ea 100644 --- a/lib/jwk.c +++ b/lib/jwk.c @@ -435,6 +435,83 @@ jose_jwk_exc(jose_cfg_t *cfg, const json_t *prv, const json_t *pub) return NULL; } +json_t * +jose_jwk_kem_enc(jose_cfg_t *cfg, const json_t *pub) +{ + const char *alg = NULL; + + if (json_unpack((json_t *) pub, "{s:s}", "alg", &alg) < 0) { + jose_cfg_err(cfg, JOSE_CFG_ERR_JWK_INVALID, + "Public JWK is missing 'alg'"); + return NULL; + } + + for (const jose_hook_alg_t *a = jose_hook_alg_list(); a; a = a->next) { + if (a->kind != JOSE_HOOK_ALG_KIND_KEM) + continue; + + if (strcmp(alg, a->name) != 0) + continue; + + if (!jose_jwk_prm(cfg, pub, false, a->kem.prm)) { + jose_cfg_err(cfg, JOSE_CFG_ERR_JWK_DENIED, + "Public JWK cannot be used for KEM"); + return NULL; + } + + return a->kem.enc(a, cfg, pub); + } + + jose_cfg_err(cfg, JOSE_CFG_ERR_ALG_NOTSUP, + "KEM algorithm %s is unsupported", alg); + return NULL; +} + +json_t * +jose_jwk_kem_dec(jose_cfg_t *cfg, const json_t *prv, const json_t *ct) +{ + const char *alg = NULL; + const char *kty = NULL; + + if (json_unpack((json_t *) prv, "{s:s,s?s}", "alg", &alg, "kty", &kty) < 0) { + jose_cfg_err(cfg, JOSE_CFG_ERR_JWK_INVALID, + "Private JWK is missing 'alg'"); + return NULL; + } + + if (!kty || strcmp(kty, "AKP") != 0) { + jose_cfg_err(cfg, JOSE_CFG_ERR_JWK_INVALID, + "KEM requires kty 'AKP'"); + return NULL; + } + + if (!json_object_get(prv, "priv")) { + jose_cfg_err(cfg, JOSE_CFG_ERR_JWK_DENIED, + "JWK is missing private key material ('priv')"); + return NULL; + } + + for (const jose_hook_alg_t *a = jose_hook_alg_list(); a; a = a->next) { + if (a->kind != JOSE_HOOK_ALG_KIND_KEM) + continue; + + if (strcmp(alg, a->name) != 0) + continue; + + if (!jose_jwk_prm(cfg, prv, false, a->kem.prm)) { + jose_cfg_err(cfg, JOSE_CFG_ERR_JWK_DENIED, + "Private JWK cannot be used for KEM"); + return NULL; + } + + return a->kem.dec(a, cfg, prv, ct); + } + + jose_cfg_err(cfg, JOSE_CFG_ERR_ALG_NOTSUP, + "KEM algorithm %s is unsupported", alg); + return NULL; +} + static void __attribute__((constructor)) constructor(void) { diff --git a/lib/libjose.map b/lib/libjose.map index 3475253..8e807ff 100644 --- a/lib/libjose.map +++ b/lib/libjose.map @@ -50,6 +50,8 @@ LIBJOSE_1.0 { jose_jwk_eql; jose_jwk_exc; jose_jwk_gen; + jose_jwk_kem_dec; + jose_jwk_kem_enc; jose_jwk_prm; jose_jwk_pub; jose_jwk_thp; From 0a492472bbd7218be9b5f05aece89b668b237e66 Mon Sep 17 00:00:00 2001 From: Sergio Correia Date: Fri, 10 Jul 2026 08:56:26 -0300 Subject: [PATCH 3/7] jwk: add AKP key type and KEM key_ops support Register the AKP (Algorithm Key Pair) key type per the IETF PQC KEM specification (draft-06 as of now), with required fields "alg" and "pub", public field "pub", and private field "priv". This key type is shared across post-quantum algorithms (ML-KEM, ML-DSA) and uses the mandatory "alg" field to distinguish between them. Add a JOSE_HOOK_ALG_KIND_KEM case to the key_ops auto-assignment switch in jose_jwk_gen(), setting "deriveKey" for generated KEM keys. Add an mlkem meson build option (auto/enabled/disabled) for future ML-KEM conditional compilation. Assisted-by: Claude Opus 4.6 Signed-off-by: Sergio Correia --- lib/jwk.c | 19 ++++++++++++++++++- meson_options.txt | 1 + 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/jwk.c b/lib/jwk.c index d9797ea..431a24e 100644 --- a/lib/jwk.c +++ b/lib/jwk.c @@ -103,6 +103,10 @@ jose_jwk_gen(jose_cfg_t *cfg, json_t *jwk) if (json_array_append_new(ops, json_string("deriveKey")) < 0) return false; break; + case JOSE_HOOK_ALG_KIND_KEM: + if (json_array_append_new(ops, json_string("deriveKey")) < 0) + return false; + break; default: break; } @@ -439,13 +443,20 @@ json_t * jose_jwk_kem_enc(jose_cfg_t *cfg, const json_t *pub) { const char *alg = NULL; + const char *kty = NULL; - if (json_unpack((json_t *) pub, "{s:s}", "alg", &alg) < 0) { + if (json_unpack((json_t *) pub, "{s:s,s?s}", "alg", &alg, "kty", &kty) < 0) { jose_cfg_err(cfg, JOSE_CFG_ERR_JWK_INVALID, "Public JWK is missing 'alg'"); return NULL; } + if (!kty || strcmp(kty, "AKP") != 0) { + jose_cfg_err(cfg, JOSE_CFG_ERR_JWK_INVALID, + "KEM requires kty 'AKP'"); + return NULL; + } + for (const jose_hook_alg_t *a = jose_hook_alg_list(); a; a = a->next) { if (a->kind != JOSE_HOOK_ALG_KIND_KEM) continue; @@ -526,6 +537,10 @@ constructor(void) static const char *ec_pub[] = { "x", "y", NULL }; static const char *ec_prv[] = { "d", NULL }; + static const char *akp_req[] = { "alg", "pub", NULL }; + static const char *akp_pub[] = { "pub", NULL }; + static const char *akp_prv[] = { "priv", NULL }; + static jose_hook_jwk_t hooks[] = { { .kind = JOSE_HOOK_JWK_KIND_TYPE, .type = { .kty = "oct", .req = oct_req, .prv = oct_prv } }, @@ -533,6 +548,8 @@ constructor(void) .type = { .kty = "RSA", .req = rsa_req, .pub = rsa_pub, .prv = rsa_prv } }, { .kind = JOSE_HOOK_JWK_KIND_TYPE, .type = { .kty = "EC", .req = ec_req, .pub = ec_pub, .prv = ec_prv } }, + { .kind = JOSE_HOOK_JWK_KIND_TYPE, + .type = { .kty = "AKP", .req = akp_req, .pub = akp_pub, .prv = akp_prv } }, { .kind = JOSE_HOOK_JWK_KIND_OPER, .oper = { .pub = "verify", .prv = "sign", .use = "sig" } }, { .kind = JOSE_HOOK_JWK_KIND_OPER, diff --git a/meson_options.txt b/meson_options.txt index dd44146..ffe69b3 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -1 +1,2 @@ option('docs', type: 'feature', description: 'Whether to build asciidoc manpages') +option('mlkem', type: 'feature', value: 'auto', description: 'ML-KEM (FIPS 203) support (requires OpenSSL >= 3.5)') From 0948df60d56cd539b227be5c06bc62d21cd90f4b Mon Sep 17 00:00:00 2001 From: Sergio Correia Date: Fri, 10 Jul 2026 09:53:04 -0300 Subject: [PATCH 4/7] openssl: implement ML-KEM key generation, encapsulate, decapsulate Add ML-KEM (FIPS 203) support using OpenSSL 3.5+ EVP API. The entire file is guarded by OPENSSL_VERSION_NUMBER >= 0x30500000L with a runtime probe that skips registration if ML-KEM is not available in the OpenSSL provider. Registers PREP, MAKE, and KEM hooks for ML-KEM-512, ML-KEM-768, and ML-KEM-1024. Key material uses the AKP key type (per the IETF PQC KEM specification (draft-06 as of now)) with base64url-encoded pub/priv fields. The MAKE hook checks both kty and alg to coexist with future AKP-based algorithms (ML-DSA). All key material buffers are heap-allocated (ML-KEM keys exceed the KEYMAX constant) and scrubbed with OPENSSL_cleanse before free. Assisted-by: Claude Opus 4.6 Signed-off-by: Sergio Correia --- include/jose/jwk.h | 4 + lib/meson.build | 27 ++- lib/openssl/mlkem.c | 415 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 445 insertions(+), 1 deletion(-) create mode 100644 lib/openssl/mlkem.c diff --git a/include/jose/jwk.h b/include/jose/jwk.h index d5c969b..821c955 100644 --- a/include/jose/jwk.h +++ b/include/jose/jwk.h @@ -201,6 +201,10 @@ jose_jwk_exc(jose_cfg_t *cfg, const json_t *lcl, const json_t *rem); * Returns a JSON object containing the shared secret as an oct JWK * ("ss") and the ciphertext as a base64url string ("ct"). * + * The returned object contains secret material. Callers handling + * sensitive key material should cleanse the "k" value of the "ss" + * object before releasing it. + * * \param cfg The configuration context (optional). * \param pub The public JWK to encapsulate against. * \return On success, a JSON object. Otherwise, NULL. diff --git a/lib/meson.build b/lib/meson.build index a5a203c..f9ea391 100644 --- a/lib/meson.build +++ b/lib/meson.build @@ -24,7 +24,7 @@ else endif endif -libjose_lib = shared_library('jose', +jose_sources = [ 'misc.c', 'misc.h', 'cfg.c', 'io.c', @@ -56,6 +56,31 @@ libjose_lib = shared_library('jose', 'openssl/rsa.c', 'openssl/rsaes.c', 'openssl/rsassa.c', +] + +mlkem_opt = get_option('mlkem') +if not mlkem_opt.disabled() + has_mlkem = cc.compiles( + ''' + #include + #if OPENSSL_VERSION_NUMBER < 0x30500000L + #error "OpenSSL >= 3.5 required for ML-KEM" + #endif + int main(void) { return 0; } + ''', + dependencies: libcrypto, + name: 'ML-KEM support (OpenSSL >= 3.5)', + ) + + if has_mlkem + jose_sources += 'openssl/mlkem.c' + elif mlkem_opt.enabled() + error('ML-KEM support requires OpenSSL >= 3.5') + endif +endif + +libjose_lib = shared_library('jose', + jose_sources, include_directories: incdir, dependencies: [zlib, jansson, libcrypto, threads], diff --git a/lib/openssl/mlkem.c b/lib/openssl/mlkem.c new file mode 100644 index 0000000..5dcbb72 --- /dev/null +++ b/lib/openssl/mlkem.c @@ -0,0 +1,415 @@ +/* vim: set tabstop=8 shiftwidth=4 softtabstop=4 expandtab smarttab colorcolumn=80: */ +/* + * Copyright 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "misc.h" +#include "../hooks.h" +#include + +#include + +#if OPENSSL_VERSION_NUMBER >= 0x30500000L + +#include +#include +#include + +#include + +#define NAMES "ML-KEM-512", "ML-KEM-768", "ML-KEM-1024" + +static bool +mlkem_available(void) +{ + EVP_PKEY_CTX *ctx = EVP_PKEY_CTX_new_from_name(NULL, "ML-KEM-768", NULL); + if (!ctx) + return false; + EVP_PKEY_CTX_free(ctx); + return true; +} + +static bool +jwk_prep_handles(jose_cfg_t *cfg, const json_t *jwk) +{ + const char *alg = NULL; + + if (json_unpack((json_t *) jwk, "{s:s}", "alg", &alg) == -1) + return false; + + return str2enum(alg, NAMES, NULL) != SIZE_MAX; +} + +static bool +jwk_prep_execute(jose_cfg_t *cfg, json_t *jwk) +{ + if (json_object_set_new(jwk, "kty", json_string("AKP")) < 0) + return false; + + return true; +} + +static bool +jwk_make_handles(jose_cfg_t *cfg, const json_t *jwk) +{ + const char *kty = NULL; + const char *alg = NULL; + + if (json_unpack((json_t *) jwk, "{s:s,s?s}", "kty", &kty, "alg", &alg) < 0) + return false; + + if (strcmp(kty, "AKP") != 0) + return false; + + return alg && str2enum(alg, NAMES, NULL) != SIZE_MAX; +} + +static bool +jwk_make_execute(jose_cfg_t *cfg, json_t *jwk) +{ + EVP_PKEY_CTX *ctx = NULL; + EVP_PKEY *pkey = NULL; + const char *alg = NULL; + unsigned char *pub = NULL; + unsigned char seed[64] = {}; + size_t pub_len = 0; + bool ret = false; + + if (json_unpack(jwk, "{s:s}", "alg", &alg) < 0) + return false; + + ctx = EVP_PKEY_CTX_new_from_name(NULL, alg, NULL); + if (!ctx) + return false; + + if (EVP_PKEY_keygen_init(ctx) <= 0) + goto egress; + + { + int retain = 1; + OSSL_PARAM gen_params[] = { + OSSL_PARAM_int(OSSL_PKEY_PARAM_ML_KEM_RETAIN_SEED, &retain), + OSSL_PARAM_END + }; + if (EVP_PKEY_CTX_set_params(ctx, gen_params) <= 0) + goto egress; + } + + if (EVP_PKEY_keygen(ctx, &pkey) <= 0) + goto egress; + + if (EVP_PKEY_get_raw_public_key(pkey, NULL, &pub_len) <= 0) + goto egress; + + pub = jose_malloc(pub_len); + if (!pub) + goto egress; + + if (EVP_PKEY_get_raw_public_key(pkey, pub, &pub_len) <= 0) + goto egress; + + { + size_t seed_len = sizeof(seed); + OSSL_PARAM seed_params[] = { + OSSL_PARAM_octet_string(OSSL_PKEY_PARAM_ML_KEM_SEED, + seed, sizeof(seed)), + OSSL_PARAM_END + }; + if (EVP_PKEY_get_params(pkey, seed_params) <= 0) + goto egress; + seed_len = seed_params[0].return_size; + if (seed_len != 64) + goto egress; + } + + if (json_object_set_new(jwk, "pub", jose_b64_enc(pub, pub_len)) < 0) + goto egress; + + if (json_object_set_new(jwk, "priv", jose_b64_enc(seed, 64)) < 0) + goto egress; + + ret = true; + +egress: + OPENSSL_cleanse(seed, sizeof(seed)); + jose_free(pub); + EVP_PKEY_free(pkey); + EVP_PKEY_CTX_free(ctx); + return ret; +} + +static EVP_PKEY * +jwk_to_pub_pkey(const json_t *jwk) +{ + const char *alg = NULL; + const char *kty = NULL; + unsigned char *pub = NULL; + EVP_PKEY *pkey = NULL; + size_t pub_len = 0; + + if (json_unpack((json_t *) jwk, "{s:s,s:s}", "alg", &alg, "kty", &kty) < 0) + return NULL; + + if (strcmp(kty, "AKP") != 0) + return NULL; + + pub_len = jose_b64_dec(json_object_get(jwk, "pub"), NULL, 0); + if (pub_len == SIZE_MAX || pub_len == 0) + return NULL; + + pub = jose_malloc(pub_len); + if (!pub) + return NULL; + + if (jose_b64_dec(json_object_get(jwk, "pub"), pub, pub_len) != pub_len) { + jose_free(pub); + return NULL; + } + + pkey = EVP_PKEY_new_raw_public_key_ex(NULL, alg, NULL, pub, pub_len); + jose_free(pub); + return pkey; +} + +static EVP_PKEY * +jwk_to_priv_pkey(const json_t *jwk) +{ + const char *alg = NULL; + const char *kty = NULL; + unsigned char *priv = NULL; + EVP_PKEY *pkey = NULL; + size_t priv_len = 0; + + if (json_unpack((json_t *) jwk, "{s:s,s:s}", "alg", &alg, "kty", &kty) < 0) + return NULL; + + if (strcmp(kty, "AKP") != 0) + return NULL; + + priv_len = jose_b64_dec(json_object_get(jwk, "priv"), NULL, 0); + if (priv_len == SIZE_MAX || priv_len == 0) + return NULL; + + if (priv_len != 64) + return NULL; + + priv = jose_malloc(priv_len); + if (!priv) + return NULL; + + if (jose_b64_dec(json_object_get(jwk, "priv"), priv, priv_len) != priv_len) { + OPENSSL_cleanse(priv, priv_len); + jose_free(priv); + return NULL; + } + + { + EVP_PKEY_CTX *ictx = EVP_PKEY_CTX_new_from_name(NULL, alg, NULL); + if (!ictx) { + OPENSSL_cleanse(priv, priv_len); + jose_free(priv); + return NULL; + } + + OSSL_PARAM import_params[] = { + OSSL_PARAM_octet_string(OSSL_PKEY_PARAM_ML_KEM_SEED, + priv, priv_len), + OSSL_PARAM_END + }; + if (EVP_PKEY_fromdata_init(ictx) <= 0 || + EVP_PKEY_fromdata(ictx, &pkey, EVP_PKEY_KEYPAIR, + import_params) <= 0) { + EVP_PKEY_free(pkey); + pkey = NULL; + } + EVP_PKEY_CTX_free(ictx); + } + + OPENSSL_cleanse(priv, priv_len); + jose_free(priv); + return pkey; +} + +static json_t * +alg_kem_enc(const jose_hook_alg_t *alg, jose_cfg_t *cfg, + const json_t *pub) +{ + EVP_PKEY *pkey = NULL; + EVP_PKEY_CTX *ctx = NULL; + unsigned char *ct = NULL; + unsigned char *ss = NULL; + size_t ct_len = 0; + size_t ss_len = 0; + json_t *result = NULL; + + pkey = jwk_to_pub_pkey(pub); + if (!pkey) + return NULL; + + ctx = EVP_PKEY_CTX_new(pkey, NULL); + if (!ctx) + goto egress; + + if (EVP_PKEY_encapsulate_init(ctx, NULL) <= 0) + goto egress; + + if (EVP_PKEY_encapsulate(ctx, NULL, &ct_len, NULL, &ss_len) <= 0) + goto egress; + + ct = jose_malloc(ct_len); + ss = jose_malloc(ss_len); + if (!ct || !ss) + goto egress; + + if (EVP_PKEY_encapsulate(ctx, ct, &ct_len, ss, &ss_len) <= 0) + goto egress; + + { + json_auto_t *ct_b64 = jose_b64_enc(ct, ct_len); + json_auto_t *ss_b64 = jose_b64_enc(ss, ss_len); + if (ct_b64 && ss_b64) + result = json_pack("{s:O,s:{s:s,s:O}}", + "ct", ct_b64, + "ss", "kty", "oct", "k", ss_b64); + } + +egress: + if (ss) { + OPENSSL_cleanse(ss, ss_len); + jose_free(ss); + } + jose_free(ct); + EVP_PKEY_CTX_free(ctx); + EVP_PKEY_free(pkey); + return result; +} + +static json_t * +alg_kem_dec(const jose_hook_alg_t *alg, jose_cfg_t *cfg, + const json_t *prv, const json_t *ct) +{ + EVP_PKEY *pkey = NULL; + EVP_PKEY_CTX *ctx = NULL; + unsigned char *ct_buf = NULL; + unsigned char *ss = NULL; + size_t ct_len = 0; + size_t ss_len = 0; + json_t *result = NULL; + + if (!json_is_string(ct)) + return NULL; + + pkey = jwk_to_priv_pkey(prv); + if (!pkey) + return NULL; + + ct_len = jose_b64_dec(ct, NULL, 0); + if (ct_len == SIZE_MAX || ct_len == 0) + goto egress; + + ct_buf = jose_malloc(ct_len); + if (!ct_buf) + goto egress; + + if (jose_b64_dec(ct, ct_buf, ct_len) != ct_len) + goto egress; + + ctx = EVP_PKEY_CTX_new(pkey, NULL); + if (!ctx) + goto egress; + + if (EVP_PKEY_decapsulate_init(ctx, NULL) <= 0) + goto egress; + + if (EVP_PKEY_decapsulate(ctx, NULL, &ss_len, ct_buf, ct_len) <= 0) + goto egress; + + ss = jose_malloc(ss_len); + if (!ss) + goto egress; + + if (EVP_PKEY_decapsulate(ctx, ss, &ss_len, ct_buf, ct_len) <= 0) + goto egress; + + { + json_auto_t *ss_b64 = jose_b64_enc(ss, ss_len); + if (ss_b64) + result = json_pack("{s:s,s:O}", "kty", "oct", "k", ss_b64); + } + +egress: + if (ss) { + OPENSSL_cleanse(ss, ss_len); + jose_free(ss); + } + jose_free(ct_buf); + EVP_PKEY_CTX_free(ctx); + EVP_PKEY_free(pkey); + return result; +} + +static const char * +alg_kem_sug(const jose_hook_alg_t *alg, jose_cfg_t *cfg, + const json_t *jwk) +{ + return NULL; +} + +static void __attribute__((constructor)) +constructor(void) +{ + static jose_hook_jwk_t jwks[] = { + { .kind = JOSE_HOOK_JWK_KIND_PREP, + .prep.handles = jwk_prep_handles, + .prep.execute = jwk_prep_execute }, + { .kind = JOSE_HOOK_JWK_KIND_MAKE, + .make.handles = jwk_make_handles, + .make.execute = jwk_make_execute }, + {} + }; + + static jose_hook_alg_t algs[] = { + { .kind = JOSE_HOOK_ALG_KIND_KEM, + .name = "ML-KEM-512", + .kem.prm = "deriveKey", + .kem.sug = alg_kem_sug, + .kem.enc = alg_kem_enc, + .kem.dec = alg_kem_dec }, + { .kind = JOSE_HOOK_ALG_KIND_KEM, + .name = "ML-KEM-768", + .kem.prm = "deriveKey", + .kem.sug = alg_kem_sug, + .kem.enc = alg_kem_enc, + .kem.dec = alg_kem_dec }, + { .kind = JOSE_HOOK_ALG_KIND_KEM, + .name = "ML-KEM-1024", + .kem.prm = "deriveKey", + .kem.sug = alg_kem_sug, + .kem.enc = alg_kem_enc, + .kem.dec = alg_kem_dec }, + {} + }; + + if (!mlkem_available()) + return; + + for (size_t i = 0; jwks[i].kind != JOSE_HOOK_JWK_KIND_NONE; i++) + jose_hook_jwk_push(&jwks[i]); + + for (size_t i = 0; algs[i].name; i++) + jose_hook_alg_push(&algs[i]); +} + +#endif /* OPENSSL_VERSION_NUMBER >= 0x30500000L */ From 09cf4ec349e8857d41e346e6ce99b39dcf7328f7 Mon Sep 17 00:00:00 2001 From: Sergio Correia Date: Fri, 10 Jul 2026 11:14:18 -0300 Subject: [PATCH 5/7] cli: add jose jwk encap/decap commands for KEM operations Add CLI commands using FIPS 203 terminology to avoid collision with jose jwe enc/dec: - jose jwk encap -i pub.jwk: encapsulate against a public AKP key, output JSON with ciphertext ("ct") and shared secret ("ss" as an oct JWK) - jose jwk decap -i prv.jwk -c : decapsulate using a private AKP key and base64url ciphertext, output shared secret as an oct JWK Assisted-by: Claude Opus 4.6 Signed-off-by: Sergio Correia --- cmd/jwk/decap.c | 130 ++++++++++++++++++++++++++++++++++++++++++++++++ cmd/jwk/encap.c | 100 +++++++++++++++++++++++++++++++++++++ cmd/meson.build | 2 + 3 files changed, 232 insertions(+) create mode 100644 cmd/jwk/decap.c create mode 100644 cmd/jwk/encap.c diff --git a/cmd/jwk/decap.c b/cmd/jwk/decap.c new file mode 100644 index 0000000..94f74bc --- /dev/null +++ b/cmd/jwk/decap.c @@ -0,0 +1,130 @@ +/* vim: set tabstop=8 shiftwidth=4 softtabstop=4 expandtab smarttab colorcolumn=80: */ +/* + * Copyright 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "jwk.h" +#include +#include + +#define SUMMARY "Performs KEM decapsulation using a private key" + +typedef struct { + FILE *output; + json_t *keys; + const char *ct; +} jcmd_opt_t; + +static const char *prefix = +"jose jwk decap -i JWK -c CT [-o JWK]\n\n" SUMMARY; + +static const jcmd_doc_t doc_input[] = { + { .arg = "JSON", .doc="Parse private JWK from JSON" }, + { .arg = "FILE", .doc="Read private JWK from FILE" }, + { .arg = "-", .doc="Read private JWK from standard input" }, + {} +}; + +static const jcmd_doc_t doc_output[] = { + { .arg = "FILE", .doc="Write shared secret JWK to FILE" }, + { .arg = "-", .doc="Write shared secret JWK to standard output" }, + {} +}; + +static const jcmd_doc_t doc_ct[] = { + { .arg = "B64U", .doc="Base64url-encoded ciphertext" }, + {} +}; + +static bool +jcmd_opt_set_ct(const jcmd_cfg_t *cfg, void *vopt, const char *arg) +{ + const char **ct = vopt; + *ct = arg; + return *ct != NULL; +} + +static const jcmd_cfg_t cfgs[] = { + { + .opt = { "input", required_argument, .val = 'i' }, + .off = offsetof(jcmd_opt_t, keys), + .set = jcmd_opt_set_jwks, + .doc = doc_input, + }, + { + .opt = { "output", required_argument, .val = 'o' }, + .off = offsetof(jcmd_opt_t, output), + .set = jcmd_opt_set_ofile, + .doc = doc_output, + .def = "-", + }, + { + .opt = { "ciphertext", required_argument, .val = 'c' }, + .off = offsetof(jcmd_opt_t, ct), + .set = jcmd_opt_set_ct, + .doc = doc_ct, + }, + {} +}; + +static void +jcmd_opt_cleanup(jcmd_opt_t *opt) +{ + jcmd_file_cleanup(&opt->output); + json_decrefp(&opt->keys); +} + +static int +jcmd_jwk_decap(int argc, char *argv[]) +{ + jcmd_opt_auto_t opt = {}; + json_auto_t *ct_json = NULL; + json_auto_t *result = NULL; + + if (!jcmd_opt_parse(argc, argv, cfgs, &opt, prefix)) + return EXIT_FAILURE; + + if (json_array_size(opt.keys) != 1) { + fprintf(stderr, "Private JWK must be specified exactly once!\n"); + return EXIT_FAILURE; + } + + if (!opt.ct) { + fprintf(stderr, "Ciphertext (-c) is required!\n"); + return EXIT_FAILURE; + } + + ct_json = json_string(opt.ct); + if (!ct_json) + return EXIT_FAILURE; + + result = jose_jwk_kem_dec(NULL, json_array_get(opt.keys, 0), ct_json); + if (!result) { + fprintf(stderr, "Error performing decapsulation!\n"); + return EXIT_FAILURE; + } + + if (json_dumpf(result, opt.output, JSON_COMPACT | JSON_SORT_KEYS) < 0) { + fprintf(stderr, "Error writing result!\n"); + return EXIT_FAILURE; + } + + if (isatty(fileno(opt.output))) + fprintf(opt.output, "\n"); + + return EXIT_SUCCESS; +} + +JCMD_REGISTER(SUMMARY, jcmd_jwk_decap, "jwk", "decap") diff --git a/cmd/jwk/encap.c b/cmd/jwk/encap.c new file mode 100644 index 0000000..e7997b9 --- /dev/null +++ b/cmd/jwk/encap.c @@ -0,0 +1,100 @@ +/* vim: set tabstop=8 shiftwidth=4 softtabstop=4 expandtab smarttab colorcolumn=80: */ +/* + * Copyright 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "jwk.h" +#include +#include + +#define SUMMARY "Performs KEM encapsulation using a public key" + +typedef struct { + FILE *output; + json_t *keys; +} jcmd_opt_t; + +static const char *prefix = +"jose jwk encap -i JWK [-o JSON]\n\n" SUMMARY; + +static const jcmd_doc_t doc_input[] = { + { .arg = "JSON", .doc="Parse public JWK from JSON" }, + { .arg = "FILE", .doc="Read public JWK from FILE" }, + { .arg = "-", .doc="Read public JWK from standard input" }, + {} +}; + +static const jcmd_doc_t doc_output[] = { + { .arg = "FILE", .doc="Write result to FILE" }, + { .arg = "-", .doc="Write result to standard output" }, + {} +}; + +static const jcmd_cfg_t cfgs[] = { + { + .opt = { "input", required_argument, .val = 'i' }, + .off = offsetof(jcmd_opt_t, keys), + .set = jcmd_opt_set_jwks, + .doc = doc_input, + }, + { + .opt = { "output", required_argument, .val = 'o' }, + .off = offsetof(jcmd_opt_t, output), + .set = jcmd_opt_set_ofile, + .doc = doc_output, + .def = "-", + }, + {} +}; + +static void +jcmd_opt_cleanup(jcmd_opt_t *opt) +{ + jcmd_file_cleanup(&opt->output); + json_decrefp(&opt->keys); +} + +static int +jcmd_jwk_encap(int argc, char *argv[]) +{ + jcmd_opt_auto_t opt = {}; + json_auto_t *result = NULL; + + if (!jcmd_opt_parse(argc, argv, cfgs, &opt, prefix)) + return EXIT_FAILURE; + + if (json_array_size(opt.keys) != 1) { + fprintf(stderr, "Public JWK must be specified exactly once!\n"); + return EXIT_FAILURE; + } + + result = jose_jwk_kem_enc(NULL, json_array_get(opt.keys, 0)); + if (!result) { + fprintf(stderr, "Error performing encapsulation!\n"); + return EXIT_FAILURE; + } + + if (json_dumpf(result, opt.output, JSON_COMPACT | JSON_SORT_KEYS) < 0) { + fprintf(stderr, "Error writing result!\n"); + return EXIT_FAILURE; + } + + if (isatty(fileno(opt.output))) + fprintf(opt.output, "\n"); + + return EXIT_SUCCESS; +} + +JCMD_REGISTER(SUMMARY, jcmd_jwk_encap, "jwk", "encap") diff --git a/cmd/meson.build b/cmd/meson.build index f87bfc3..034a516 100644 --- a/cmd/meson.build +++ b/cmd/meson.build @@ -5,6 +5,8 @@ executable(meson.project_name(), 'b64/enc.c', 'jwk/jwk.h', 'jwk/eql.c', + 'jwk/encap.c', + 'jwk/decap.c', 'jwk/exc.c', 'jwk/gen.c', 'jwk/pub.c', From 48a8550e5ad63b43416b1d42622703505d819af6 Mon Sep 17 00:00:00 2001 From: Sergio Correia Date: Fri, 10 Jul 2026 11:17:00 -0300 Subject: [PATCH 6/7] tests: add ML-KEM test coverage C test (alg_kem.c) exercises all registered KEM algorithms: - Roundtrip: encap with public key, decap with private key, verify shared secrets match - Implicit rejection: corrupt ciphertext produces a different shared secret (not an error), per ML-KEM design - Cross-key rejection: decap with wrong private key produces a different shared secret - Public-only decap rejection: decap with a public-only key is rejected with a clear error - key_ops validation: generated keys have ["deriveKey"] - Thumbprint: SHA-256 thumbprint works for AKP keys Shell tests (jose-jwk-encap, jose-jwk-decap) exercise the CLI commands across all three ML-KEM variants, including error cases. Both skip gracefully when ML-KEM is unavailable (OpenSSL < 3.5). Assisted-by: Claude Opus 4.6 Signed-off-by: Sergio Correia --- tests/alg_kem.c | 219 +++++++++++++++++++++++++++++++++++++++++++ tests/jose-jwk-decap | 45 +++++++++ tests/jose-jwk-encap | 41 ++++++++ tests/meson.build | 3 + 4 files changed, 308 insertions(+) create mode 100644 tests/alg_kem.c create mode 100755 tests/jose-jwk-decap create mode 100755 tests/jose-jwk-encap diff --git a/tests/alg_kem.c b/tests/alg_kem.c new file mode 100644 index 0000000..3eefea6 --- /dev/null +++ b/tests/alg_kem.c @@ -0,0 +1,219 @@ +/* vim: set tabstop=8 shiftwidth=4 softtabstop=4 expandtab smarttab colorcolumn=80: */ +/* + * Copyright 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "../lib/hooks.h" +#include +#include +#include +#include +#include + +static void +test_roundtrip(const jose_hook_alg_t *a) +{ + json_auto_t *jwk = json_pack("{s:s}", "alg", a->name); + json_auto_t *pub = NULL; + json_auto_t *enc = NULL; + json_auto_t *dec = NULL; + const char *ss1 = NULL; + const char *ss2 = NULL; + + assert(jose_jwk_gen(NULL, jwk)); + + pub = json_deep_copy(jwk); + assert(jose_jwk_pub(NULL, pub)); + assert(!json_object_get(pub, "priv")); + + enc = jose_jwk_kem_enc(NULL, pub); + assert(enc); + assert(json_object_get(enc, "ct")); + assert(json_object_get(enc, "ss")); + + ss1 = json_string_value(json_object_get(json_object_get(enc, "ss"), "k")); + assert(ss1); + + dec = jose_jwk_kem_dec(NULL, jwk, json_object_get(enc, "ct")); + assert(dec); + + ss2 = json_string_value(json_object_get(dec, "k")); + assert(ss2); + + assert(strcmp(ss1, ss2) == 0); +} + +static void +test_implicit_rejection(const jose_hook_alg_t *a) +{ + json_auto_t *jwk = json_pack("{s:s}", "alg", a->name); + json_auto_t *enc = NULL; + json_auto_t *dec = NULL; + json_auto_t *corrupt_ct = NULL; + const char *ss1 = NULL; + const char *ss2 = NULL; + uint8_t *ct_raw = NULL; + size_t ct_len = 0; + + assert(jose_jwk_gen(NULL, jwk)); + + json_auto_t *pub = json_deep_copy(jwk); + assert(jose_jwk_pub(NULL, pub)); + + enc = jose_jwk_kem_enc(NULL, pub); + assert(enc); + + ss1 = json_string_value(json_object_get(json_object_get(enc, "ss"), "k")); + assert(ss1); + + json_t *ct = json_object_get(enc, "ct"); + ct_len = jose_b64_dec(ct, NULL, 0); + ct_raw = malloc(ct_len); + assert(ct_raw); + assert(jose_b64_dec(ct, ct_raw, ct_len) == ct_len); + + ct_raw[ct_len / 2] ^= 0x01; + corrupt_ct = jose_b64_enc(ct_raw, ct_len); + free(ct_raw); + assert(corrupt_ct); + + dec = jose_jwk_kem_dec(NULL, jwk, corrupt_ct); + assert(dec); + + ss2 = json_string_value(json_object_get(dec, "k")); + assert(ss2); + assert(strcmp(ss1, ss2) != 0); +} + +static void +test_cross_key(const jose_hook_alg_t *a) +{ + json_auto_t *jwk1 = json_pack("{s:s}", "alg", a->name); + json_auto_t *jwk2 = json_pack("{s:s}", "alg", a->name); + json_auto_t *enc = NULL; + json_auto_t *dec = NULL; + const char *ss1 = NULL; + const char *ss2 = NULL; + + assert(jose_jwk_gen(NULL, jwk1)); + assert(jose_jwk_gen(NULL, jwk2)); + + json_auto_t *pub1 = json_deep_copy(jwk1); + assert(jose_jwk_pub(NULL, pub1)); + + enc = jose_jwk_kem_enc(NULL, pub1); + assert(enc); + + ss1 = json_string_value(json_object_get(json_object_get(enc, "ss"), "k")); + assert(ss1); + + dec = jose_jwk_kem_dec(NULL, jwk2, json_object_get(enc, "ct")); + assert(dec); + + ss2 = json_string_value(json_object_get(dec, "k")); + assert(ss2); + assert(strcmp(ss1, ss2) != 0); +} + +static void +test_pub_only_decap_fails(const jose_hook_alg_t *a) +{ + json_auto_t *jwk = json_pack("{s:s}", "alg", a->name); + json_auto_t *pub = NULL; + json_auto_t *enc = NULL; + json_auto_t *dec = NULL; + + assert(jose_jwk_gen(NULL, jwk)); + + pub = json_deep_copy(jwk); + assert(jose_jwk_pub(NULL, pub)); + + enc = jose_jwk_kem_enc(NULL, pub); + assert(enc); + + dec = jose_jwk_kem_dec(NULL, pub, json_object_get(enc, "ct")); + assert(!dec); +} + +static void +test_key_ops(const jose_hook_alg_t *a) +{ + json_auto_t *jwk = json_pack("{s:s}", "alg", a->name); + json_t *ops = NULL; + const char *op = NULL; + + assert(jose_jwk_gen(NULL, jwk)); + + ops = json_object_get(jwk, "key_ops"); + assert(json_is_array(ops)); + assert(json_array_size(ops) == 1); + + op = json_string_value(json_array_get(ops, 0)); + assert(op); + assert(strcmp(op, "deriveKey") == 0); +} + +static void +test_thumbprint(const jose_hook_alg_t *a) +{ + json_auto_t *jwk = json_pack("{s:s}", "alg", a->name); + json_auto_t *thp = NULL; + + assert(jose_jwk_gen(NULL, jwk)); + + thp = jose_jwk_thp(NULL, jwk, "S256"); + assert(thp); + assert(json_is_string(thp)); + assert(json_string_length(thp) > 0); +} + +int +main(int argc, char *argv[]) +{ + bool found = false; + + for (const jose_hook_alg_t *a = jose_hook_alg_list(); a; a = a->next) { + if (a->kind != JOSE_HOOK_ALG_KIND_KEM) + continue; + + found = true; + fprintf(stderr, "alg: %s\n", a->name); + + test_roundtrip(a); + fprintf(stderr, " roundtrip: OK\n"); + + test_implicit_rejection(a); + fprintf(stderr, " implicit rejection: OK\n"); + + test_cross_key(a); + fprintf(stderr, " cross-key rejection: OK\n"); + + test_pub_only_decap_fails(a); + fprintf(stderr, " pub-only decap rejected: OK\n"); + + test_key_ops(a); + fprintf(stderr, " key_ops: OK\n"); + + test_thumbprint(a); + fprintf(stderr, " thumbprint: OK\n"); + } + + if (!found) { + fprintf(stderr, "No KEM algorithms available (OpenSSL < 3.5?)\n"); + return 77; + } + + return EXIT_SUCCESS; +} diff --git a/tests/jose-jwk-decap b/tests/jose-jwk-decap new file mode 100755 index 0000000..627edf9 --- /dev/null +++ b/tests/jose-jwk-decap @@ -0,0 +1,45 @@ +#!/bin/sh -ex + +tmpdir=`mktemp -d 2>/dev/null || mktemp -d -t jose` + +onexit() { + rm -rf $tmpdir +} + +trap onexit EXIT + +# Skip if no KEM algorithms are available +if ! jose alg -k kem | grep -q ML-KEM; then + echo "No ML-KEM algorithms available, skipping" + exit 77 +fi + +for ALG in ML-KEM-512 ML-KEM-768 ML-KEM-1024; do + jose jwk gen -i "{\"alg\":\"$ALG\"}" -o $tmpdir/kem.jwk + jose jwk pub -i $tmpdir/kem.jwk -o $tmpdir/kem_pub.jwk + + # Encapsulate + jose jwk encap -i $tmpdir/kem_pub.jwk -o $tmpdir/enc.json + CT=$(jose fmt -j $tmpdir/enc.json -Og ct -Su-) + SS1=$(jose fmt -j $tmpdir/enc.json -Og ss -Og k -Su-) + + # Decapsulate with private key + jose jwk decap -i $tmpdir/kem.jwk -c "$CT" -o $tmpdir/dec.json + SS2=$(jose fmt -j $tmpdir/dec.json -Og k -Su-) + + # Shared secrets must match + test "$SS1" = "$SS2" + + # Decap with public-only key should fail + ! jose jwk decap -i $tmpdir/kem_pub.jwk -c "$CT" + + # Decap with wrong key should produce different shared secret + jose jwk gen -i "{\"alg\":\"$ALG\"}" -o $tmpdir/kem2.jwk + jose jwk decap -i $tmpdir/kem2.jwk -c "$CT" -o $tmpdir/dec2.json + SS3=$(jose fmt -j $tmpdir/dec2.json -Og k -Su-) + test "$SS1" != "$SS3" +done + +# Decap should fail with non-KEM key +jose jwk gen -i '{"alg":"ES256"}' -o $tmpdir/ec.jwk +! jose jwk decap -i $tmpdir/ec.jwk -c "AAAA" diff --git a/tests/jose-jwk-encap b/tests/jose-jwk-encap new file mode 100755 index 0000000..b1e2ce8 --- /dev/null +++ b/tests/jose-jwk-encap @@ -0,0 +1,41 @@ +#!/bin/sh -ex + +tmpdir=`mktemp -d 2>/dev/null || mktemp -d -t jose` + +onexit() { + rm -rf $tmpdir +} + +trap onexit EXIT + +# Skip if no KEM algorithms are available +if ! jose alg -k kem | grep -q ML-KEM; then + echo "No ML-KEM algorithms available, skipping" + exit 77 +fi + +for ALG in ML-KEM-512 ML-KEM-768 ML-KEM-1024; do + jose jwk gen -i "{\"alg\":\"$ALG\"}" -o $tmpdir/kem.jwk + jose jwk pub -i $tmpdir/kem.jwk -o $tmpdir/kem_pub.jwk + + # Encapsulate with public key + jose jwk encap -i $tmpdir/kem_pub.jwk -o $tmpdir/enc.json + + # Verify output has ct and ss fields + CT=$(jose fmt -j $tmpdir/enc.json -Og ct -Su-) + test -n "$CT" + SS=$(jose fmt -j $tmpdir/enc.json -Og ss -Og k -Su-) + test -n "$SS" + + # Encapsulate with full key should also work + jose jwk encap -i $tmpdir/kem.jwk -o $tmpdir/enc2.json + CT2=$(jose fmt -j $tmpdir/enc2.json -Og ct -Su-) + test -n "$CT2" + + # Each encapsulation produces different ct and ss + test "$CT" != "$CT2" +done + +# Encap should fail with non-KEM key +jose jwk gen -i '{"alg":"ES256"}' -o $tmpdir/ec.jwk +! jose jwk encap -i $tmpdir/ec.jwk diff --git a/tests/meson.build b/tests/meson.build index d2aa442..4e6297e 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -15,6 +15,8 @@ scripts = [ 'jose-jwe-fmt', 'jose-jwe-dec', 'jose-jwe-enc', + 'jose-jwk-encap', + 'jose-jwk-decap', ] progs = [ @@ -23,6 +25,7 @@ progs = [ 'alg_encr', 'alg_wrap', 'alg_comp', + 'alg_kem', 'api_b64', 'api_jws', 'api_jwe', From d3b516584895bfabd43b1e15389a6fbbf646d9dc Mon Sep 17 00:00:00 2001 From: Sergio Correia Date: Mon, 20 Jul 2026 19:33:49 -0300 Subject: [PATCH 7/7] tests: add seed format validation and invalid key test Verify that generated ML-KEM private keys use the 64-byte seed format (d || z) per draft-ietf-jose-pqc-kem-06, not the expanded private key. Add a test with an invalid ML-KEM-768 public key (coefficient exceeding the field modulus q=3329) to verify jose handles malformed key material without crashing. Assisted-by: Claude Opus 4.6 Signed-off-by: Sergio Correia --- tests/alg_kem.c | 98 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/tests/alg_kem.c b/tests/alg_kem.c index 3eefea6..ed90484 100644 --- a/tests/alg_kem.c +++ b/tests/alg_kem.c @@ -179,6 +179,92 @@ test_thumbprint(const jose_hook_alg_t *a) assert(json_string_length(thp) > 0); } +static void +test_seed_format(const jose_hook_alg_t *a) +{ + json_auto_t *jwk = json_pack("{s:s}", "alg", a->name); + assert(jose_jwk_gen(NULL, jwk)); + + size_t priv_len = jose_b64_dec(json_object_get(jwk, "priv"), NULL, 0); + assert(priv_len == 64); +} + +static void +test_seed_determinism(const jose_hook_alg_t *a) +{ + json_auto_t *jwk = json_pack("{s:s}", "alg", a->name); + assert(jose_jwk_gen(NULL, jwk)); + + const char *pub1 = json_string_value(json_object_get(jwk, "pub")); + const char *priv_b64 = json_string_value(json_object_get(jwk, "priv")); + assert(pub1 && priv_b64); + + json_auto_t *jwk2 = json_pack("{s:s,s:s,s:s}", + "kty", "AKP", + "alg", a->name, + "priv", priv_b64); + assert(jwk2); + + json_auto_t *pub_jwk = json_deep_copy(jwk); + assert(jose_jwk_pub(NULL, pub_jwk)); + + json_auto_t *enc = jose_jwk_kem_enc(NULL, pub_jwk); + assert(enc); + + json_auto_t *dec = jose_jwk_kem_dec(NULL, jwk2, json_object_get(enc, "ct")); + assert(dec); + + const char *ss1 = json_string_value(json_object_get(json_object_get(enc, "ss"), "k")); + const char *ss2 = json_string_value(json_object_get(dec, "k")); + assert(ss1 && ss2); + assert(strcmp(ss1, ss2) == 0); +} + +static void +test_wrong_priv_len(const jose_hook_alg_t *a) +{ + json_auto_t *jwk = json_pack("{s:s}", "alg", a->name); + assert(jose_jwk_gen(NULL, jwk)); + + json_auto_t *pub_jwk = json_deep_copy(jwk); + assert(jose_jwk_pub(NULL, pub_jwk)); + + json_auto_t *enc = jose_jwk_kem_enc(NULL, pub_jwk); + assert(enc); + + unsigned char bad_priv[32]; + memset(bad_priv, 0x42, sizeof(bad_priv)); + json_auto_t *bad_jwk = json_pack("{s:s,s:s,s:O,s:o}", + "kty", "AKP", + "alg", a->name, + "pub", json_object_get(jwk, "pub"), + "priv", jose_b64_enc(bad_priv, sizeof(bad_priv))); + assert(bad_jwk); + + json_auto_t *dec = jose_jwk_kem_dec(NULL, bad_jwk, json_object_get(enc, "ct")); + assert(!dec); +} + +static void +test_invalid_pub_key(void) +{ + /* Use a wrong-length buffer: ML-KEM-768 expects 1184 bytes */ + unsigned char bad_pub[100]; + memset(bad_pub, 0x42, sizeof(bad_pub)); + + json_auto_t *pub_b64 = jose_b64_enc(bad_pub, sizeof(bad_pub)); + assert(pub_b64); + + json_auto_t *jwk = json_pack("{s:s,s:s,s:O}", + "kty", "AKP", + "alg", "ML-KEM-768", + "pub", pub_b64); + assert(jwk); + + json_auto_t *result = jose_jwk_kem_enc(NULL, jwk); + assert(!result); +} + int main(int argc, char *argv[]) { @@ -208,6 +294,15 @@ main(int argc, char *argv[]) test_thumbprint(a); fprintf(stderr, " thumbprint: OK\n"); + + test_seed_format(a); + fprintf(stderr, " seed format (64 bytes): OK\n"); + + test_seed_determinism(a); + fprintf(stderr, " seed determinism: OK\n"); + + test_wrong_priv_len(a); + fprintf(stderr, " wrong priv length rejected: OK\n"); } if (!found) { @@ -215,5 +310,8 @@ main(int argc, char *argv[]) return 77; } + test_invalid_pub_key(); + fprintf(stderr, " invalid pub key rejected: OK\n"); + return EXIT_SUCCESS; }