diff --git a/configure.ac b/configure.ac index 77315be2f..15069dd4a 100644 --- a/configure.ac +++ b/configure.ac @@ -230,38 +230,6 @@ AM_CONDITIONAL([HAVE_MICROHTTPD], [test "$with_microhttpd" != "no"]) AC_SUBST([MICROHTTPD_CFLAGS]) AC_SUBST([MICROHTTPD_LIBS]) -dnl libcjwt -AC_ARG_WITH([cjwt], - AC_HELP_STRING([--with-cjwt], [(Experimental) use cjwt to validate JWT tokens @<:@default=check@:>@]), - [], - [with_cjwt=check]) -if test "$with_cjwt" != "no"; then - PKG_CHECK_MODULES([CJWT], [libcjwt >= 1.0.0], - [with_cjwt=yes],[with_cjwt=no]) -fi -if test "$with_cjwt" = "yes"; then - AC_DEFINE_UNQUOTED([HAVE_CJWT], 1, [whether libcjwt is available for KDC REST API]) -fi -AM_CONDITIONAL([HAVE_CJWT], [test "$with_cjwt" != "no"]) -AC_SUBST([CJWT_CFLAGS]) -AC_SUBST([CJWT_LIBS]) - -dnl libcjson -AC_ARG_WITH([cjson], - AC_HELP_STRING([--with-cjson], [(Experimental) use cJSON to extract private claims from JWT tokens @<:@default=check@:>@]), - [], - [with_cjson=check]) -if test "$with_cjson" != "no"; then - PKG_CHECK_MODULES([CJSON], [libcjson >= 1.0.0], - [with_cjson=yes],[with_cjson=no]) -fi -if test "$with_cjson" = "yes"; then - AC_DEFINE_UNQUOTED([HAVE_CJSON], 1, [whether libcjson is available for KDC REST API]) -fi -AM_CONDITIONAL([HAVE_CJSON], [test "$with_cjson" != "no"]) -AC_SUBST([CJSON_CFLAGS]) -AC_SUBST([CJSON_LIBS]) - dnl mitkrb5 AC_ARG_WITH([mitkrb5], AC_HELP_STRING([--with-mitkrb5], [Path to MIT Kerberos for interop testing @<:@default=check@:>@]), diff --git a/include/Makefile.am b/include/Makefile.am index bca303a28..3dd71b40d 100644 --- a/include/Makefile.am +++ b/include/Makefile.am @@ -133,7 +133,6 @@ CLEANFILES = \ kdc-audit.h \ csr_authorizer_plugin.h \ gss_preauth_authorizer_plugin.h \ - token_validator_plugin.h \ xdbm.h \ x690sample_asn1.h \ x690sample_template_asn1.h diff --git a/kdc/Makefile.am b/kdc/Makefile.am index e07438c38..267084092 100644 --- a/kdc/Makefile.am +++ b/kdc/Makefile.am @@ -7,12 +7,7 @@ WFLAGS += $(WFLAGS_ENUM_CONV) AM_CPPFLAGS += $(INCLUDE_libintl) $(INCLUDE_openssl_crypto) -I$(srcdir)/../lib/krb5 lib_LTLIBRARIES = ipc_csr_authorizer.la \ - negotiate_token_validator.la \ libkdc.la - -if HAVE_CJWT -lib_LTLIBRARIES += cjwt_token_validator.la -endif if OPENLDAP lib_LTLIBRARIES += altsecid_gss_preauth_authorizer.la endif @@ -23,7 +18,8 @@ bin_PROGRAMS = string2key sbin_PROGRAMS = kstash libexec_PROGRAMS = hprop hpropd kdc \ - test_token_validator test_csr_authorizer test_kdc_ca + test_token_validator test_csr_authorizer test_kdc_ca \ + test_jwt_validator noinst_PROGRAMS = kdc-replay kdc-tester @@ -84,16 +80,8 @@ kdc_tester_SOURCES = \ test_token_validator_SOURCES = test_token_validator.c test_csr_authorizer_SOURCES = test_csr_authorizer.c test_kdc_ca_SOURCES = test_kdc_ca.c +test_jwt_validator_SOURCES = test_jwt_validator.c jwt_validator.c -# Token plugins (for bx509d) -if HAVE_CJWT -cjwt_token_validator_la_SOURCES = cjwt_token_validator.c -cjwt_token_validator_la_CFLAGS = $(CJSON_CFLAGS) $(CJWT_CFLAGS) -cjwt_token_validator_la_LDFLAGS = -module $(CJSON_LIBS) $(CJWT_LIBS) -endif - -negotiate_token_validator_la_SOURCES = negotiate_token_validator.c -negotiate_token_validator_la_LDFLAGS = -module $(LIB_gssapi) # CSR Authorizer plugins (for kdc/kx509 and bx509d) ipc_csr_authorizer_la_SOURCES = ipc_csr_authorizer.c ipc_csr_authorizer_la_LDFLAGS = -module \ @@ -127,6 +115,8 @@ libkdc_la_SOURCES = \ misc.c \ kx509.c \ token_validator.c \ + jwt_validator.c \ + jwt_validator.h \ csr_authorizer.c \ process.c \ kdc-plugin.c \ @@ -147,12 +137,11 @@ ALL_OBJECTS += $(hprop_OBJECTS) ALL_OBJECTS += $(hpropd_OBJECTS) ALL_OBJECTS += $(bx509d_OBJECTS) ALL_OBJECTS += $(httpkadmind_OBJECTS) -ALL_OBJECTS += $(cjwt_token_validator_la_OBJECTS) ALL_OBJECTS += $(test_token_validator_OBJECTS) ALL_OBJECTS += $(test_csr_authorizer_OBJECTS) ALL_OBJECTS += $(test_kdc_ca_OBJECTS) +ALL_OBJECTS += $(test_jwt_validator_OBJECTS) ALL_OBJECTS += $(ipc_csr_authorizer_la_OBJECTS) -ALL_OBJECTS += $(negotiate_token_validator_la_OBJECTS) $(ALL_OBJECTS): $(KDC_PROTOS) @@ -226,13 +215,14 @@ test_csr_authorizer_LDADD = libkdc.la \ $(LIB_heimbase) \ $(top_builddir)/lib/ipc/libheim-ipcs.la test_kdc_ca_LDADD = libkdc.la $(top_builddir)/lib/hx509/libhx509.la $(LDADD) $(LIB_pidfile) $(LIB_heimbase) +test_jwt_validator_LDADD = libkdc.la $(top_builddir)/lib/hx509/libhx509.la $(LDADD) $(LIB_pidfile) $(LIB_heimbase) include_HEADERS = kdc.h $(srcdir)/kdc-protos.h noinst_HEADERS = $(srcdir)/kdc-private.h krb5dir = $(includedir)/krb5 -krb5_HEADERS = kdc-audit.h kdc-plugin.h kdc-accessors.h token_validator_plugin.h csr_authorizer_plugin.h gss_preauth_authorizer_plugin.h +krb5_HEADERS = kdc-audit.h kdc-plugin.h kdc-accessors.h csr_authorizer_plugin.h gss_preauth_authorizer_plugin.h build_HEADERZ = $(krb5_HEADERS) # XXX diff --git a/kdc/NTMakefile b/kdc/NTMakefile index 696495cb8..e4f08add7 100644 --- a/kdc/NTMakefile +++ b/kdc/NTMakefile @@ -103,6 +103,7 @@ LIBKDC_OBJS=\ $(OBJ)\misc.obj \ $(OBJ)\kx509.obj \ $(OBJ)\token_validator.obj \ + $(OBJ)\jwt_validator.obj \ $(OBJ)\csr_authorizer.obj \ $(OBJ)\process.obj \ $(OBJ)\kdc-plugin.obj \ @@ -142,6 +143,7 @@ libkdc_la_SOURCES = \ misc.c \ kx509.c \ token_validator.c \ + jwt_validator.c \ csr_authorizer.c \ process.c \ kdc-plugin.c \ diff --git a/kdc/bx509d.c b/kdc/bx509d.c index d9a3c1164..89c86b797 100644 --- a/kdc/bx509d.c +++ b/kdc/bx509d.c @@ -128,7 +128,6 @@ #include #include "kdc_locl.h" -#include "token_validator_plugin.h" #include #include #include diff --git a/kdc/cjwt_token_validator.c b/kdc/cjwt_token_validator.c deleted file mode 100644 index 93742e5dd..000000000 --- a/kdc/cjwt_token_validator.c +++ /dev/null @@ -1,343 +0,0 @@ -/* - * Copyright (c) 2019 Kungliga Tekniska Högskolan - * (Royal Institute of Technology, Stockholm, Sweden). - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions - * are met: - * - * 1. Redistributions of source code must retain the above copyright - * notice, this list of conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright - * notice, this list of conditions and the following disclaimer in the - * documentation and/or other materials provided with the distribution. - * - * 3. Neither the name of the Institute nor the names of its contributors - * may be used to endorse or promote products derived from this software - * without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE INSTITUTE AND CONTRIBUTORS ``AS IS'' AND - * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE - * ARE DISCLAIMED. IN NO EVENT SHALL THE INSTITUTE OR CONTRIBUTORS BE LIABLE - * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL - * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS - * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) - * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT - * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY - * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF - * SUCH DAMAGE. - */ - -/* - * This is a plugin by which bx509d can validate JWT Bearer tokens using the - * cjwt library. - * - * Configuration: - * - * [kdc] - * realm = { - * A.REALM.NAME = { - * cjwt_jqk = PATH-TO-JWK-PEM-FILE - * } - * } - * - * where AUDIENCE-FOR-KDC is the value of the "audience" (i.e., the target) of - * the token. - */ - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#ifdef HAVE_CJSON -#include -#endif - -static const char * -get_kv(krb5_context context, const char *realm, const char *k, const char *k2) -{ - return krb5_config_get_string(context, NULL, "bx509", "realms", realm, - k, k2, NULL); -} - -static krb5_error_code -get_issuer_pubkeys(krb5_context context, - const char *realm, - krb5_data *previous, - krb5_data *current, - krb5_data *next) -{ - krb5_error_code save_ret = 0; - krb5_error_code ret; - const char *v; - size_t nkeys = 0; - - previous->data = current->data = next->data = 0; - previous->length = current->length = next->length = 0; - - if ((v = get_kv(context, realm, "cjwt_jwk_next", NULL)) && - (++nkeys) && - (ret = rk_undumpdata(v, &next->data, &next->length))) - save_ret = ret; - if ((v = get_kv(context, realm, "cjwt_jwk_previous", NULL)) && - (++nkeys) && - (ret = rk_undumpdata(v, &previous->data, &previous->length)) && - save_ret == 0) - save_ret = ret; - if ((v = get_kv(context, realm, "cjwt_jwk_current", NULL)) && - (++nkeys) && - (ret = rk_undumpdata(v, ¤t->data, ¤t->length)) && - save_ret == 0) - save_ret = ret; - if (nkeys == 0) - krb5_set_error_message(context, EINVAL, "jwk issuer key not specified in " - "[bx509]->realm->%s->cjwt_jwk_{previous,current,next}", - realm); - if (!previous->length && !current->length && !next->length) - krb5_set_error_message(context, save_ret, - "Could not read jwk issuer public key files"); - if (current->length && current->length == next->length && - memcmp(current->data, next->data, next->length) == 0) { - free(next->data); - next->data = 0; - next->length = 0; - } - if (current->length && current->length == previous->length && - memcmp(current->data, previous->data, previous->length) == 0) { - free(previous->data); - previous->data = 0; - previous->length = 0; - } - - if (previous->data == NULL && current->data == NULL && next->data == NULL) - return krb5_set_error_message(context, ENOENT, "No JWKs found"), - ENOENT; - return 0; -} - -static krb5_error_code -check_audience(krb5_context context, - const char *realm, - cjwt_t *jwt, - const char * const *audiences, - size_t naudiences) -{ - size_t i, k; - - if (!jwt->aud) { - krb5_set_error_message(context, EACCES, "JWT bearer token has no " - "audience"); - return EACCES; - } - for (i = 0; i < jwt->aud->count; i++) - for (k = 0; k < naudiences; k++) - if (strcasecmp(audiences[k], jwt->aud->names[i]) == 0) - return 0; - krb5_set_error_message(context, EACCES, "JWT bearer token's audience " - "does not match any expected audience"); - return EACCES; -} - -static krb5_error_code -get_princ(krb5_context context, - const char *realm, - cjwt_t *jwt, - krb5_principal *actual_principal) -{ - krb5_error_code ret; - const char *force_realm = NULL; - const char *domain; - -#ifdef HAVE_CJSON - if (jwt->private_claims) { - cJSON *jval; - - if ((jval = cJSON_GetObjectItem(jwt->private_claims, "authz_sub"))) - return krb5_parse_name(context, jval->valuestring, actual_principal); - } -#endif - - if (jwt->sub == NULL) { - krb5_set_error_message(context, EACCES, "JWT token lacks 'sub' " - "(subject name)!"); - return EACCES; - } - if ((domain = strchr(jwt->sub, '@'))) { - force_realm = get_kv(context, realm, "cjwt_force_realm", ++domain); - ret = krb5_parse_name(context, jwt->sub, actual_principal); - } else { - ret = krb5_parse_name_flags(context, jwt->sub, - KRB5_PRINCIPAL_PARSE_NO_REALM, - actual_principal); - } - if (ret) - krb5_set_error_message(context, ret, "JWT token 'sub' not a valid " - "principal name: %s", jwt->sub); - else if (force_realm) - ret = krb5_principal_set_realm(context, *actual_principal, realm); - else if (domain == NULL) - ret = krb5_principal_set_realm(context, *actual_principal, realm); - /* else leave the domain as the realm */ - return ret; -} - -static KRB5_LIB_CALL krb5_error_code -validate(void *ctx, - krb5_context context, - const char *realm, - const char *token_type, - krb5_data *token, - const char * const *audiences, - size_t naudiences, - krb5_boolean *result, - krb5_principal *actual_principal, - krb5_times *token_times) -{ - heim_octet_string jwk_previous; - heim_octet_string jwk_current; - heim_octet_string jwk_next; - cjwt_t *jwt = NULL; - char *tokstr = NULL; - char *defrealm = NULL; - int ret; - - if (strcmp(token_type, "Bearer") != 0) - return KRB5_PLUGIN_NO_HANDLE; /* Not us */ - - if ((tokstr = calloc(1, token->length + 1)) == NULL) - return ENOMEM; - memcpy(tokstr, token->data, token->length); - - if (realm == NULL) { - ret = krb5_get_default_realm(context, &defrealm); - if (ret) { - krb5_set_error_message(context, ret, "could not determine default " - "realm"); - free(tokstr); - return ret; - } - realm = defrealm; - } - - ret = get_issuer_pubkeys(context, realm, &jwk_previous, &jwk_current, - &jwk_next); - if (ret) { - free(defrealm); - free(tokstr); - return ret; - } - - if (jwk_current.length && jwk_current.data) - ret = cjwt_decode(tokstr, 0, &jwt, jwk_current.data, - jwk_current.length); - if (ret && jwk_next.length && jwk_next.data) - ret = cjwt_decode(tokstr, 0, &jwt, jwk_next.data, - jwk_next.length); - if (ret && jwk_previous.length && jwk_previous.data) - ret = cjwt_decode(tokstr, 0, &jwt, jwk_previous.data, - jwk_previous.length); - free(jwk_previous.data); - free(jwk_current.data); - free(jwk_next.data); - jwk_previous.data = jwk_current.data = jwk_next.data = NULL; - free(tokstr); - tokstr = NULL; - switch (ret) { - case 0: - if (jwt == NULL) { - krb5_set_error_message(context, EINVAL, "JWT validation failed"); - free(defrealm); - return EPERM; - } - if (jwt->header.alg == alg_none) { - krb5_set_error_message(context, EINVAL, "JWT signature algorithm " - "not supported"); - free(defrealm); - return EPERM; - } - break; - case -1: - krb5_set_error_message(context, EINVAL, "invalid JWT format"); - free(defrealm); - return EINVAL; - case -2: - krb5_set_error_message(context, EINVAL, "JWT signature validation " - "failed (wrong issuer?)"); - free(defrealm); - return EPERM; - default: - krb5_set_error_message(context, ret, "misc token validation error"); - free(defrealm); - return ret; - } - - /* Success; check audience */ - if ((ret = check_audience(context, realm, jwt, audiences, naudiences))) { - cjwt_destroy(&jwt); - free(defrealm); - return EACCES; - } - - /* Success; extract principal name */ - if ((ret = get_princ(context, realm, jwt, actual_principal)) == 0) { - token_times->authtime = jwt->iat.tv_sec; - token_times->starttime = jwt->nbf.tv_sec; - token_times->endtime = jwt->exp.tv_sec; - token_times->renew_till = jwt->exp.tv_sec; - *result = TRUE; - } - - cjwt_destroy(&jwt); - free(defrealm); - return ret; -} - -static KRB5_LIB_CALL krb5_error_code -hcjwt_init(krb5_context context, void **c) -{ - *c = NULL; - return 0; -} - -static KRB5_LIB_CALL void -hcjwt_fini(void *c) -{ -} - -static krb5plugin_token_validator_ftable plug_desc = - { 1, hcjwt_init, hcjwt_fini, validate }; - -static krb5plugin_token_validator_ftable *plugs[] = { &plug_desc }; - -static uintptr_t -hcjwt_get_instance(const char *libname) -{ - if (strcmp(libname, "krb5") == 0) - return krb5_get_instance(libname); - return 0; -} - -krb5_plugin_load_ft kdc_token_validator_plugin_load; - -krb5_error_code KRB5_CALLCONV -kdc_token_validator_plugin_load(heim_pcontext context, - krb5_get_instance_func_t *get_instance, - size_t *num_plugins, - krb5_plugin_common_ftable_cp **plugins) -{ - *get_instance = hcjwt_get_instance; - *num_plugins = sizeof(plugs) / sizeof(plugs[0]); - *plugins = (krb5_plugin_common_ftable_cp *)plugs; - return 0; -} diff --git a/kdc/httpkadmind.c b/kdc/httpkadmind.c index feb8f2fb3..eb65b8dfe 100644 --- a/kdc/httpkadmind.c +++ b/kdc/httpkadmind.c @@ -63,7 +63,6 @@ #include #include "kdc_locl.h" -#include "token_validator_plugin.h" #include #include #include diff --git a/kdc/jwt_validator.c b/kdc/jwt_validator.c new file mode 100644 index 000000000..fa844f2de --- /dev/null +++ b/kdc/jwt_validator.c @@ -0,0 +1,430 @@ +/* + * Copyright (c) 2019-2025 Kungliga Tekniska Högskolan + * (Royal Institute of Technology, Stockholm, Sweden). + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of the Institute nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE INSTITUTE AND CONTRIBUTORS ``AS IS'' AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE INSTITUTE OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + * SUCH DAMAGE. + */ + +/* + * JWT Bearer token validator for bx509d/httpkadmind. + * + * Uses hx509 JOSE library for signature verification. + * + * Configuration: + * + * [bx509] + * realms = { + * A.REALM.NAME = { + * # At least one of these must be set + * jwk_current = PATH-TO-JWK-PEM-FILE + * jwk_previous = PATH-TO-JWK-PEM-FILE + * jwk_next = PATH-TO-JWK-PEM-FILE + * } + * } + */ + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "jwt_validator.h" + +/* + * Get a string value from a heim_dict_t, returning NULL if not present + * or not a string. + */ +static const char * +heim_dict_get_string(heim_dict_t dict, const char *key) +{ + heim_string_t hkey = heim_string_create(key); + heim_object_t val; + const char *result = NULL; + + if (hkey == NULL) + return NULL; + val = heim_dict_get_value(dict, hkey); + heim_release(hkey); + if (val && heim_get_tid(val) == HEIM_TID_STRING) + result = heim_string_get_utf8((heim_string_t)val); + return result; +} + +/* + * Get a number value from a heim_dict_t, returning def if not present + * or not a number. + */ +static int64_t +heim_dict_get_int(heim_dict_t dict, const char *key, int64_t def) +{ + heim_string_t hkey = heim_string_create(key); + heim_object_t val; + int64_t result = def; + + if (hkey == NULL) + return def; + val = heim_dict_get_value(dict, hkey); + heim_release(hkey); + if (val && heim_get_tid(val) == HEIM_TID_NUMBER) + result = heim_number_get_long((heim_number_t)val); + return result; +} + +/* + * Get an object value from a heim_dict_t. + */ +static heim_object_t +heim_dict_get_obj(heim_dict_t dict, const char *key) +{ + heim_string_t hkey = heim_string_create(key); + heim_object_t val; + + if (hkey == NULL) + return NULL; + val = heim_dict_get_value(dict, hkey); + heim_release(hkey); + return val; +} + +/* JWT claims structure for KDC use */ + +typedef struct jwt_claims { + char *sub; /* Subject */ + char *iss; /* Issuer */ + char **aud; /* Audience (array) */ + size_t aud_count; + int64_t exp; /* Expiration time */ + int64_t nbf; /* Not before */ + int64_t iat; /* Issued at */ + char *authz_sub; /* Private claim: authz_sub */ +} jwt_claims; + +static void +jwt_claims_free(jwt_claims *claims) +{ + size_t i; + + if (claims == NULL) + return; + free(claims->sub); + free(claims->iss); + for (i = 0; i < claims->aud_count; i++) + free(claims->aud[i]); + free(claims->aud); + free(claims->authz_sub); +} + +static int +parse_jwt_claims(const char *payload_json, size_t payload_len, jwt_claims *claims) +{ + heim_object_t root = NULL; + heim_object_t aud; + const char *s; + size_t i, k; + + memset(claims, 0, sizeof(*claims)); + + root = heim_json_create_with_bytes(payload_json, payload_len, 10, 0, NULL); + if (root == NULL || heim_get_tid(root) != HEIM_TID_DICT) + goto fail; + + /* Required claims */ + if ((s = heim_dict_get_string((heim_dict_t)root, "sub")) != NULL && + (claims->sub = strdup(s)) == NULL) + goto fail; + if ((s = heim_dict_get_string((heim_dict_t)root, "iss")) != NULL && + (claims->iss = strdup(s)) == NULL) + goto fail; + + claims->exp = heim_dict_get_int((heim_dict_t)root, "exp", 0); + claims->nbf = heim_dict_get_int((heim_dict_t)root, "nbf", 0); + claims->iat = heim_dict_get_int((heim_dict_t)root, "iat", 0); + + /* Audience can be string or array of strings */ + aud = heim_dict_get_obj((heim_dict_t)root, "aud"); + if (aud) { + if (heim_get_tid(aud) == HEIM_TID_STRING) { + claims->aud = malloc(sizeof(char *)); + if (claims->aud) { + claims->aud[0] = strdup(heim_string_get_utf8((heim_string_t)aud)); + if (claims->aud[0] == NULL) + goto fail; + claims->aud_count = 1; + } + } else if (heim_get_tid(aud) == HEIM_TID_ARRAY) { + size_t count = heim_array_get_length((heim_array_t)aud); + claims->aud = calloc(count, sizeof(char *)); + if (claims->aud) { + for (k = i = 0; k < count; k++) { + heim_object_t item = heim_array_get_value((heim_array_t)aud, i); + if (item && heim_get_tid(item) == HEIM_TID_STRING) { + claims->aud[i] = strdup(heim_string_get_utf8((heim_string_t)item)); + if (claims->aud[i] == NULL) + goto fail; + i++; + } + } + claims->aud_count = i; + } + } + } + + /* Private claims */ + if ((s = heim_dict_get_string((heim_dict_t)root, "authz_sub")) != NULL && + (claims->authz_sub = strdup(s)) == NULL) + goto fail; + + heim_release(root); + return 0; + +fail: + heim_release(root); + jwt_claims_free(claims); + return -1; +} + +/* + * Read a PEM file into memory. + * Returns allocated buffer (caller must free) or NULL on error. + */ +static char * +read_pem_file(const char *path, size_t *len_out) +{ + FILE *fp; + long len; + char *data; + + fp = fopen(path, "r"); + if (fp == NULL) + return NULL; + + if (fseek(fp, 0, SEEK_END) < 0) { + fclose(fp); + return NULL; + } + len = ftell(fp); + if (len < 0 || len > 1024 * 1024) { /* Max 1MB */ + fclose(fp); + return NULL; + } + rewind(fp); + + data = malloc(len + 1); + if (data == NULL) { + fclose(fp); + return NULL; + } + + if (fread(data, 1, len, fp) != (size_t)len) { + fclose(fp); + free(data); + return NULL; + } + data[len] = '\0'; + fclose(fp); + + if (len_out) + *len_out = len; + return data; +} + +/* + * Validate a JWT Bearer token. + * + * Uses hx509_jws_verify() for signature verification, then performs + * KDC-specific claims validation. + * + * Returns 0 on success, error code on failure. + */ +krb5_error_code +validate_jwt_token(krb5_context context, + const char *token, + size_t token_len, + const char * const *jwk_paths, + size_t njwk_paths, + const char * const *audiences, + size_t naudiences, + krb5_boolean *result, + krb5_principal *actual_principal, + krb5_times *token_times, + const char *realm) +{ + hx509_context hx509ctx = NULL; + char *tokstr = NULL; + char **pem_keys = NULL; + void *payload = NULL; + size_t payload_len = 0; + jwt_claims claims; + time_t now; + size_t i, j, k; + int found_aud = 0; + krb5_error_code ret = 0; + int hx_ret; + + memset(&claims, 0, sizeof(claims)); + *result = FALSE; + + if (njwk_paths == 0) { + krb5_set_error_message(context, EINVAL, "No JWK paths provided"); + return EINVAL; + } + + /* Initialize hx509 context */ + hx_ret = hx509_context_init(&hx509ctx); + if (hx_ret) { + krb5_set_error_message(context, ENOMEM, "Could not initialize hx509 context"); + return ENOMEM; + } + + /* Make a null-terminated copy of the token */ + tokstr = calloc(1, token_len + 1); + if (tokstr == NULL) { + ret = krb5_enomem(context); + goto out; + } + memcpy(tokstr, token, token_len); + + /* Read PEM key files into memory */ + pem_keys = calloc(njwk_paths, sizeof(char *)); + if (pem_keys == NULL) { + ret = krb5_enomem(context); + goto out; + } + for (k = 0; k < njwk_paths; k++) { + if (jwk_paths[k] == NULL) + continue; + pem_keys[k] = read_pem_file(jwk_paths[k], NULL); + /* It's OK if some keys fail to load - we'll try others */ + } + + /* + * Verify signature using hx509. + * This handles all the crypto: algorithm detection, key type matching, + * ECDSA signature format conversion, EdDSA, etc. + */ + hx_ret = hx509_jws_verify(hx509ctx, tokstr, + (const char * const *)pem_keys, njwk_paths, + &payload, &payload_len); + if (hx_ret) { + ret = EPERM; + krb5_set_error_message(context, ret, + "JWT signature verification failed: %s", + hx509_get_error_string(hx509ctx, hx_ret)); + goto out; + } + + /* Parse claims */ + if (parse_jwt_claims(payload, payload_len, &claims) != 0) { + ret = EINVAL; + krb5_set_error_message(context, ret, "Invalid JWT: could not parse claims"); + goto out; + } + + /* Validate exp/nbf */ + now = time(NULL); + if (claims.exp && now >= claims.exp) { + ret = EACCES; + krb5_set_error_message(context, ret, "JWT token has expired"); + goto out; + } + if (claims.nbf && now < claims.nbf) { + ret = EACCES; + krb5_set_error_message(context, ret, "JWT token not yet valid"); + goto out; + } + + /* Validate audience (support multiple allowed audiences) */ + if (naudiences > 0) { + for (i = 0; i < claims.aud_count && !found_aud; i++) { + for (j = 0; j < naudiences; j++) { + if (strcmp(claims.aud[i], audiences[j]) == 0) { + found_aud = 1; + break; + } + } + } + if (!found_aud) { + ret = EACCES; + krb5_set_error_message(context, ret, "JWT audience does not match"); + goto out; + } + } + + /* Extract principal */ + if (claims.authz_sub) { + ret = krb5_parse_name(context, claims.authz_sub, actual_principal); + } else if (claims.sub) { + const char *at = strchr(claims.sub, '@'); + if (at) { + ret = krb5_parse_name(context, claims.sub, actual_principal); + } else { + ret = krb5_parse_name_flags(context, claims.sub, + KRB5_PRINCIPAL_PARSE_NO_REALM, + actual_principal); + if (ret == 0 && realm) + ret = krb5_principal_set_realm(context, *actual_principal, realm); + } + } else { + ret = EACCES; + krb5_set_error_message(context, ret, "JWT has no subject"); + goto out; + } + + if (ret) { + krb5_prepend_error_message(context, ret, "Could not parse JWT subject: "); + goto out; + } + + /* Set times */ + token_times->authtime = claims.iat ? claims.iat : now; + token_times->starttime = claims.nbf ? claims.nbf : claims.iat; + token_times->endtime = claims.exp ? claims.exp : 0; + token_times->renew_till = claims.exp ? claims.exp : 0; + + *result = TRUE; + +out: + jwt_claims_free(&claims); + if (pem_keys) { + for (k = 0; k < njwk_paths; k++) + free(pem_keys[k]); + free(pem_keys); + } + free(tokstr); + free(payload); + hx509_context_free(&hx509ctx); + return ret; +} diff --git a/kdc/jwt_validator.h b/kdc/jwt_validator.h new file mode 100644 index 000000000..32af4da62 --- /dev/null +++ b/kdc/jwt_validator.h @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2019-2025 Kungliga Tekniska Högskolan + * (Royal Institute of Technology, Stockholm, Sweden). + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of the Institute nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE INSTITUTE AND CONTRIBUTORS ``AS IS'' AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE INSTITUTE OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + * SUCH DAMAGE. + */ + +#ifndef HEIMDAL_KDC_JWT_VALIDATOR_H +#define HEIMDAL_KDC_JWT_VALIDATOR_H 1 + +#include + +/* + * Validate a JWT Bearer token using OpenSSL 3.x APIs. + * + * Tries multiple public keys in order (for key rotation support). + * Signature verification succeeds if any of the provided keys validates. + * + * @param context Kerberos context + * @param token The JWT token string (base64url encoded header.payload.signature) + * @param token_len Length of the token + * @param jwk_paths Array of paths to PEM files containing public keys + * @param njwk_paths Number of paths (typically 1-3 for current/previous/next) + * @param audiences Array of expected audience strings (can be NULL) + * @param naudiences Number of audience strings + * @param result Output: TRUE if valid + * @param actual_principal Output: the principal from the token's subject + * @param token_times Output: times from the token (iat, nbf, exp) + * @param realm Default realm to use if subject has no realm + * + * @return 0 on success, krb5 error code on failure + */ +krb5_error_code +validate_jwt_token(krb5_context context, + const char *token, + size_t token_len, + const char * const *jwk_paths, + size_t njwk_paths, + const char * const *audiences, + size_t naudiences, + krb5_boolean *result, + krb5_principal *actual_principal, + krb5_times *token_times, + const char *realm); + +#endif /* HEIMDAL_KDC_JWT_VALIDATOR_H */ diff --git a/kdc/negotiate_token_validator.c b/kdc/negotiate_token_validator.c deleted file mode 100644 index 20250c6dc..000000000 --- a/kdc/negotiate_token_validator.c +++ /dev/null @@ -1,323 +0,0 @@ -/* - * Copyright (c) 2019 Kungliga Tekniska Högskolan - * (Royal Institute of Technology, Stockholm, Sweden). - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions - * are met: - * - * 1. Redistributions of source code must retain the above copyright - * notice, this list of conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright - * notice, this list of conditions and the following disclaimer in the - * documentation and/or other materials provided with the distribution. - * - * 3. Neither the name of the Institute nor the names of its contributors - * may be used to endorse or promote products derived from this software - * without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE INSTITUTE AND CONTRIBUTORS ``AS IS'' AND - * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE - * ARE DISCLAIMED. IN NO EVENT SHALL THE INSTITUTE OR CONTRIBUTORS BE LIABLE - * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL - * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS - * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) - * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT - * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY - * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF - * SUCH DAMAGE. - */ - -/* - * This is a plugin by which bx509d can validate Negotiate tokens. - * - * [kdc] - * negotiate_token_validator = { - * keytab = ... - * } - */ - -#define _DEFAULT_SOURCE -#define _BSD_SOURCE -#define _GNU_SOURCE 1 - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -static int -display_status(krb5_context context, - OM_uint32 major, - OM_uint32 minor, - gss_cred_id_t acred, - gss_ctx_id_t gctx, - gss_OID mech_type) -{ - gss_buffer_desc buf = GSS_C_EMPTY_BUFFER; - OM_uint32 dmaj, dmin; - OM_uint32 more = 0; - char *gmmsg = NULL; - char *gmsg = NULL; - char *s = NULL; - - do { - gss_release_buffer(&dmin, &buf); - dmaj = gss_display_status(&dmin, major, GSS_C_GSS_CODE, GSS_C_NO_OID, - &more, &buf); - if (GSS_ERROR(dmaj) || - buf.length >= INT_MAX || - asprintf(&s, "%s%s%.*s", gmsg ? gmsg : "", gmsg ? ": " : "", - (int)buf.length, (char *)buf.value) == -1 || - s == NULL) { - free(gmsg); - gmsg = NULL; - break; - } - gmsg = s; - s = NULL; - } while (!GSS_ERROR(dmaj) && more); - if (mech_type != GSS_C_NO_OID) { - do { - gss_release_buffer(&dmin, &buf); - dmaj = gss_display_status(&dmin, major, GSS_C_MECH_CODE, mech_type, - &more, &buf); - if (GSS_ERROR(dmaj) || - asprintf(&s, "%s%s%.*s", gmmsg ? gmmsg : "", gmmsg ? ": " : "", - (int)buf.length, (char *)buf.value) == -1 || - s == NULL) { - free(gmmsg); - gmmsg = NULL; - break; - } - gmmsg = s; - s = NULL; - } while (!GSS_ERROR(dmaj) && more); - } - if (gmsg == NULL) - krb5_set_error_message(context, ENOMEM, "Error displaying GSS-API " - "status"); - else - krb5_set_error_message(context, EACCES, "%s%s%s%s", gmmsg, - gmmsg ? " (" : "", gmmsg ? gmmsg : "", - gmmsg ? ")" : ""); - if (acred && gctx) - krb5_prepend_error_message(context, EACCES, "Failed to validate " - "Negotiate token due to error examining " - "GSS-API security context"); - else if (acred) - krb5_prepend_error_message(context, EACCES, "Failed to validate " - "Negotiate token due to error accepting " - "GSS-API security context token"); - else - krb5_prepend_error_message(context, EACCES, "Failed to validate " - "Negotiate token due to error acquiring " - "GSS-API default acceptor credential"); - return EACCES; -} - -static KRB5_LIB_CALL krb5_error_code -validate(void *ctx, - krb5_context context, - const char *realm, - const char *token_type, - krb5_data *token, - const char * const *audiences, - size_t naudiences, - krb5_boolean *result, - krb5_principal *actual_principal, - krb5_times *token_times) -{ - gss_buffer_desc adisplay_name = GSS_C_EMPTY_BUFFER; - gss_buffer_desc idisplay_name = GSS_C_EMPTY_BUFFER; - gss_buffer_desc output_token = GSS_C_EMPTY_BUFFER; - gss_buffer_desc input_token; - gss_cred_id_t acred = GSS_C_NO_CREDENTIAL; - gss_ctx_id_t gctx = GSS_C_NO_CONTEXT; - gss_name_t aname = GSS_C_NO_NAME; - gss_name_t iname = GSS_C_NO_NAME; - gss_OID mech_type = GSS_C_NO_OID; - const char *kt = krb5_config_get_string(context, NULL, "kdc", - "negotiate_token_validator", - "keytab", NULL); - OM_uint32 major, minor, ret_flags, time_rec; - size_t i; - char *token_decoded = NULL; - void *token_copy = NULL; - char *princ_str = NULL; - int ret = 0; - - if (strcmp(token_type, "Negotiate") != 0) - return KRB5_PLUGIN_NO_HANDLE; - - if (kt) { - gss_key_value_element_desc store_keytab_kv; - gss_key_value_set_desc store; - gss_OID_desc mech_set[2] = { *GSS_KRB5_MECHANISM, *GSS_SPNEGO_MECHANISM }; - gss_OID_set_desc mechs = { 2, mech_set }; - - store_keytab_kv.key = "keytab"; - store_keytab_kv.value = kt; - store.elements = &store_keytab_kv; - store.count = 1; - major = gss_acquire_cred_from(&minor, GSS_C_NO_NAME, GSS_C_INDEFINITE, - &mechs, GSS_C_ACCEPT, &store, &acred, NULL, - NULL); - if (major != GSS_S_COMPLETE) - return display_status(context, major, minor, acred, gctx, mech_type); - - mechs.count = 1; - major = gss_set_neg_mechs(&minor, acred, &mechs); - if (major != GSS_S_COMPLETE) - return display_status(context, major, minor, acred, gctx, mech_type); - } /* else we'll use the default credential */ - - if ((token_decoded = malloc(token->length)) == NULL || - (token_copy = calloc(1, token->length + 1)) == NULL) - goto enomem; - - memcpy(token_copy, token->data, token->length); - if ((ret = rk_base64_decode(token_copy, token_decoded)) <= 0) { - krb5_set_error_message(context, EACCES, "Negotiate token malformed"); - ret = EACCES; - goto out; - } - - input_token.value = token_decoded; - input_token.length = ret; - major = gss_accept_sec_context(&minor, &gctx, acred, &input_token, NULL, - &iname, &mech_type, &output_token, - &ret_flags, &time_rec, NULL); - - if (mech_type == GSS_C_NO_OID || - !gss_oid_equal(mech_type, GSS_KRB5_MECHANISM)) { - krb5_set_error_message(context, ret = EACCES, "Negotiate token used " - "non-Kerberos mechanism"); - goto out; - } - - if (major != GSS_S_COMPLETE) { - ret = display_status(context, major, minor, acred, gctx, mech_type); - if (ret == 0) - ret = EINVAL; - goto out; - } - - major = gss_inquire_context(&minor, gctx, NULL, &aname, NULL, NULL, - NULL, NULL, NULL); - if (major == GSS_S_COMPLETE) - major = gss_display_name(&minor, aname, &adisplay_name, NULL); - if (major == GSS_S_COMPLETE) - major = gss_display_name(&minor, iname, &idisplay_name, NULL); - if (major != GSS_S_COMPLETE) { - ret = display_status(context, major, minor, acred, gctx, mech_type); - if (ret == 0) - ret = EINVAL; - goto out; - } - - for (i = 0; i < naudiences; i++) { - const char *s = adisplay_name.value; - size_t slen = adisplay_name.length; - size_t len = strlen(audiences[i]); - - if (slen >= sizeof("HTTP/") - 1 && - slen >= sizeof("HTTP/") - 1 + len && - memcmp(s, "HTTP/", sizeof("HTTP/") - 1) == 0 && - memcmp(s + sizeof("HTTP/") - 1, audiences[i], len) == 0 && - s[sizeof("HTTP/") - 1 + len] == '@') - break; - } - if (i == naudiences) { - /* This handles the case where naudiences == 0 as an error */ - krb5_set_error_message(context, EACCES, "Negotiate token used " - "wrong HTTP service host acceptor name"); - goto out; - } - - if ((princ_str = calloc(1, idisplay_name.length + 1)) == NULL) - goto enomem; - memcpy(princ_str, idisplay_name.value, idisplay_name.length); - if ((ret = krb5_parse_name(context, princ_str, actual_principal))) - goto out; - - /* XXX Need name attributes to get authtime/starttime/renew_till */ - token_times->authtime = 0; - token_times->starttime = time(NULL) - 300; - token_times->endtime = token_times->starttime + 300 + time_rec; - token_times->renew_till = 0; - - *result = TRUE; - goto out; - -enomem: - ret = krb5_enomem(context); -out: - gss_delete_sec_context(&minor, &gctx, NULL); - gss_release_buffer(&minor, &adisplay_name); - gss_release_buffer(&minor, &idisplay_name); - gss_release_buffer(&minor, &output_token); - gss_release_cred(&minor, &acred); - gss_release_name(&minor, &aname); - gss_release_name(&minor, &iname); - free(token_decoded); - free(token_copy); - free(princ_str); - return ret; -} - -static KRB5_LIB_CALL krb5_error_code -negotiate_init(krb5_context context, void **c) -{ - *c = NULL; - return 0; -} - -static KRB5_LIB_CALL void -negotiate_fini(void *c) -{ -} - -static krb5plugin_token_validator_ftable plug_desc = - { 1, negotiate_init, negotiate_fini, validate }; - -static krb5plugin_token_validator_ftable *plugs[] = { &plug_desc }; - -static uintptr_t -negotiate_get_instance(const char *libname) -{ - if (strcmp(libname, "krb5") == 0) - return krb5_get_instance(libname); - - return 0; -} - -krb5_plugin_load_ft kdc_token_validator_plugin_load; - -krb5_error_code KRB5_CALLCONV -kdc_token_validator_plugin_load(heim_pcontext context, - krb5_get_instance_func_t *get_instance, - size_t *num_plugins, - krb5_plugin_common_ftable_cp **plugins) -{ - *get_instance = negotiate_get_instance; - *num_plugins = sizeof(plugs) / sizeof(plugs[0]); - *plugins = (krb5_plugin_common_ftable_cp *)plugs; - return 0; -} diff --git a/kdc/test_jwt_validator.c b/kdc/test_jwt_validator.c new file mode 100644 index 000000000..cfa0e0cb1 --- /dev/null +++ b/kdc/test_jwt_validator.c @@ -0,0 +1,345 @@ +/* + * Copyright (c) 2025 Kungliga Tekniska Högskolan + * (Royal Institute of Technology, Stockholm, Sweden). + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * 3. Neither the name of the Institute nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE INSTITUTE AND CONTRIBUTORS ``AS IS'' AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE INSTITUTE OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + * SUCH DAMAGE. + */ + +/* + * Test program for JWT validator using RFC 7515 and RFC 8037 test vectors. + */ + +#include +#include +#include +#include +#include +#include + +#include +#include "jwt_validator.h" + +/* + * RFC 7515 Appendix A.2 - RS256 test vector + * + * The token has claims: {"iss":"joe", "exp":1300819380, "http://example.com/is_root":true} + * Note: exp is in the past (2011), so we test signature only, not expiration. + */ +static const char *rfc7515_rs256_token = + "eyJhbGciOiJSUzI1NiJ9" + "." + "eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ" + "." + "cC4hiUPoj9Eetdgtv3hF80EGrhuB__dzERat0XF9g2VtQgr9PJbu3XOiZj5RZmh7AAuHIm4Bh-0Qc_lF5YKt_O8W2Fp5jujGbds9uJdbF9CUAr7t1dnZcAcQjbKBYNX4BAynRFdiuB--f_nZLgrnbyTyWzO75vRK5h6xBArLIARNPvkSjtQBMHlb1L07Qe7K0GarZRmB_eSN9383LcOLn6_dO--xi12jzDwusC-eOkHWEsqtFZESc6BfI7noOPqvhJ1phCnvWh6IeYI2w9QOYEUipUTI8np6LbgGY9Fs98rqVt5AXLIhWkWywlVmtVrBp0igcN_IoypGlUPQGe77Rw"; + +/* RSA public key from RFC 7515 A.2 in PEM format */ +static const char *rfc7515_rs256_pubkey_pem = + "-----BEGIN PUBLIC KEY-----\n" + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAofgWCuLjybRlzo0tZWJj\n" + "NiuSfb4p4fAkd/wWJcyQoTbji9k0l8W26mPddxHmfHQp+Vaw+4qPCJrcS2mJPMEz\n" + "P1Pt0Bm4d4QlL+yRT+SFd2lZS+pCgNMsD1W/YpRPEwOWvG6b32690r2jZ47soMZo\n" + "9wGzjb/7OMg0LOL+bSf63kpaSHSXndS5z5rexMdbBYUsLA9e+KXBdQOS+UTo7WTB\n" + "EMa2R2CapHg665xsmtdVMTBQY4uDZlxvb3qCo5ZwKh9kG4LT6/I5IhlJH7aGhyxX\n" + "FvUK+DWNmoudF8NAco9/h9iaGNj8q2ethFkMLs91kzk2PAcDTW9gb54h4FRWyuXp\n" + "oQIDAQAB\n" + "-----END PUBLIC KEY-----\n"; + +/* + * RFC 7515 Appendix A.3 - ES256 test vector + * + * Same claims as RS256. + */ +static const char *rfc7515_es256_token = + "eyJhbGciOiJFUzI1NiJ9" + "." + "eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ" + "." + "DtEhU3ljbEg8L38VWAfUAqOyKAM6-Xx-F4GawxaepmXFCgfTjDxw5djxLa8ISlSApmWQxfKTUJqPP3-Kg6NU1Q"; + +/* EC P-256 public key from RFC 7515 A.3 in PEM format */ +static const char *rfc7515_es256_pubkey_pem = + "-----BEGIN PUBLIC KEY-----\n" + "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEf83OJ3D2xF1Bg8vub9tLe1gHMzV7\n" + "6e8Tus9uPHvRVEXH8UTNG72bfocs3+257rn0s2ldbqkLJK2KRiMohYjlrQ==\n" + "-----END PUBLIC KEY-----\n"; + +/* + * RFC 8037 Appendix A.4 - EdDSA (Ed25519) test vector + * + * Payload is "Example of Ed25519 signing" (not JSON claims) + */ +static const char *rfc8037_eddsa_token = + "eyJhbGciOiJFZERTQSJ9" + "." + "RXhhbXBsZSBvZiBFZDI1NTE5IHNpZ25pbmc" + "." + "hgyY0il_MGCjP0JzlnLWG1PPOt7-09PGcvMg3AIbQR6dWbhijcNR4ki4iylGjg5BhVsPt9g7sVvpAr_MuM0KAg"; + +/* Ed25519 public key from RFC 8037 A.4 in PEM format */ +static const char *rfc8037_ed25519_pubkey_pem = + "-----BEGIN PUBLIC KEY-----\n" + "MCowBQYDK2VwAyEA11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=\n" + "-----END PUBLIC KEY-----\n"; + +static int +write_temp_key(const char *pem, char *path, size_t pathlen) +{ + int fd; + ssize_t len = strlen(pem); + + snprintf(path, pathlen, "/tmp/jwt_test_key_XXXXXX"); + fd = mkstemp(path); + if (fd < 0) + return -1; + if (write(fd, pem, len) != len) { + close(fd); + unlink(path); + return -1; + } + close(fd); + return 0; +} + +/* + * Test signature verification only (not claims validation). + * We pass no audiences and ignore expiration since RFC test vectors + * have expired exp claims. + */ +static int +test_signature_only(krb5_context context, + const char *name, + const char *token, + const char *pubkey_pem) +{ + char keypath[256]; + const char *paths[1]; + krb5_boolean result = FALSE; + krb5_principal princ = NULL; + krb5_times times; + krb5_error_code ret; + + printf("Testing %s signature verification... ", name); + fflush(stdout); + + if (write_temp_key(pubkey_pem, keypath, sizeof(keypath)) < 0) { + printf("FAILED (could not write temp key)\n"); + return 1; + } + + paths[0] = keypath; + + /* + * Note: validate_jwt_token validates exp/nbf claims, but the RFC + * test vectors have expired tokens. We're primarily testing signature + * verification here, so we accept EACCES for expired tokens as long + * as we get past signature verification. + */ + ret = validate_jwt_token(context, + token, strlen(token), + paths, 1, + NULL, 0, /* no audience check */ + &result, &princ, ×, + "TEST.REALM"); + + unlink(keypath); + + /* + * For RFC test vectors, we expect either: + * - Success (result == TRUE) + * - EACCES with "expired" in the error message (signature was valid) + * - EACCES with "no subject" (signature was valid, but payload isn't JWT claims) + * - EINVAL with "could not parse claims" (signature was valid, but payload isn't JSON) + */ + if (ret == 0 && result) { + printf("OK\n"); + krb5_free_principal(context, princ); + return 0; + } + + if (ret == EACCES || ret == EINVAL) { + const char *msg = krb5_get_error_message(context, ret); + if (strstr(msg, "expired") || strstr(msg, "no subject") || + strstr(msg, "not valid JSON") || strstr(msg, "could not parse claims")) { + printf("OK (signature valid, %s)\n", + strstr(msg, "expired") ? "token expired" : "non-JWT payload"); + krb5_free_error_message(context, msg); + krb5_free_principal(context, princ); + return 0; + } + printf("FAILED: %s\n", msg); + krb5_free_error_message(context, msg); + } else if (ret) { + const char *msg = krb5_get_error_message(context, ret); + printf("FAILED: %s\n", msg); + krb5_free_error_message(context, msg); + } else { + printf("FAILED: result=%d\n", result); + } + + krb5_free_principal(context, princ); + return 1; +} + +/* + * Test with a tampered token (should fail signature verification). + */ +static int +test_tampered_token(krb5_context context) +{ + char keypath[256]; + const char *paths[1]; + krb5_boolean result = FALSE; + krb5_principal princ = NULL; + krb5_times times; + krb5_error_code ret; + char *tampered; + + printf("Testing tampered token rejection... "); + fflush(stdout); + + /* Copy and tamper with the token (change one character in payload) */ + tampered = strdup(rfc7515_rs256_token); + if (!tampered) { + printf("FAILED (out of memory)\n"); + return 1; + } + /* Find the payload and change a character */ + tampered[50] = (tampered[50] == 'a') ? 'b' : 'a'; + + if (write_temp_key(rfc7515_rs256_pubkey_pem, keypath, sizeof(keypath)) < 0) { + printf("FAILED (could not write temp key)\n"); + free(tampered); + return 1; + } + + paths[0] = keypath; + + ret = validate_jwt_token(context, + tampered, strlen(tampered), + paths, 1, + NULL, 0, + &result, &princ, ×, + "TEST.REALM"); + + unlink(keypath); + free(tampered); + + if (ret == EPERM && !result) { + printf("OK (correctly rejected)\n"); + return 0; + } + + printf("FAILED: tampered token was accepted!\n"); + krb5_free_principal(context, princ); + return 1; +} + +/* + * Test wrong key rejection. + */ +static int +test_wrong_key(krb5_context context) +{ + char keypath[256]; + const char *paths[1]; + krb5_boolean result = FALSE; + krb5_principal princ = NULL; + krb5_times times; + krb5_error_code ret; + + printf("Testing wrong key rejection... "); + fflush(stdout); + + /* Use ES256 key to verify RS256 token - should fail */ + if (write_temp_key(rfc7515_es256_pubkey_pem, keypath, sizeof(keypath)) < 0) { + printf("FAILED (could not write temp key)\n"); + return 1; + } + + paths[0] = keypath; + + ret = validate_jwt_token(context, + rfc7515_rs256_token, strlen(rfc7515_rs256_token), + paths, 1, + NULL, 0, + &result, &princ, ×, + "TEST.REALM"); + + unlink(keypath); + + if (ret == EPERM && !result) { + printf("OK (correctly rejected)\n"); + return 0; + } + + printf("FAILED: wrong key type was accepted!\n"); + krb5_free_principal(context, princ); + return 1; +} + +int +main(int argc, char **argv) +{ + krb5_context context; + krb5_error_code ret; + int failures = 0; + + ret = krb5_init_context(&context); + if (ret) { + fprintf(stderr, "krb5_init_context failed: %d\n", ret); + return 1; + } + + printf("JWT Validator Test Suite\n"); + printf("========================\n\n"); + + printf("RFC 7515 Test Vectors:\n"); + failures += test_signature_only(context, "RS256", rfc7515_rs256_token, + rfc7515_rs256_pubkey_pem); + failures += test_signature_only(context, "ES256", rfc7515_es256_token, + rfc7515_es256_pubkey_pem); + + printf("\nRFC 8037 Test Vectors:\n"); + failures += test_signature_only(context, "EdDSA (Ed25519)", rfc8037_eddsa_token, + rfc8037_ed25519_pubkey_pem); + + printf("\nNegative Tests:\n"); + failures += test_tampered_token(context); + failures += test_wrong_key(context); + + printf("\n"); + if (failures == 0) { + printf("All tests passed!\n"); + } else { + printf("%d test(s) failed!\n", failures); + } + + krb5_free_context(context); + return failures ? 1 : 0; +} diff --git a/kdc/token_validator.c b/kdc/token_validator.c index 858fdfa7b..84227dcc6 100644 --- a/kdc/token_validator.c +++ b/kdc/token_validator.c @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019 Kungliga Tekniska Högskolan + * Copyright (c) 2019-2025 Kungliga Tekniska Högskolan * (Royal Institute of Technology, Stockholm, Sweden). * All rights reserved. * @@ -31,52 +31,354 @@ * SUCH DAMAGE. */ +/* + * Token validators for Bearer (JWT) and Negotiate (GSSAPI/Kerberos) tokens. + * + * This replaces the plugin-based token validation with inline validators + * using OpenSSL for JWT and Heimdal's GSS-API for Negotiate tokens. + */ + #include "kdc_locl.h" -#include "token_validator_plugin.h" +#include +#include +#include "jwt_validator.h" -struct plctx { - const char *realm; - const char *token_kind; - krb5_data *token; - const char * const *audiences; - size_t naudiences; - krb5_boolean result; - krb5_principal actual_principal; - krb5_times token_times; -}; - -static krb5_error_code KRB5_LIB_CALL -plcallback(krb5_context context, const void *plug, void *plugctx, void *userctx) +/* + * Get configuration value from [bx509] realms section. + */ +static const char * +get_kv(krb5_context context, const char *realm, const char *k, const char *k2) { - const krb5plugin_token_validator_ftable *validator = plug; - krb5_error_code ret; - struct plctx *plctx = userctx; + return krb5_config_get_string(context, NULL, "bx509", "realms", realm, + k, k2, NULL); +} - ret = validator->validate(plugctx, context, plctx->realm, - plctx->token_kind, plctx->token, - plctx->audiences, plctx->naudiences, - &plctx->result, &plctx->actual_principal, - &plctx->token_times); - if (ret) { - krb5_free_principal(context, plctx->actual_principal); - plctx->actual_principal = NULL; +/* + * Collect JWK paths from configuration for key rotation support. + * Returns the number of paths found (0-3). + */ +static size_t +get_jwk_paths(krb5_context context, + const char *realm, + const char *paths[3]) +{ + size_t n = 0; + + paths[0] = paths[1] = paths[2] = NULL; + + /* + * We used to use libcjwt. No more. We'll keep the old config names for + * now, but not document them because though we never shipped, these have + * been in production. + */ + + /* Current key is tried first */ + if ((paths[n] = get_kv(context, realm, "jwk_current", NULL)) != NULL || + (paths[n] = get_kv(context, realm, "cjwt_jwk_current", NULL)) != NULL) + n++; + /* Then next key (for key rotation) */ + if ((paths[n] = get_kv(context, realm, "jwk_next", NULL)) != NULL || + (paths[n] = get_kv(context, realm, "cjwt_jwk_next", NULL)) != NULL) + n++; + /* Then previous key (for key rotation) */ + if ((paths[n] = get_kv(context, realm, "jwk_previous", NULL)) != NULL || + (paths[n] = get_kv(context, realm, "cjwt_jwk_previous", NULL)) != NULL) + n++; + + return n; +} + +/* + * Validate a JWT Bearer token. + */ +static krb5_error_code +validate_bearer(krb5_context context, + const char *realm, + krb5_data *token, + const char * const *audiences, + size_t naudiences, + krb5_boolean *result, + krb5_principal *actual_principal, + krb5_times *token_times) +{ + const char *jwk_paths[3]; + size_t njwk_paths; + char *defrealm = NULL; + krb5_error_code ret; + + *result = FALSE; + *actual_principal = NULL; + + if (realm == NULL) { + ret = krb5_get_default_realm(context, &defrealm); + if (ret) { + krb5_set_error_message(context, ret, + "Could not determine default realm for JWT validation"); + return ret; + } + realm = defrealm; } + + njwk_paths = get_jwk_paths(context, realm, jwk_paths); + if (njwk_paths == 0) { + free(defrealm); + krb5_set_error_message(context, ENOENT, + "No JWK configured for realm %s in " + "[bx509]->realms->%s->jwk_{current,next,previous}", + realm, realm); + return ENOENT; + } + + ret = validate_jwt_token(context, + token->data, token->length, + jwk_paths, njwk_paths, + audiences, naudiences, + result, actual_principal, + token_times, realm); + + free(defrealm); return ret; } -static const char *plugin_deps[] = { "krb5", NULL }; +/* + * Display GSS-API status for error reporting. + */ +static krb5_error_code +gss_error(krb5_context context, + OM_uint32 major, + OM_uint32 minor, + gss_OID mech_type, + const char *prefix) +{ + gss_buffer_desc buf = GSS_C_EMPTY_BUFFER; + OM_uint32 dmaj, dmin; + OM_uint32 more = 0; + char *msg = NULL; + char *s = NULL; -static struct heim_plugin_data token_validator_data = { - "kdc", - KDC_PLUGIN_BEARER, - 1, - plugin_deps, - krb5_get_instance -}; + do { + gss_release_buffer(&dmin, &buf); + dmaj = gss_display_status(&dmin, major, GSS_C_GSS_CODE, GSS_C_NO_OID, + &more, &buf); + if (GSS_ERROR(dmaj) || buf.length == 0) + break; + if (asprintf(&s, "%s%s%.*s", msg ? msg : "", msg ? ": " : "", + (int)buf.length, (char *)buf.value) == -1) { + free(msg); + msg = NULL; + break; + } + free(msg); + msg = s; + s = NULL; + } while (!GSS_ERROR(dmaj) && more); + + if (mech_type != GSS_C_NO_OID && minor != 0) { + more = 0; + do { + gss_release_buffer(&dmin, &buf); + dmaj = gss_display_status(&dmin, minor, GSS_C_MECH_CODE, mech_type, + &more, &buf); + if (GSS_ERROR(dmaj) || buf.length == 0) + break; + if (asprintf(&s, "%s%s%.*s", msg ? msg : "", msg ? " (" : "", + (int)buf.length, (char *)buf.value) == -1) { + break; + } + free(msg); + msg = s; + s = NULL; + if (more == 0 && msg) { + if (asprintf(&s, "%s)", msg) != -1) { + free(msg); + msg = s; + } + } + } while (!GSS_ERROR(dmaj) && more); + } + gss_release_buffer(&dmin, &buf); + + if (msg) + krb5_set_error_message(context, EACCES, "%s: %s", prefix, msg); + else + krb5_set_error_message(context, EACCES, "%s", prefix); + free(msg); + return EACCES; +} /* - * Invoke a plugin to validate a JWT/SAML/OIDC token and partially-evaluate - * access control. + * Validate a Negotiate (GSSAPI/Kerberos) token. + */ +static krb5_error_code +validate_negotiate(krb5_context context, + const char *realm, + krb5_data *token, + const char * const *audiences, + size_t naudiences, + krb5_boolean *result, + krb5_principal *actual_principal, + krb5_times *token_times) +{ + gss_buffer_desc adisplay_name = GSS_C_EMPTY_BUFFER; + gss_buffer_desc idisplay_name = GSS_C_EMPTY_BUFFER; + gss_buffer_desc output_token = GSS_C_EMPTY_BUFFER; + gss_buffer_desc input_token; + gss_cred_id_t acred = GSS_C_NO_CREDENTIAL; + gss_ctx_id_t gctx = GSS_C_NO_CONTEXT; + gss_name_t aname = GSS_C_NO_NAME; + gss_name_t iname = GSS_C_NO_NAME; + gss_OID mech_type = GSS_C_NO_OID; + const char *kt; + OM_uint32 major, minor, ret_flags, time_rec; + size_t i; + char *token_decoded = NULL; + void *token_copy = NULL; + char *princ_str = NULL; + krb5_error_code ret = 0; + int decoded_len; + + *result = FALSE; + *actual_principal = NULL; + + /* Get keytab from configuration */ + kt = krb5_config_get_string(context, NULL, "kdc", + "negotiate_token_validator", "keytab", NULL); + if (kt) { + gss_key_value_element_desc store_keytab_kv; + gss_key_value_set_desc store; + gss_OID_desc mech_set[2] = { *GSS_KRB5_MECHANISM, *GSS_SPNEGO_MECHANISM }; + gss_OID_set_desc mechs = { 2, mech_set }; + + store_keytab_kv.key = "keytab"; + store_keytab_kv.value = kt; + store.elements = &store_keytab_kv; + store.count = 1; + major = gss_acquire_cred_from(&minor, GSS_C_NO_NAME, GSS_C_INDEFINITE, + &mechs, GSS_C_ACCEPT, &store, &acred, + NULL, NULL); + if (major != GSS_S_COMPLETE) { + ret = gss_error(context, major, minor, GSS_C_NO_OID, + "Failed to acquire GSS-API acceptor credential"); + goto out; + } + + /* Restrict SPNEGO to Kerberos 5 only */ + mechs.count = 1; + major = gss_set_neg_mechs(&minor, acred, &mechs); + if (major != GSS_S_COMPLETE) { + ret = gss_error(context, major, minor, GSS_C_NO_OID, + "Failed to set SPNEGO negotiation mechanisms"); + goto out; + } + } /* else use default credential */ + + /* Base64 decode the token */ + token_decoded = malloc(token->length); + token_copy = calloc(1, token->length + 1); + if (token_decoded == NULL || token_copy == NULL) { + ret = krb5_enomem(context); + goto out; + } + + memcpy(token_copy, token->data, token->length); + decoded_len = rk_base64_decode(token_copy, token_decoded); + if (decoded_len <= 0) { + krb5_set_error_message(context, EACCES, "Negotiate token malformed"); + ret = EACCES; + goto out; + } + + /* Accept security context */ + input_token.value = token_decoded; + input_token.length = decoded_len; + major = gss_accept_sec_context(&minor, &gctx, acred, &input_token, NULL, + &iname, &mech_type, &output_token, + &ret_flags, &time_rec, NULL); + + /* Require Kerberos 5 mechanism */ + if (mech_type == GSS_C_NO_OID || + !gss_oid_equal(mech_type, GSS_KRB5_MECHANISM)) { + krb5_set_error_message(context, EACCES, + "Negotiate token used non-Kerberos mechanism"); + ret = EACCES; + goto out; + } + + if (major != GSS_S_COMPLETE) { + ret = gss_error(context, major, minor, mech_type, + "Failed to accept Negotiate token"); + goto out; + } + + /* Get acceptor and initiator names */ + major = gss_inquire_context(&minor, gctx, NULL, &aname, NULL, NULL, + NULL, NULL, NULL); + if (major == GSS_S_COMPLETE) + major = gss_display_name(&minor, aname, &adisplay_name, NULL); + if (major == GSS_S_COMPLETE) + major = gss_display_name(&minor, iname, &idisplay_name, NULL); + if (major != GSS_S_COMPLETE) { + ret = gss_error(context, major, minor, mech_type, + "Failed to get names from GSS-API context"); + goto out; + } + + /* Check audience (acceptor name must be HTTP/@REALM) */ + for (i = 0; i < naudiences; i++) { + const char *s = adisplay_name.value; + size_t slen = adisplay_name.length; + size_t len = strlen(audiences[i]); + + if (slen >= sizeof("HTTP/") - 1 && + slen >= sizeof("HTTP/") - 1 + len && + memcmp(s, "HTTP/", sizeof("HTTP/") - 1) == 0 && + memcmp(s + sizeof("HTTP/") - 1, audiences[i], len) == 0 && + s[sizeof("HTTP/") - 1 + len] == '@') + break; + } + if (i == naudiences) { + krb5_set_error_message(context, EACCES, + "Negotiate token used wrong HTTP service " + "host acceptor name"); + ret = EACCES; + goto out; + } + + /* Parse initiator principal */ + princ_str = calloc(1, idisplay_name.length + 1); + if (princ_str == NULL) { + ret = krb5_enomem(context); + goto out; + } + memcpy(princ_str, idisplay_name.value, idisplay_name.length); + ret = krb5_parse_name(context, princ_str, actual_principal); + if (ret) + goto out; + + /* Set times (approximate since we don't have exact values) */ + token_times->authtime = 0; + token_times->starttime = time(NULL) - 300; + token_times->endtime = token_times->starttime + 300 + time_rec; + token_times->renew_till = 0; + + *result = TRUE; + +out: + gss_delete_sec_context(&minor, &gctx, NULL); + gss_release_buffer(&minor, &adisplay_name); + gss_release_buffer(&minor, &idisplay_name); + gss_release_buffer(&minor, &output_token); + gss_release_cred(&minor, &acred); + gss_release_name(&minor, &aname); + gss_release_name(&minor, &iname); + free(token_decoded); + free(token_copy); + free(princ_str); + return ret; +} + +/* + * Validate a JWT/Bearer or Negotiate token. */ KDC_LIB_FUNCTION krb5_error_code KDC_LIB_CALL kdc_validate_token(krb5_context context, @@ -89,34 +391,45 @@ kdc_validate_token(krb5_context context, krb5_times *token_times) { krb5_error_code ret; - struct plctx ctx; + krb5_boolean result = FALSE; + krb5_times times; - memset(&ctx, 0, sizeof(ctx)); - ctx.realm = realm; - ctx.token_kind = token_kind; - ctx.token = token; - ctx.audiences = audiences; - ctx.naudiences = naudiences; - ctx.result = FALSE; - ctx.actual_principal = NULL; + memset(×, 0, sizeof(times)); + if (actual_principal) + *actual_principal = NULL; krb5_clear_error_message(context); - ret = _krb5_plugin_run_f(context, &token_validator_data, 0, &ctx, - plcallback); - if (ret == 0 && ctx.result && actual_principal) { - *actual_principal = ctx.actual_principal; - ctx.actual_principal = NULL; + + if (strcasecmp(token_kind, "Bearer") == 0) { + ret = validate_bearer(context, realm, token, audiences, naudiences, + &result, actual_principal, ×); + } else if (strcasecmp(token_kind, "Negotiate") == 0) { + ret = validate_negotiate(context, realm, token, audiences, naudiences, + &result, actual_principal, ×); + } else { + krb5_set_error_message(context, EINVAL, + "Unknown token type '%s' (expected Bearer or Negotiate)", + token_kind); + return EINVAL; } if (token_times) - *token_times = ctx.token_times; + *token_times = times; + + if (ret) { + krb5_prepend_error_message(context, ret, "token validation failed: "); + if (actual_principal) { + krb5_free_principal(context, *actual_principal); + *actual_principal = NULL; + } + } else if (!result) { + krb5_set_error_message(context, EACCES, "token validation failed"); + ret = EACCES; + if (actual_principal) { + krb5_free_principal(context, *actual_principal); + *actual_principal = NULL; + } + } - krb5_free_principal(context, ctx.actual_principal); - if (ret) - krb5_prepend_error_message(context, ret, "bearer token validation " - "failed: "); - else if (!ctx.result) - krb5_set_error_message(context, ret = EACCES, - "bearer token validation failed"); return ret; } diff --git a/tests/kdc/check-bx509.in b/tests/kdc/check-bx509.in index 55ddb1e43..3a4c7bc44 100644 --- a/tests/kdc/check-bx509.in +++ b/tests/kdc/check-bx509.in @@ -39,6 +39,8 @@ testfailed="echo test failed; cat messages.log; exit 1" . ${env_setup} +srcdir="${top_srcdir}/tests/kdc" + # If there is no useful db support compiled in, disable test ${have_db} || exit 77 @@ -113,6 +115,13 @@ rm -rf authz_dir mkdir -p authz_dir +# Generate JWT signing keys upfront (needed before config file is read) +echo "Generating JWT signing keys" +openssl genpkey -algorithm RSA -out ${objdir}/jwt_sign.pem -pkeyopt rsa_keygen_bits:2048 2>/dev/null || + { echo "Failed to generate JWT signing key"; exit 2; } +openssl pkey -in ${objdir}/jwt_sign.pem -pubout -out ${objdir}/jwt_sign_pub.pem 2>/dev/null || + { echo "Failed to extract JWT public key"; exit 2; } + > messages.log kdcpid= @@ -279,6 +288,43 @@ token=$(KRB5CCNAME=$cache $gsstoken HTTP@$server) $test_token_validator -a datan.test.h5l.se Negotiate "$token" || { echo "Negotiate token validator failed to validate valid token"; exit 2; } +echo "Testing JWT validator with RFC test vectors" +${objdir}/../../kdc/test_jwt_validator || + { echo "JWT validator RFC test vectors failed"; exit 2; } + +echo "Check Bearer/JWT token validator" +# JWT signing key was already generated at start of test + +# Generate a valid JWT using hxtool +jwt_token=$($hxtool jwt-sign -k ${objdir}/jwt_sign.pem -a RS256 \ + -s "foo@${R}" -i "test-issuer" -A "datan.test.h5l.se" -l 3600 | tr -d '\n') + +echo "Testing Bearer token validator with valid JWT" +$test_token_validator -a datan.test.h5l.se Bearer "$jwt_token" || + { echo "Bearer token validator failed to validate valid JWT"; exit 2; } + +echo "Testing Bearer token validator with wrong audience" +$test_token_validator -a wrong.audience.example Bearer "$jwt_token" && + { echo "Bearer token validator accepted JWT with wrong audience"; exit 2; } + +echo "Testing Bearer token validator with tampered JWT" +tampered_jwt=$(echo "$jwt_token" | sed 's/eyJ/eXJ/') +$test_token_validator -a datan.test.h5l.se Bearer "$tampered_jwt" && + { echo "Bearer token validator accepted tampered JWT"; exit 2; } + +echo "Testing Bearer token validator with expired JWT" +# Use negative lifetime to create an already-expired token +expired_jwt=$($hxtool jwt-sign -k ${objdir}/jwt_sign.pem -a RS256 \ + -s "foo@${R}" -i "test-issuer" -A "datan.test.h5l.se" -l -3600 | tr -d '\n') +$test_token_validator -a datan.test.h5l.se Bearer "$expired_jwt" && + { echo "Bearer token validator accepted expired JWT"; exit 2; } + +# Note: ES256 and EdDSA signature verification is tested by the RFC test vectors +# in test_jwt_validator. The Bearer token tests use only RS256 because the +# configuration only supports a single key file. + +echo "JWT/Bearer token validator tests passed" + echo "Starting CSR authorizer IPC service" $test_csr_authorizer --server --daemon || diff --git a/tests/kdc/check-httpkadmind.in b/tests/kdc/check-httpkadmind.in index 776decbae..5a1a0ba29 100644 --- a/tests/kdc/check-httpkadmind.in +++ b/tests/kdc/check-httpkadmind.in @@ -79,8 +79,8 @@ kadminr="${kadmin} -r $R -a $(uname -n)" kadmin="${kadmin} -l -A -r $R" kadmind2="${kadmind} --keytab=${keytab} --detach -p $admport2 --read-only" kadmind="${kadmind} -A --keytab=${keytab} --detach -p $admport" -httpkadmind2="${httpkadmind} -A --reverse-proxied -T Negotiate -p $restport2" -httpkadmind="${httpkadmind} -A --reverse-proxied -T Negotiate -p $restport1" +httpkadmind2="${httpkadmind} -A --reverse-proxied -T Negotiate -T Bearer -p $restport2" +httpkadmind="${httpkadmind} -A --reverse-proxied -T Negotiate -T Bearer -p $restport1" kinit2="${kinit} -c $cache2 ${afs_no_afslog}" kinit="${kinit} -c $cache ${afs_no_afslog}" @@ -113,6 +113,13 @@ mkdir -p authz_dir > messages.log +# Generate JWT signing keys for Bearer token tests +echo "Generating JWT signing keys" +openssl genpkey -algorithm RSA -out ${objdir}/jwt_sign.pem -pkeyopt rsa_keygen_bits:2048 2>/dev/null || + { echo "Failed to generate JWT signing key"; exit 2; } +openssl pkey -in ${objdir}/jwt_sign.pem -pubout -out ${objdir}/jwt_sign_pub.pem 2>/dev/null || + { echo "Failed to extract JWT public key"; exit 2; } + # We'll avoid using a KDC for now. For testing /httpkadmind we only need keys # for Negotiate tokens, and we'll use ktutil and kimpersonate to make it # possible to create and accept those without a KDC. @@ -149,6 +156,19 @@ HTTP() { "$@" } +# HTTP_JWT jwt-token curl-opts +# Use Bearer token authentication instead of Negotiate +HTTP_JWT() { + jwt_token="$1" + shift + curl -g --resolve ${server}:${restport1}:127.0.0.1 \ + --resolve ${server}:${restport2}:127.0.0.1 \ + -H "Authorization: Bearer ${jwt_token}" \ + $verbose \ + -D response-headers \ + "$@" +} + # get_config QPARAMS curl-opts get_config() { url="http://${server}:${restport}/get-config?$1" @@ -352,6 +372,61 @@ ${ktutil} -k "${objdir}/extracted_keytab" list --keys > extracted_keytab.rest || cmp extracted_keytab.kadmin extracted_keytab.rest || { echo "Keytabs for $p don't match!"; exit 1; } +# JWT Bearer token tests +echo "Testing JWT Bearer token authentication" +hn=foo.ns.${domain} +p=HTTP/$hn +echo "Fetching keytab for virtual principal $p using JWT Bearer token" +rm -f extracted_keytab* +grant san_dnsname $hn foo@${R} + +# Get expected keytab via kadmin for comparison +${kadmin} ext_keytab -k extracted_keytab $p || + { echo "Failed to get a keytab for $p with kadmin"; exit 1; } +${ktutil} -k "${objdir}/extracted_keytab" list --keys > extracted_keytab.kadmin || + { echo "Failed to list keytab for $p"; exit 1; } + +# Generate a JWT token for foo@TEST.H5L.SE +jwt_token=$($hxtool jwt-sign -k ${objdir}/jwt_sign.pem -a RS256 \ + -s "foo@${R}" -i "test-issuer" -A "${server}" -l 3600 | tr -d '\n') || + { echo "Failed to generate JWT token"; exit 1; } + +# Use JWT Bearer authentication to fetch keytab +rm -f extracted_keytab +url="http://${server}:${restport}/get-keys?dNSName=${hn}" +HTTP_JWT "$jwt_token" $verbose -sf -o "${objdir}/extracted_keytab" "$url" || + { echo "Failed to get a keytab for $p with JWT Bearer token"; exit 1; } +${ktutil} -k "${objdir}/extracted_keytab" list --keys > extracted_keytab.jwt || + { echo "Failed to list keytab for $p"; exit 1; } +cmp extracted_keytab.kadmin extracted_keytab.jwt || + { echo "Keytabs for $p don't match (JWT vs kadmin)!"; exit 1; } + +echo "Testing JWT Bearer token with invalid signature fails" +# Create a token with wrong key (we'll just mangle the signature) +bad_jwt_token="${jwt_token}x" +url="http://${server}:${restport}/get-keys?dNSName=${hn}" +HTTP_JWT "$bad_jwt_token" $verbose -sf -o "${objdir}/extracted_keytab" "$url" && + { echo "Got keytab with invalid JWT token!"; exit 1; } + +echo "Testing JWT Bearer token with wrong subject fails authorization" +revoke +jwt_token_other=$($hxtool jwt-sign -k ${objdir}/jwt_sign.pem -a RS256 \ + -s "other@${R}" -i "test-issuer" -A "${server}" -l 3600 | tr -d '\n') || + { echo "Failed to generate JWT token"; exit 1; } +# foo@R is not authorized, only granted to other@R, but we're using foo@R's JWT +grant san_dnsname $hn other@${R} +url="http://${server}:${restport}/get-keys?dNSName=${hn}" +HTTP_JWT "$jwt_token" $verbose -sf -o "${objdir}/extracted_keytab" "$url" && + { echo "Got keytab with unauthorized JWT subject!"; exit 1; } + +# But other@R's JWT should work +HTTP_JWT "$jwt_token_other" $verbose -sf -o "${objdir}/extracted_keytab" "$url" || + { echo "Failed to get keytab with authorized JWT subject"; exit 1; } + +# Restore authorization for foo@R for subsequent tests +revoke +grant san_dnsname $hn foo@${R} + hn1=foo.ns.${domain} hn2=foobar.ns.${domain} hn3=xyz.${domain} diff --git a/tests/kdc/krb5-bx509.conf.in b/tests/kdc/krb5-bx509.conf.in index 2cd6fef22..22131a9b3 100644 --- a/tests/kdc/krb5-bx509.conf.in +++ b/tests/kdc/krb5-bx509.conf.in @@ -85,6 +85,8 @@ [bx509] realms = { TEST.H5L.SE = { + # JWT token validation keys + jwk_current = @objdir@/jwt_sign_pub.pem # Default (no cert exts requested) user = { # Use an issuer for user certs: diff --git a/tests/kdc/krb5-httpkadmind.conf.in b/tests/kdc/krb5-httpkadmind.conf.in index fb2fc6a2f..cbe9f7e1d 100644 --- a/tests/kdc/krb5-httpkadmind.conf.in +++ b/tests/kdc/krb5-httpkadmind.conf.in @@ -96,3 +96,10 @@ [domain_realm] . = TEST.H5L.SE + +[bx509] + realms = { + TEST.H5L.SE = { + jwk_current = @objdir@/jwt_sign_pub.pem + } + }