From 130e142ee23c4c562a81456041bc0cf42d8ae47f Mon Sep 17 00:00:00 2001 From: Remi Tricot-Le Breton Date: Fri, 1 Oct 2021 15:36:58 +0200 Subject: [PATCH] MEDIUM: jwt: Add jwt_verify converter to verify JWT integrity This new converter takes a JSON Web Token, an algorithm (among the ones specified for JWS tokens in RFC 7518) and a public key or a secret, and it returns a verdict about the signature contained in the token. It does not simply return a boolean because some specific error cases cas be specified by returning an integer instead, such as unmanaged algorithms or invalid tokens. This enables to distinguich malformed tokens from tampered ones, that would be valid format-wise but would have a bad signature. This converter does not perform a full JWT validation as decribed in section 7.2 of RFC 7519. For instance it does not ensure that the header and payload parts of the token are completely valid JSON objects because it would need a complete JSON parser. It only focuses on the signature and checks that it matches the token's contents. --- doc/configuration.txt | 50 +++++++++++ include/haproxy/jwt-t.h | 11 +++ include/haproxy/jwt.h | 3 + src/jwt.c | 192 ++++++++++++++++++++++++++++++++++++++++ src/sample.c | 55 ++++++++++++ 5 files changed, 311 insertions(+) diff --git a/doc/configuration.txt b/doc/configuration.txt index 465e326db7..982d9b3745 100644 --- a/doc/configuration.txt +++ b/doc/configuration.txt @@ -16637,6 +16637,56 @@ jwt_payload_query([],[]) Please note that this converter is only available when HAProxy has been compiled with USE_OPENSSL. +jwt_verify(,) + Performs a signature verification for the JSON Web Token (JWT) given in input + by using the algorithm and the parameter, which should either + hold a secret or a path to a public certificate. Returns 1 in cae of + verification success. See below for a full list of the possible return + values. + For now, only JWS tokens using the Compact Serialization format can be + processed (three dot-separated base64-url encoded strings). Among the + accepted algorithms for a JWS (see section 3.1 of RFC7518), the PSXXX ones + are not managed yet. + If the used algorithm is of the HMAC family, should be the secret used + in the HMAC signature calculation. Otherwise, should be the path to the + public certificate that can be used to validate the token's signature. All + the certificates that might be used to verify JWTs must be known during init + in order to be added into a dedicated certificate cache so that no disk + access is required during runtime. For this reason, any used certificate must + be mentioned explicitely at least once in a jwt_verify call. Passing an + intermediate variable as second parameter is then not advised. + + This converter only verifies the signature of the token and does not perform + a full JWT validation as specified in section 7.2 of RFC7519. We do not + ensure that the header and payload contents are fully valid JSON's once + decoded for instance, and no checks are performed regarding their respective + contents. + + The possible return values are the following : + + +----+---------------------------------------------------------------------------+ + | ID | message | + +----+---------------------------------------------------------------------------+ + | 0 | "Verification failure" | + | 1 | "Verification sucess" | + | 2 | "Unknown algorithm (not mentioned in RFC7518)" | + | 3 | "Unmanaged algorithm (PSXXX algorithm family)" | + | 4 | "Invalid token" | + | 5 | "Out of memory" | + | 6 | "Unknown certificate" | + +----+---------------------------------------------------------------------------+ + + Please note that this converter is only available when HAProxy has been + compiled with USE_OPENSSL. + + Example: + # Get a JWT from the authorization header, extract the "alg" field of its + # JOSE header and use a public certificate to verify a signature + http-request set-var(txn.bearer) http_auth_bearer + http-request set-var(txn.jwt_alg) var(txn.bearer),jwt_header_query('$.alg') + http-request deny unless { var(txn.jwt_alg) "RS256" } + http-request deny unless { var(txn.bearer),jwt_verify(txn.jwt_alg,"/path/to/crt.pem") 1 } + language([,]) Returns the value with the highest q-factor from a list as extracted from the "accept-language" header using "req.fhdr". Values with no q-factor have a diff --git a/include/haproxy/jwt-t.h b/include/haproxy/jwt-t.h index 4189e65065..3e7d57757c 100644 --- a/include/haproxy/jwt-t.h +++ b/include/haproxy/jwt-t.h @@ -67,6 +67,17 @@ struct jwt_cert_tree_entry { struct ebmb_node node; char path[VAR_ARRAY]; }; + +enum jwt_vrfy_status { + JWT_VRFY_KO = 0, + JWT_VRFY_OK = 1, + JWT_VRFY_UNKNOWN_ALG, + JWT_VRFY_UNMANAGED_ALG, + JWT_VRFY_INVALID_TOKEN, + JWT_VRFY_OUT_OF_MEMORY, + JWT_VRFY_UNKNOWN_CERT +}; + #endif /* USE_OPENSSL */ diff --git a/include/haproxy/jwt.h b/include/haproxy/jwt.h index 6ae6e63802..a343ffaf75 100644 --- a/include/haproxy/jwt.h +++ b/include/haproxy/jwt.h @@ -29,6 +29,9 @@ 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); + +enum jwt_vrfy_status jwt_verify(const struct buffer *token, const struct buffer *alg, + const struct buffer *key); #endif /* USE_OPENSSL */ #endif /* _HAPROXY_JWT_H */ diff --git a/src/jwt.c b/src/jwt.c index 0f2e00c49e..fd4626215d 100644 --- a/src/jwt.c +++ b/src/jwt.c @@ -165,4 +165,196 @@ int jwt_tree_load_cert(char *path, int pathlen, char **err) BIO_free(bio); return retval; } + +/* + * Calculate the HMAC signature of a specific JWT and check that it matches the + * one included in the token. + * Returns 1 in case of success. + */ +static enum jwt_vrfy_status +jwt_jwsverify_hmac(const struct jwt_ctx *ctx, const struct buffer *decoded_signature) +{ + const EVP_MD *evp = NULL; + unsigned char *signature = NULL; + unsigned int signature_length = 0; + struct buffer *trash = NULL; + unsigned char *hmac_res = NULL; + enum jwt_vrfy_status retval = JWT_VRFY_KO; + + trash = alloc_trash_chunk(); + if (!trash) + return JWT_VRFY_OUT_OF_MEMORY; + + signature = (unsigned char*)trash->area; + signature_length = trash->size; + + switch(ctx->alg) { + case JWS_ALG_HS256: + evp = EVP_sha256(); + break; + case JWS_ALG_HS384: + evp = EVP_sha384(); + break; + case JWS_ALG_HS512: + evp = EVP_sha512(); + break; + default: break; + } + + hmac_res = HMAC(evp, ctx->key, ctx->key_length, (const unsigned char*)ctx->jose.start, + ctx->jose.length + ctx->claims.length + 1, signature, &signature_length); + + if (hmac_res && signature_length == decoded_signature->data && + (memcmp(decoded_signature->area, signature, signature_length) == 0)) + retval = JWT_VRFY_OK; + + free_trash_chunk(trash); + + return retval; +} + +/* + * Check that the signature included in a JWT signed via RSA or ECDSA is valid + * and can be verified thanks to a given public certificate. + * Returns 1 in case of success. + */ +static enum jwt_vrfy_status +jwt_jwsverify_rsa_ecdsa(const struct jwt_ctx *ctx, const struct buffer *decoded_signature) +{ + const EVP_MD *evp = NULL; + EVP_MD_CTX *evp_md_ctx; + enum jwt_vrfy_status retval = JWT_VRFY_KO; + struct buffer *trash = NULL; + struct ebmb_node *eb; + struct jwt_cert_tree_entry *entry = NULL; + + trash = alloc_trash_chunk(); + if (!trash) + return JWT_VRFY_OUT_OF_MEMORY; + + switch(ctx->alg) { + case JWS_ALG_RS256: + case JWS_ALG_ES256: + evp = EVP_sha256(); + break; + case JWS_ALG_RS384: + case JWS_ALG_ES384: + evp = EVP_sha384(); + break; + case JWS_ALG_RS512: + case JWS_ALG_ES512: + evp = EVP_sha512(); + break; + default: break; + } + + evp_md_ctx = EVP_MD_CTX_new(); + if (!evp_md_ctx) { + free_trash_chunk(trash); + return JWT_VRFY_OUT_OF_MEMORY; + } + + eb = ebst_lookup(&jwt_cert_tree, ctx->key); + + if (!eb) { + retval = JWT_VRFY_UNKNOWN_CERT; + goto end; + } + + entry = ebmb_entry(eb, struct jwt_cert_tree_entry, node); + + if (!entry->pkey) { + retval = JWT_VRFY_UNKNOWN_CERT; + goto end; + } + + if (EVP_DigestVerifyInit(evp_md_ctx, NULL, evp, NULL,entry-> pkey) == 1 && + EVP_DigestVerifyUpdate(evp_md_ctx, (const unsigned char*)ctx->jose.start, + ctx->jose.length + ctx->claims.length + 1) == 1 && + EVP_DigestVerifyFinal(evp_md_ctx, (const unsigned char*)decoded_signature->area, decoded_signature->data) == 1) { + retval = JWT_VRFY_OK; + } + +end: + EVP_MD_CTX_free(evp_md_ctx); + free_trash_chunk(trash); + return retval; +} + +/* + * Check that the that was signed via algorithm using the + * (either an HMAC secret or the path to a public certificate) has a valid + * signature. + * Returns 1 in case of success. + */ +enum jwt_vrfy_status jwt_verify(const struct buffer *token, const struct buffer *alg, + const struct buffer *key) +{ + struct jwt_item items[JWT_ELT_MAX] = { { 0 } }; + unsigned int item_num = JWT_ELT_MAX; + + struct buffer *decoded_sig = NULL; + struct jwt_ctx ctx = {}; + enum jwt_vrfy_status retval = JWT_VRFY_KO; + + ctx.alg = jwt_parse_alg(alg->area, alg->data); + + if (ctx.alg == JWT_ALG_DEFAULT) + return JWT_VRFY_UNKNOWN_ALG; + + if (jwt_tokenize(token, items, &item_num)) + return JWT_VRFY_INVALID_TOKEN; + + if (item_num != JWT_ELT_MAX) + if (ctx.alg != JWS_ALG_NONE || item_num != JWT_ELT_SIG) + return JWT_VRFY_INVALID_TOKEN; + + ctx.jose = items[JWT_ELT_JOSE]; + ctx.claims = items[JWT_ELT_CLAIMS]; + ctx.signature = items[JWT_ELT_SIG]; + + /* "alg" is "none", the signature must be empty for the JWS to be valid. */ + if (ctx.alg == JWS_ALG_NONE) { + return (ctx.signature.length == 0) ? JWT_VRFY_OK : JWT_VRFY_KO; + } + + if (ctx.signature.length == 0) + return JWT_VRFY_INVALID_TOKEN; + + decoded_sig = alloc_trash_chunk(); + if (!decoded_sig) + return JWT_VRFY_OUT_OF_MEMORY; + + decoded_sig->data = base64urldec(ctx.signature.start, ctx.signature.length, + decoded_sig->area, decoded_sig->size); + if (decoded_sig->data == (unsigned int)-1) { + retval = JWT_VRFY_INVALID_TOKEN; + goto end; + } + + ctx.key = key->area; + ctx.key_length = key->data; + + /* We have all three sections, signature calculation can begin. */ + + if (ctx.alg <= JWS_ALG_HS512) { + /* HMAC + SHA-XXX */ + retval = jwt_jwsverify_hmac(&ctx, decoded_sig); + } else if (ctx.alg <= JWS_ALG_ES512) { + /* RSASSA-PKCS1-v1_5 + SHA-XXX */ + /* ECDSA using P-XXX and SHA-XXX */ + retval = jwt_jwsverify_rsa_ecdsa(&ctx, decoded_sig); + } else if (ctx.alg <= JWS_ALG_PS512) { + /* RSASSA-PSS using SHA-XXX and MGF1 with SHA-XXX */ + + /* Not managed yet */ + retval = JWT_VRFY_UNMANAGED_ALG; + } + +end: + free_trash_chunk(decoded_sig); + + return retval; +} + #endif /* USE_OPENSSL */ diff --git a/src/sample.c b/src/sample.c index 7b78433040..de45245e93 100644 --- a/src/sample.c +++ b/src/sample.c @@ -3495,6 +3495,60 @@ static int sample_conv_json_query(const struct arg *args, struct sample *smp, vo } #ifdef USE_OPENSSL +static int sample_conv_jwt_verify_check(struct arg *args, struct sample_conv *conv, + const char *file, int line, char **err) +{ + vars_check_arg(&args[0], NULL); + vars_check_arg(&args[1], NULL); + + if (args[0].type == ARGT_STR) { + enum jwt_alg alg = jwt_parse_alg(args[0].data.str.area, args[0].data.str.data); + + switch(alg) { + case JWT_ALG_DEFAULT: + memprintf(err, "unknown JWT algorithm : %s", *err); + break; + + case JWS_ALG_PS256: + case JWS_ALG_PS384: + case JWS_ALG_PS512: + memprintf(err, "RSASSA-PSS JWS signing not managed yet"); + break; + + default: + break; + } + } + + if (args[1].type == ARGT_STR) { + jwt_tree_load_cert(args[1].data.str.area, args[1].data.str.data, err); + } + + return 1; +} + +/* Check that a JWT's signature is correct */ +static int sample_conv_jwt_verify(const struct arg *args, struct sample *smp, void *private) +{ + struct sample alg_smp, key_smp; + + smp->data.type = SMP_T_SINT; + smp->data.u.sint = 0; + + smp_set_owner(&alg_smp, smp->px, smp->sess, smp->strm, smp->opt); + smp_set_owner(&key_smp, smp->px, smp->sess, smp->strm, smp->opt); + if (!sample_conv_var2smp_str(&args[0], &alg_smp)) + return 0; + if (!sample_conv_var2smp_str(&args[1], &key_smp)) + return 0; + + smp->data.u.sint = jwt_verify(&smp->data.u.str, &alg_smp.data.u.str, + &key_smp.data.u.str); + + return 1; +} + + /* * Returns the decoded header or payload of a JWT if no parameter is given, or * the value of the specified field of the corresponding JWT subpart if a @@ -4091,6 +4145,7 @@ static struct sample_conv_kw_list sample_conv_kws = {ILH, { /* JSON Web Token converters */ { "jwt_header_query", sample_conv_jwt_header_query, ARG2(0,STR,STR), sample_conv_jwt_query_check, SMP_T_BIN, SMP_T_ANY }, { "jwt_payload_query", sample_conv_jwt_payload_query, ARG2(0,STR,STR), sample_conv_jwt_query_check, SMP_T_BIN, SMP_T_ANY }, + { "jwt_verify", sample_conv_jwt_verify, ARG2(2,STR,STR), sample_conv_jwt_verify_check, SMP_T_BIN, SMP_T_SINT }, #endif { NULL, NULL, 0, 0, 0 }, }};