From: Remi Tricot-Le Breton Date: Mon, 30 Jun 2025 14:56:26 +0000 (+0200) Subject: MAJOR: jwt: Allow certificate instead of public key in jwt_verify converter X-Git-Tag: v3.3-dev3~64 X-Git-Url: http://git.ipfire.org/?a=commitdiff_plain;h=522bca98e14385beaf0b06a4f711777ba1aab933;p=thirdparty%2Fhaproxy.git MAJOR: jwt: Allow certificate instead of public key in jwt_verify converter The 'jwt_verify' converter could only be passed public keys as second parameter instead of full-on public certificates. This patch allows proper certificates to be used. Those certificates can be loaded in ckch_stores like any other certificate which means that all the certificate-related operations that can be made via the CLI can now benefit JWT validation as well. We now have two ways JWT validation can work, the legacy one which only relies on public keys which could not be stored in ckch_stores without some in depth changes in the way the ckch_stores are built. In this legacy way, the public keys are fully stored in a cache dedicated to JWT only which does not have any CLI commands and any way to update them during runtime. It also requires that all the public keys used are passed at least once explicitely to the 'jwt_verify' converter so that they can be loaded during init. The new way uses actual certificates, either already stored in the ckch_store tree (if predefined in a crt-store or already used previously in the configuration) or loaded in the ckch_store tree during init if they are explicitely used in the configuration like so: var(txn.bearer),jwt_verify(txn.jwt_alg,"cert.pem") When using a variable (or any other way that can only be resolved during runtime) in place of the converter's parameter, the first time we encounter a new value (for which we don't have any entry in the jwt tree) we will lock the ckch_store tree and try to perform a lookup in it. If the lookup fails, an entry will still be inserted into the jwt tree so that any following call with this value avoids performing the ckch_store tree lookup. --- diff --git a/include/haproxy/jwt-t.h b/include/haproxy/jwt-t.h index 4f611b38e..1372fddac 100644 --- a/include/haproxy/jwt-t.h +++ b/include/haproxy/jwt-t.h @@ -64,8 +64,17 @@ enum jwt_elt { JWT_ELT_MAX }; +enum jwt_entry_type { + JWT_ENTRY_DFLT, + JWT_ENTRY_STORE, + JWT_ENTRY_PKEY, + JWT_ENTRY_INVALID, /* already tried looking into ckch_store tree (unsuccessful) */ +}; + struct jwt_cert_tree_entry { EVP_PKEY *pubkey; + struct ckch_store *ckch_store; + int type; /* jwt_entry_type */ struct ebmb_node node; char path[VAR_ARRAY]; }; @@ -78,7 +87,8 @@ enum jwt_vrfy_status { JWT_VRFY_UNMANAGED_ALG = -2, JWT_VRFY_INVALID_TOKEN = -3, JWT_VRFY_OUT_OF_MEMORY = -4, - JWT_VRFY_UNKNOWN_CERT = -5 + JWT_VRFY_UNKNOWN_CERT = -5, + JWT_VRFY_INTERNAL_ERR = -6 }; #endif /* USE_OPENSSL */ diff --git a/include/haproxy/jwt.h b/include/haproxy/jwt.h index a343ffaf7..9e2df709a 100644 --- a/include/haproxy/jwt.h +++ b/include/haproxy/jwt.h @@ -28,10 +28,13 @@ #ifdef USE_OPENSSL enum jwt_alg jwt_parse_alg(const char *alg_str, unsigned int alg_len); int jwt_tokenize(const struct buffer *jwt, struct jwt_item *items, unsigned int *item_num); -int jwt_tree_load_cert(char *path, int pathlen, char **err); +int jwt_tree_load_cert(char *path, int pathlen, const char *file, int line, char **err); enum jwt_vrfy_status jwt_verify(const struct buffer *token, const struct buffer *alg, const struct buffer *key); + +void jwt_replace_ckch_store(struct ckch_store *old_ckchs, struct ckch_store *new_ckchs); + #endif /* USE_OPENSSL */ #endif /* _HAPROXY_JWT_H */ diff --git a/include/haproxy/ssl_ckch-t.h b/include/haproxy/ssl_ckch-t.h index 00a1d729b..f3076e9a7 100644 --- a/include/haproxy/ssl_ckch-t.h +++ b/include/haproxy/ssl_ckch-t.h @@ -73,6 +73,8 @@ struct ckch_conf { } acme; }; +struct jwt_cert_tree_entry; + /* * this is used to store 1 to SSL_SOCK_NUM_KEYTYPES cert_key_and_chain and * metadata. @@ -88,6 +90,7 @@ struct ckch_store { struct list crtlist_entry; /* list of entries which use this store */ struct ckch_conf conf; struct task *acme_task; + struct jwt_cert_tree_entry *jwt_entry; struct ebmb_node node; char path[VAR_ARRAY]; }; diff --git a/include/haproxy/thread-t.h b/include/haproxy/thread-t.h index 2c99a4cb9..c9683542f 100644 --- a/include/haproxy/thread-t.h +++ b/include/haproxy/thread-t.h @@ -217,6 +217,7 @@ enum lock_label { QC_CID_LOCK, CACHE_LOCK, GUID_LOCK, + JWT_LOCK, OTHER_LOCK, /* WT: make sure never to use these ones outside of development, * we need them for lock profiling! diff --git a/src/jwt.c b/src/jwt.c index 17d32cd16..a545c2629 100644 --- a/src/jwt.c +++ b/src/jwt.c @@ -10,6 +10,8 @@ * 2 of the License, or (at your option) any later version. */ +#include + #include #include @@ -19,11 +21,16 @@ #include #include #include +#include +#include #ifdef USE_OPENSSL + /* Tree into which the public certificates used to validate JWTs will be stored. */ -static struct eb_root jwt_cert_tree = EB_ROOT_UNIQUE; +struct eb_root jwt_cert_tree = EB_ROOT_UNIQUE; +__decl_rwlock(jwt_tree_lock); + /* * The possible algorithm strings that can be found in a JWS's JOSE header are @@ -125,14 +132,24 @@ int jwt_tokenize(const struct buffer *jwt, struct jwt_item *items, unsigned int /* * Parse a public certificate and insert it into the jwt_cert_tree. + * This function can only be called during configuration parsing so we do not + * need to lock the jwt certificate tree. * Returns 0 in case of success. */ -int jwt_tree_load_cert(char *path, int pathlen, char **err) +int jwt_tree_load_cert(char *path, int pathlen, const char *file, int line, char **err) { int retval = -1; struct jwt_cert_tree_entry *entry = NULL; EVP_PKEY *pubkey = NULL; BIO *bio = NULL; + struct stat buf; + struct ebmb_node *eb = NULL; + struct ckch_store *store = NULL; + + eb = ebst_lookup(&jwt_cert_tree, path); + + if (eb) + return 0; /* Entry already in the tree, nothing to do. */ entry = calloc(1, sizeof(*entry) + pathlen + 1); if (!entry) { @@ -140,31 +157,90 @@ int jwt_tree_load_cert(char *path, int pathlen, char **err) return -1; } memcpy(entry->path, path, pathlen + 1); + entry->type = JWT_ENTRY_DFLT; if (ebst_insert(&jwt_cert_tree, &entry->node) != &entry->node) { + /* Should never happen since we checked if the entry already + * existed previously. + */ free(entry); - return 0; /* Entry already in the tree */ + return 0; } - bio = BIO_new(BIO_s_file()); - if (!bio) { - memprintf(err, "%sunable to allocate memory (BIO).\n", err && *err ? *err : ""); - goto end; + if (stat(path, &buf) == 0) { + bio = BIO_new(BIO_s_file()); + if (!bio) { + memprintf(err, "%sunable to allocate memory (BIO).\n", err && *err ? *err : ""); + goto end; + } + + if (BIO_read_filename(bio, path) == 1) { + pubkey = PEM_read_bio_PUBKEY(bio, NULL, NULL, NULL); + + /* The file might exist but not contain a public key if + * we were given an actual certificate path (or a + * named crt-store). + */ + if (pubkey) { + entry->type = JWT_ENTRY_PKEY; + entry->pubkey = pubkey; + retval = 0; + goto end; + } + } } - if (BIO_read_filename(bio, path) == 1) { + /* Look for an actual certificate or crt-store with the given name. + * If the path corresponds to an actual certificate that was not loaded + * yet we will create the corresponding ckch_store. */ + if (HA_SPIN_TRYLOCK(CKCH_LOCK, &ckch_lock)) + goto end; + + store = ckchs_lookup(path); + if (!store) { + struct ckch_conf conf = {}; + int err_code = 0; + + /* Create a new store with the given path */ + store = ckch_store_new(path); + if (!store) { + HA_SPIN_UNLOCK(CKCH_LOCK, &ckch_lock); + goto end; + } + + conf.crt = path; - pubkey = PEM_read_bio_PUBKEY(bio, NULL, NULL, NULL); + err_code = ckch_store_load_files(&conf, store, 0, file, line, err); + if (err_code & ERR_FATAL) { + ckch_store_free(store); - if (!pubkey) { - memprintf(err, "%sfile not found (%s)\n", err && *err ? *err : "", path); + /* If we are in this case we are in the conf + * parsing phase and this case might happen if + * we were provided an HMAC secret or a variable + * name. + */ + retval = 0; + ha_free(err); + HA_SPIN_UNLOCK(CKCH_LOCK, &ckch_lock); goto end; } - entry->pubkey = pubkey; - retval = 0; + if (ebst_insert(&ckchs_tree, &store->node) != &store->node) { + ckch_store_free(store); + HA_SPIN_UNLOCK(CKCH_LOCK, &ckch_lock); + goto end; + } } + retval = 0; + + BUG_ON(store->jwt_entry != NULL); + entry->type = JWT_ENTRY_STORE; + entry->ckch_store = store; + entry->pubkey = X509_get_pubkey(store->data->cert); + store->jwt_entry = entry; + HA_SPIN_UNLOCK(CKCH_LOCK, &ckch_lock); + end: if (retval) { /* Some error happened during pubkey parsing, remove the already @@ -174,9 +250,67 @@ end: free(entry); } BIO_free(bio); + return retval; } + +/* Try to look for an already existing ckch_store in the store tree with the + * path found in the jwt_entry. Keep a reference to its pubkey if it exists. + * Return 0 in case of success. + */ +static int jwt_tree_tryload_store(struct jwt_cert_tree_entry *jwt_entry) +{ + struct ckch_store *store = NULL; + int retval = 1; + + if (!jwt_entry) + return 1; + + /* We might have been given a 'crt-store' name */ + if (HA_SPIN_TRYLOCK(CKCH_LOCK, &ckch_lock)) + return 1; + + store = ckchs_lookup(jwt_entry->path); + if (!store || store->jwt_entry) + goto end; + + store->jwt_entry = jwt_entry; + + BUG_ON(jwt_entry->pubkey != NULL); + jwt_entry->pubkey = X509_get_pubkey(store->data->cert); + + retval = 0; + +end: + HA_SPIN_UNLOCK(CKCH_LOCK, &ckch_lock); + return retval; + +} + +/* Update the ckch_store and public key reference of a jwt_entry. This is only + * useful whne updating a certificate from the CLI if it was being used for JWT + * validation. + */ +void jwt_replace_ckch_store(struct ckch_store *old_ckchs, struct ckch_store *new_ckchs) +{ + struct jwt_cert_tree_entry *entry = old_ckchs->jwt_entry; + + HA_RWLOCK_WRLOCK(JWT_LOCK, &jwt_tree_lock); + + if (entry == NULL) + goto end; + + old_ckchs->jwt_entry->ckch_store = new_ckchs; + new_ckchs->jwt_entry = old_ckchs->jwt_entry; + + EVP_PKEY_free(entry->pubkey); + entry->pubkey = X509_get_pubkey(new_ckchs->data->cert); + +end: + HA_RWLOCK_WRUNLOCK(JWT_LOCK, &jwt_tree_lock); +} + /* * Calculate the HMAC signature of a specific JWT and check that it matches the * one included in the token. @@ -277,10 +411,12 @@ jwt_jwsverify_rsa_ecdsa(const struct jwt_ctx *ctx, struct buffer *decoded_signat EVP_MD_CTX *evp_md_ctx; EVP_PKEY_CTX *pkey_ctx = NULL; enum jwt_vrfy_status retval = JWT_VRFY_KO; - struct ebmb_node *eb; + struct ebmb_node *eb = NULL; struct jwt_cert_tree_entry *entry = NULL; int is_ecdsa = 0; int padding = RSA_PKCS1_PADDING; + EVP_PKEY *pubkey = NULL; + int lock_iswrlock = 0; switch(ctx->alg) { case JWS_ALG_RS256: @@ -325,19 +461,71 @@ jwt_jwsverify_rsa_ecdsa(const struct jwt_ctx *ctx, struct buffer *decoded_signat if (!evp_md_ctx) return JWT_VRFY_OUT_OF_MEMORY; + HA_RWLOCK_RDLOCK(JWT_LOCK, &jwt_tree_lock); + eb = ebst_lookup(&jwt_cert_tree, ctx->key); if (!eb) { + HA_RWLOCK_RDUNLOCK(JWT_LOCK, &jwt_tree_lock); + + /* Create new entry and insert it in the jwt cert tree if we + * could find a corresponding ckch_store. + */ + entry = calloc(1, sizeof(*entry) + ctx->key_length + 1); + if (!entry) { + retval = JWT_VRFY_OUT_OF_MEMORY; + goto end; + } + memcpy(entry->path, ctx->key, ctx->key_length); + + /* The ckch_lock will be taken in jwt_tree_tryload_store so we + * can't hold the lock on the jwt_cert_tree here because the lock + * order is different when updating a certificate from the CLI, + * where the ckch_lock is taken first and then the JWT one is + * taken in jwt_replace_ckch_store. + * If no corresponding ckch_store was found, we still try to + * insert the entry in the tree so that next calls to jwt_verify + * with the same 'key' path do not perform the lookup in the + * ckch_store anymore. */ + entry->type = (jwt_tree_tryload_store(entry) == 0) ? JWT_ENTRY_STORE : JWT_ENTRY_INVALID; + + HA_RWLOCK_WRLOCK(JWT_LOCK, &jwt_tree_lock); + if (ebst_insert(&jwt_cert_tree, &entry->node) != &entry->node) { + /* This rather unlikely case can only happen if the tree was + * modified between the previous read unlock and here. + */ + retval = JWT_VRFY_INTERNAL_ERR; + free(entry); + entry = NULL; + HA_RWLOCK_WRUNLOCK(JWT_LOCK, &jwt_tree_lock); + goto end; + } + lock_iswrlock = 1; + } else { + entry = ebmb_entry(eb, struct jwt_cert_tree_entry, node); + } + + /* We tried looking for a ckch_store but could not find it */ + switch (entry->type) { + case JWT_ENTRY_PKEY: + case JWT_ENTRY_STORE: + pubkey = entry->pubkey; + if (pubkey) + EVP_PKEY_up_ref(pubkey); + break; + case JWT_ENTRY_DFLT: + case JWT_ENTRY_INVALID: retval = JWT_VRFY_UNKNOWN_CERT; - goto end; + break; } - entry = ebmb_entry(eb, struct jwt_cert_tree_entry, node); + if (lock_iswrlock) + HA_RWLOCK_WRUNLOCK(JWT_LOCK, &jwt_tree_lock); + else + HA_RWLOCK_RDUNLOCK(JWT_LOCK, &jwt_tree_lock); - if (!entry->pubkey) { - retval = JWT_VRFY_UNKNOWN_CERT; + if (!pubkey) goto end; - } /* * ECXXX signatures are a direct concatenation of the (R, S) pair and @@ -352,7 +540,7 @@ jwt_jwsverify_rsa_ecdsa(const struct jwt_ctx *ctx, struct buffer *decoded_signat } } - if (EVP_DigestVerifyInit(evp_md_ctx, &pkey_ctx, evp, NULL, entry->pubkey) == 1) { + if (EVP_DigestVerifyInit(evp_md_ctx, &pkey_ctx, evp, NULL, pubkey) == 1) { if (is_ecdsa || EVP_PKEY_CTX_set_rsa_padding(pkey_ctx, padding) > 0) { if (EVP_DigestVerifyUpdate(evp_md_ctx, (const unsigned char*)ctx->jose.start, ctx->jose.length + ctx->claims.length + 1) == 1 && @@ -371,6 +559,7 @@ end: */ ERR_clear_error(); } + EVP_PKEY_free(pubkey); return retval; } @@ -470,6 +659,8 @@ static void jwt_deinit(void) struct ebmb_node *node = NULL; struct jwt_cert_tree_entry *entry = NULL; + HA_RWLOCK_WRLOCK(JWT_LOCK, &jwt_tree_lock); + node = ebmb_first(&jwt_cert_tree); while (node) { entry = ebmb_entry(node, struct jwt_cert_tree_entry, node); @@ -478,6 +669,8 @@ static void jwt_deinit(void) ha_free(&entry); node = ebmb_first(&jwt_cert_tree); } + + HA_RWLOCK_WRUNLOCK(JWT_LOCK, &jwt_tree_lock); } REGISTER_POST_DEINIT(jwt_deinit); diff --git a/src/sample.c b/src/sample.c index b91e97ce5..77c2977cf 100644 --- a/src/sample.c +++ b/src/sample.c @@ -4457,6 +4457,7 @@ static int sample_conv_jwt_verify_check(struct arg *args, struct sample_conv *co const char *file, int line, char **err) { enum jwt_alg alg = JWT_ALG_DEFAULT; + int retval = 0; vars_check_arg(&args[0], NULL); vars_check_arg(&args[1], NULL); @@ -4476,14 +4477,22 @@ static int sample_conv_jwt_verify_check(struct arg *args, struct sample_conv *co JWS_ALG_HS384: JWS_ALG_HS512: /* don't try to load a file with HMAC algorithms */ + retval = 1; break; default: - jwt_tree_load_cert(args[1].data.str.area, args[1].data.str.data, err); + retval = (jwt_tree_load_cert(args[1].data.str.area, args[1].data.str.data, + file, line, err) == 0); break; } + } else if (args[1].type == ARGT_VAR) { + /* We will try to resolve the var during runtime because the + * processing might work if it actually points to an already + * existing ckch_store. + */ + retval = 1; } - return 1; + return retval; } /* Check that a JWT's signature is correct */ diff --git a/src/ssl_ckch.c b/src/ssl_ckch.c index 9157421b4..75dc02caa 100644 --- a/src/ssl_ckch.c +++ b/src/ssl_ckch.c @@ -41,6 +41,7 @@ #include #include #include +#include /* Uncommitted CKCH transaction */ @@ -2729,6 +2730,8 @@ void ckch_store_replace(struct ckch_store *old_ckchs, struct ckch_store *new_ckc __ckch_inst_free_locked(ckchi); } + jwt_replace_ckch_store(old_ckchs, new_ckchs); + ckch_store_free(old_ckchs); ebst_insert(&ckchs_tree, &new_ckchs->node); } diff --git a/src/thread.c b/src/thread.c index 8c0c5c294..266c1562b 100644 --- a/src/thread.c +++ b/src/thread.c @@ -437,6 +437,7 @@ const char *lock_label(enum lock_label label) case QC_CID_LOCK: return "QC_CID"; case CACHE_LOCK: return "CACHE"; case GUID_LOCK: return "GUID"; + case JWT_LOCK: return "JWT"; case OTHER_LOCK: return "OTHER"; case DEBUG1_LOCK: return "DEBUG1"; case DEBUG2_LOCK: return "DEBUG2";