From 855b27ccfbd35a6d74c78f5c644c594b03138a0c Mon Sep 17 00:00:00 2001 From: Nicolas Williams Date: Tue, 29 Jun 2021 00:31:13 -0500 Subject: [PATCH] httpkadmind: Allow host SPNs to fetch selves Combined with the synthetic_clients feature, this will allow hosts that have a PKINIT-worthy client certificate with a SAN with their host principals to create their own principals and "extract" their host keytabs. Together with some other PKIX credential bootstrapping protocol, this can help hosts bootstrap Kerberos host credentials. --- kdc/httpkadmind.8 | 17 ++++++ kdc/httpkadmind.c | 91 +++++++++++++++++++++------- tests/kdc/check-httpkadmind.in | 96 ++++++++++++++++++++++++++++-- tests/kdc/krb5-httpkadmind.conf.in | 14 ++--- 4 files changed, 183 insertions(+), 35 deletions(-) diff --git a/kdc/httpkadmind.8 b/kdc/httpkadmind.8 index 08edce41f..90b4f63aa 100644 --- a/kdc/httpkadmind.8 +++ b/kdc/httpkadmind.8 @@ -160,6 +160,23 @@ but using .Nm [ext_keytab] as the .Nm krb5.conf(5) section. +Clients with host-based principals for the the host service can +create and extract keys for their own service name, but otherwise +a number of service names are not denied: +.Bl -tag -width Ds -offset indent +.It Dq host +.It Dq root +.It Dq exceed +.El +as well as all the service names for Heimdal-specific services: +.Bl -tag -width Ds -offset indent +.It Dq krbtgt +.It Dq iprop +.It Dq kadmin +.It Dq hprop +.It Dq WELLKNOWN +.It Dq K +.El .Pp Supported options: .Bl -tag -width Ds -offset indent diff --git a/kdc/httpkadmind.c b/kdc/httpkadmind.c index 13b5528e1..b21d034eb 100644 --- a/kdc/httpkadmind.c +++ b/kdc/httpkadmind.c @@ -171,6 +171,7 @@ typedef struct kadmin_request_desc { unsigned int revoke:1; unsigned int create:1; unsigned int ro:1; + unsigned int is_self:1; char frombuf[128]; } *kadmin_request_desc; @@ -875,7 +876,7 @@ check_service_name(kadmin_request_desc r, const char *name) return 0; krb5_set_error_message(r->context, EACCES, "No one is allowed to fetch keys for " - "Heimdal service %s because of authorizer " + "service \"%s\" because of authorizer " "limitations", name); return EACCES; } @@ -916,6 +917,8 @@ param_cb(void *d, strcmp(key, "rotate") == 0 || strcmp(key, "create") == 0 || strcmp(key, "ro") == 0) { + heim_audit_addkv((heim_svc_req_desc)r, KDC_AUDIT_VIS, + "requested_option", "%s", key); if (!val || strcmp(val, "true") != 0) krb5_set_error_message(r->context, ret = EINVAL, "get-keys \"%s\" q-param accepts " @@ -930,28 +933,38 @@ param_cb(void *d, r->create = 1; else if (strcmp(key, "ro") == 0) r->ro = 1; - if (ret == 0) - heim_audit_addkv((heim_svc_req_desc)r, KDC_AUDIT_VIS, - "requested_option", "%s", key); } else if (strcmp(key, "dNSName") == 0 && val) { - s = heim_string_create(val); - if (!s) - ret = krb5_enomem(r->context); - else - ret = heim_array_append_value(r->hostnames, s); heim_audit_addkv((heim_svc_req_desc)r, KDC_AUDIT_VIS, "requested_dNSName", "%s", val); - ret = hx509_request_add_dns_name(r->context->hx509ctx, r->req, val); + if (r->is_self) { + krb5_set_error_message(r->context, ret = EACCES, + "only one service may be requested for self"); + } else if (strchr(val, '.') == NULL) { + krb5_set_error_message(r->context, ret = EACCES, + "dNSName must have at least one '.' in it"); + } else { + s = heim_string_create(val); + if (!s) + ret = krb5_enomem(r->context); + else + ret = heim_array_append_value(r->hostnames, s); + } + if (ret == 0) + ret = hx509_request_add_dns_name(r->context->hx509ctx, r->req, val); } else if (strcmp(key, "service") == 0 && val) { - ret = check_service_name(r, val); + heim_audit_addkv((heim_svc_req_desc)r, KDC_AUDIT_VIS, + "requested_service", "%s", val); + if (r->is_self) + krb5_set_error_message(r->context, ret = EACCES, + "use \"spn\" for self"); + else + ret = check_service_name(r, val); if (ret == 0) { s = heim_string_create(val); if (!s) ret = krb5_enomem(r->context); else ret = heim_array_append_value(r->service_names, s); - heim_audit_addkv((heim_svc_req_desc)r, KDC_AUDIT_VIS, - "requested_service", "%s", val); } } else if (strcmp(key, "enctypes") == 0 && val) { r->enctypes = strdup(val); @@ -959,6 +972,11 @@ param_cb(void *d, ret = krb5_enomem(r->context); heim_audit_addkv((heim_svc_req_desc)r, KDC_AUDIT_VIS, "requested_enctypes", "%s", val); + } else if (r->is_self && strcmp(key, "spn") == 0 && val) { + heim_audit_addkv((heim_svc_req_desc)r, KDC_AUDIT_VIS, + "requested_spn", "%s", val); + krb5_set_error_message(r->context, ret = EACCES, + "only one service may be requested for self"); } else if (strcmp(key, "spn") == 0 && val) { krb5_principal p = NULL; const char *hostname = ""; @@ -971,17 +989,44 @@ param_cb(void *d, if (ret == 0 && krb5_principal_get_realm(r->context, p) == NULL) ret = krb5_principal_set_realm(r->context, p, r->realm ? r->realm : realm); + + /* + * The SPN has to have two components. + * + * TODO: Support more components? Support AD-style NetBIOS computer + * account names? + */ if (ret == 0 && krb5_principal_get_num_comp(r->context, p) != 2) ret = ENOTSUP; - if (ret == 0) + + /* + * Allow only certain service names. Except that when + * the SPN == the requestor's principal name then allow the "host" + * service name. + */ + if (ret == 0) { + const char *service = + krb5_principal_get_comp_string(r->context, p, 0); + + if (strcmp(service, "host") == 0 && + krb5_principal_compare(r->context, p, r->cprinc) && + !r->is_self && + heim_array_get_length(r->hostnames) == 0 && + heim_array_get_length(r->spns) == 0) { + r->is_self = 1; + } else + ret = check_service_name(r, service); + } + if (ret == 0 && !krb5_principal_compare(r->context, p, r->cprinc)) ret = check_service_name(r, krb5_principal_get_comp_string(r->context, p, 0)); - if (ret == 0) + if (ret == 0) { hostname = krb5_principal_get_comp_string(r->context, p, 1); - if (!hostname || !strchr(hostname, '.')) - krb5_set_error_message(r->context, ret = ENOTSUP, - "Only host-based service names supported"); + if (!hostname || !strchr(hostname, '.')) + krb5_set_error_message(r->context, ret = ENOTSUP, + "Only host-based service names supported"); + } if (ret == 0 && r->realm) ret = krb5_principal_set_realm(r->context, p, r->realm); else if (ret == 0 && realm) @@ -1016,19 +1061,25 @@ authorize_req(kadmin_request_desc r) { krb5_error_code ret; + r->is_self = 0; ret = hx509_request_init(r->context->hx509ctx, &r->req); if (ret) return bad_enomem(r, ret); (void) MHD_get_connection_values(r->connection, MHD_GET_ARGUMENT_KIND, param_cb, r); ret = r->ret; + if (ret == EACCES) + return bad_403(r, ret, "Not authorized to requested principal(s)"); if (ret) return bad_req(r, ret, MHD_HTTP_SERVICE_UNAVAILABLE, "Could not handle query parameters"); - ret = kdc_authorize_csr(r->context, "ext_keytab", r->req, r->cprinc); + if (r->is_self) + ret = 0; + else + ret = kdc_authorize_csr(r->context, "ext_keytab", r->req, r->cprinc); if (ret == EACCES || ret == EINVAL || ret == ENOTSUP || ret == KRB5KDC_ERR_POLICY) - return bad_403(r, ret, "Not authorized to requested certificate"); + return bad_403(r, ret, "Not authorized to requested principal(s)"); if (ret) return bad_req(r, ret, MHD_HTTP_SERVICE_UNAVAILABLE, "Error checking authorization"); diff --git a/tests/kdc/check-httpkadmind.in b/tests/kdc/check-httpkadmind.in index 1962a3ff3..b593925a3 100644 --- a/tests/kdc/check-httpkadmind.in +++ b/tests/kdc/check-httpkadmind.in @@ -65,7 +65,8 @@ restport2=@restport2@ server=datan.test.h5l.se otherserver=other.test.h5l.se cache="FILE:${objdir}/cache.krb5" -admincache="FILE:${objdir}/cache2.krb5" +cache2="FILE:${objdir}/cache2.krb5" +admincache="FILE:${objdir}/cache3.krb5" keyfile="${hx509_data}/key.der" keyfile2="${hx509_data}/key2.der" kt=${objdir}/kt @@ -81,10 +82,14 @@ kadmind="${kadmind} --keytab=${keytab} --detach -p $admport" httpkadmind2="${httpkadmind} --reverse-proxied -T Negotiate -p $restport2" httpkadmind="${httpkadmind} --reverse-proxied -T Negotiate -p $restport1" +kinit2="${kinit} -c $cache2 ${afs_no_afslog}" kinit="${kinit} -c $cache ${afs_no_afslog}" adminklist="${klist} --hidden -v -c $admincache" +klist2="${klist} --hidden -v -c $cache2" klist="${klist} --hidden -v -c $cache" +kgetcred2="${kgetcred} -c $cache2" kgetcred="${kgetcred} -c $cache" +kdestroy2="${kdestroy} -c $cache2 ${afs_no_unlog}" kdestroy="${kdestroy} -c $cache ${afs_no_unlog}" kx509="${kx509} -c $cache" @@ -159,11 +164,11 @@ get_keytab_POST() { get_keytab "$q" -X POST --data-binary @/dev/null -f "$@" && { echo "POST succeeded w/o CSRF token!"; return 1; } get_keytab "$q" -X POST --data-binary @/dev/null -D response-headers "$@" - grep ^X-CSRF-Token: response-headers >/dev/null || - { echo "POST w/o CSRF token had response w/o CSRF token!"; return 1; } - get_keytab "$q" -X POST --data-binary @/dev/null -f \ + grep ^X-CSRF-Token: response-headers >/dev/null || return 1 + get_keytab "$q" -X POST --data-binary @/dev/null -D response-headers \ -H "$(sed -e 's/\r//' response-headers | grep ^X-CSRF-Token:)" "$@" - return $? + grep '^HTTP/1.1 200' response-headers >/dev/null || return $? + return 0 } get_keytab_POST_redir() { @@ -481,6 +486,38 @@ $kimpersonate --ticket-flags=initial --ccache=$admincache -k $keytab -t aes128-c $adminklist -t >/dev/null || { echo "failed to setup kimpersonate credentials"; exit 2; } + +echo "Making PKINIT certs for KDC" +${hxtool} issue-certificate \ + --self-signed \ + --issue-ca \ + --ca-private-key=FILE:${keyfile} \ + --subject="CN=CA,DC=test,DC=h5l,DC=se" \ + --certificate="FILE:ca.crt" || exit 1 +${hxtool} request-create \ + --subject="CN=kdc,DC=test,DC=h5l,DC=se" \ + --key=FILE:${keyfile2} \ + req-kdc.der || exit 1 +${hxtool} issue-certificate \ + --ca-certificate=FILE:$objdir/ca.crt,${keyfile} \ + --type="pkinit-kdc" \ + --pk-init-principal="krbtgt/TEST.H5L.SE@TEST.H5L.SE" \ + --req="PKCS10:req-kdc.der" \ + --certificate="FILE:kdc.crt" || exit 1 +${hxtool} request-create \ + --subject="CN=bar,DC=test,DC=h5l,DC=se" \ + --key=FILE:${keyfile2} \ + req-pkinit.der || + { echo "Failed to make CSR for PKINIT client cert"; exit 1; } +${hxtool} issue-certificate \ + --ca-certificate=FILE:$objdir/ca.crt,${keyfile} \ + --type="pkinit-client" \ + --pk-init-principal="host/synthesized.${domain}@$R" \ + --req="PKCS10:req-pkinit.der" \ + --lifetime=7d \ + --certificate="FILE:pkinit-synthetic.crt" || + { echo "Failed to make PKINIT client cert"; exit 1; } + echo "Starting kdc needed for httpkadmind authentication to kadmind" ${kdc} --detach --testing || { echo "kdc failed to start"; exit 1; } kdcpid=`getpid kdc` @@ -585,6 +622,50 @@ cmp extracted_keytab.rest1 extracted_keytab.rest2 > /dev/null && test "$(grep $p extracted_keytab.rest2 | wc -l)" -eq 3 || { echo "Wrong number of new keys!"; exit 1; } +echo "Checking that host services as clients can self-serve" +hn=synthesized.${domain} +p=host/$hn +KRB5CCNAME=$admincache ${kadmin} get -s $p && + { echo "Internal error -- $p exists too soon"; exit 1; } +${kinit2} -C "FILE:${objdir}/pkinit-synthetic.crt,${keyfile2}" ${p}@${R} || \ + { echo "Failed to kinit with PKINIT client cert"; exit 1; } +${kgetcred2} HTTP/localhost@$R || echo WAT +${klist2} +rm -f extracted_keytab* +KRB5CCNAME=$cache2 \ +get_keytab_POST "spn=$p&create=true" -s -o "${objdir}/extracted_keytab" || + { echo "Failed to create and extract host keys for self"; exit 1; } +${ktutil} -k "${objdir}/extracted_keytab" list || + { echo "Failed to create and extract host keys for self (bogus keytab)"; exit 1; } +KRB5CCNAME=$admincache ${kadmin} get -s $p || + { echo "Failed to create and extract host keys for self"; exit 1; } + +echo "Checking that host services can't get other host service principals" +hn=nonexistent.${domain} +p=host/$hn +KRB5CCNAME=$cache2 \ +get_keytab_POST "spn=$p&create=true" -s -o "${objdir}/extracted_keytab2" && + { echo "Failed to fail to create and extract host keys for other!"; exit 1; } +${ktutil} -k "${objdir}/extracted_keytab2" list || true +KRB5CCNAME=$admincache ${kadmin} get -s $p && + { echo "Failed to fail to create and extract host keys for other!"; exit 1; } + +echo "Checking that host services can't get keys for themselves and others" +hn=synthesized.${domain} +p=host/$hn +p2=host/nonexistent.${domain} +${kinit2} -C "FILE:${objdir}/pkinit-synthetic.crt,${keyfile2}" ${p}@${R} || \ + { echo "Failed to kinit with PKINIT client cert"; exit 1; } +${kgetcred2} HTTP/localhost@$R || echo WAT +${klist2} +rm -f extracted_keytab* +KRB5CCNAME=$cache2 \ +get_keytab_POST "spn=$p&spn=$p2&create=true" -s -o "${objdir}/extracted_keytab" && + { echo "Failed to fail to create and extract host keys for other!"; exit 1; } +${ktutil} -k "${objdir}/extracted_keytab2" list || true +KRB5CCNAME=$admincache ${kadmin} get -s $p2 && + { echo "Failed to fail to create and extract host keys for other!"; exit 1; } + grep 'Internal error' messages.log && { echo "Internal errors in log"; exit 1; } @@ -593,7 +674,10 @@ sh ${leaks_kill} kadmind $kadmindpid || ec=1 sh ${leaks_kill} kadmind $kadmind2pid || ec=1 sh ${leaks_kill} kdc $kdcpid || ec=1 -trap "" EXIT +if [ $ec = 0 ]; then + trap "" EXIT + echo "Success" +fi # TODO # diff --git a/tests/kdc/krb5-httpkadmind.conf.in b/tests/kdc/krb5-httpkadmind.conf.in index e3adbd2bb..4882d52f5 100644 --- a/tests/kdc/krb5-httpkadmind.conf.in +++ b/tests/kdc/krb5-httpkadmind.conf.in @@ -7,8 +7,8 @@ name_canon_rules = as-is:realm=TEST.H5L.SE [appdefaults] - pkinit_anchors = FILE:@objdir@/pkinit-anchor.pem - pkinit_pool = FILE:@objdir@/pkinit-anchor.pem + pkinit_anchors = FILE:@objdir@/ca.crt + pkinit_pool = FILE:@objdir@/ca.crt [realms] TEST.H5L.SE = { @@ -19,9 +19,10 @@ [kdc] num-kdc-processes = 1 strict-nametypes = true + synthetic_clients = true enable-pkinit = true - pkinit_identity = PEM-FILE:@objdir@/user-issuer.pem - pkinit_anchors = PEM-FILE:@objdir@/pkinit-anchor.pem + pkinit_identity = FILE:@objdir@/kdc.crt,@srcdir@/../../lib/hx509/data/key2.der + pkinit_anchors = FILE:@objdir@/ca.crt pkinit_mappings_file = @srcdir@/pki-mapping # Locate kdc plugins for testing @@ -29,11 +30,6 @@ # Configure kdc plugins for testing simple_csr_authorizer_directory = @objdir@/simple_csr_authz - - enable-pkinit = true - pkinit_identity = PEM-FILE:@objdir@/user-issuer.pem - pkinit_anchors = PEM-FILE:@objdir@/pkinit-anchor.pem - pkinit_mappings_file = @srcdir@/pki-mapping database = { dbname = @objdir@/current-db