bx509d: /get-tgts: Allow piecemeal authorization
authorNicolas Williams <nico@twosigma.com>
Tue, 29 Nov 2022 00:12:04 +0000 (18:12 -0600)
committerNico Williams <nico@cryptonector.com>
Thu, 15 Dec 2022 23:44:41 +0000 (17:44 -0600)
We use the CSR authorizer system for /get-tgt and /get-tgts because,
well, the CSR authorizer system knows how to deal with principal names
("PKINIT SANs").

The caller of the /get-tgts end-point is a batch API that is meant for
super-user clients that implement orchestration for automation.  For
this end-point it's important to be able to return TGTs for just the
requested principals that are authorized rather than fail the whole
request because one principal isn't.  A principal might be rejected by
the authorizer if, for example, it's not meant to exist, and that might
be desirable because "synthetic" HDB entries might be configured, and we
might not want principals that don't exist to appear to exist for such
an orchestration service.

The hx509 CSR related functions allow one to mark specific requested
EKUs and SANs as authorized or not.  Until now we have simply rejected
all requests that don't have all attributes approved, but for /get-tgts
we need partial request approval.  This commit implements partial
request approval for the /get-tgts end-point.

kdc/bx509d.c
kdc/ipc_csr_authorizer.c

index 9cb9bcdbcb9dc1331d15c53560ae8dd414315230..31a14834de5b5654519bb520441020173bf0ef6b 100644 (file)
@@ -185,6 +185,7 @@ typedef struct bx509_request_desc {
     const char *redir;
     const char *method;
     size_t post_data_size;
+    size_t san_idx; /* For /get-tgts */
     enum k5_creds_kind cckind;
     char *pkix_store;
     char *tgts_filename;
@@ -1857,6 +1858,7 @@ authorize_TGT_REQ(struct bx509_request_desc *r)
         ret = kdc_authorize_csr(r->context, "get-tgt", r->req, p);
     krb5_free_principal(r->context, p);
     hx509_request_free(&r->req);
+    r->req = NULL;
     if (ret)
         return bad_403(r, ret, "Not authorized to requested TGT");
     return ret;
@@ -2125,18 +2127,79 @@ get_tgts_param_execute_cb(void *d,
                           const char *val)
 {
     struct bx509_request_desc *r = d;
-    heim_mhd_result res = MHD_YES;
+    hx509_san_type san_type;
     krb5_error_code ret;
+    size_t san_idx = r->san_idx++;
+    const char *save_for_cname = r->for_cname;
+    char *s = NULL;
 
-    if (strcmp(key, "cname") == 0 && val) {
-        /* Handled upstairs */
+    /* We expect only cname=principal q-params here */
+    if (strcmp(key, "cname") != 0 || val == NULL)
+        return MHD_YES;
+
+    /*
+     * We expect the `san_idx'th SAN in the `r->req' request checked by
+     * kdc_authorize_csr() to be the same as this cname.  This happens
+     * naturally because we add these SANs to `r->req' in the same order as we
+     * visit them here (unless our HTTP library somehow went crazy).
+     *
+     * Still, we check that it's the same SAN.
+     */
+    ret = hx509_request_get_san(r->req, san_idx, &san_type, &s);
+    if (ret == HX509_NO_ITEM ||
+        san_type != HX509_SAN_TYPE_PKINIT ||
+        strcmp(s, val) != 0) {
+        /*
+         * If the cname and SAN don't match, it's some weird internal error
+         * (can't happen).
+         */
+        krb5_set_error_message(r->context, r->error_code = EACCES,
+                               "PKINIT SAN not granted: %s (internal error)",
+                               val);
+        ret = EACCES;
+    }
+
+    /*
+     * We're going to pretend to be this SAN for the purpose of acquring a TGT
+     * for it.  So we "push" `r->for_cname'.
+     */
+    if (ret == 0)
         r->for_cname = val;
+
+    /*
+     * Our authorizer supports partial authorization where the whole request is
+     * rejected but some features of it are permitted.
+     *
+     * (In most end-points we don't want partial authorization, but in
+     * /get-tgts we very much do.)
+     */
+    if (ret == 0 && !hx509_request_san_authorized_p(r->req, san_idx)) {
+        heim_audit_addkv((heim_svc_req_desc)r, KDC_AUDIT_VIS,
+                         "REJECT_krb5PrincipalName", "%s", val);
+        krb5_set_error_message(r->context, r->error_code = EACCES,
+                               "PKINIT SAN denied: %s", val);
+        ret = EACCES;
+    }
+    if (ret == 0) {
+        heim_audit_addkv((heim_svc_req_desc)r, KDC_AUDIT_VIS,
+                         "ACCEPT_krb5PrincipalName", "%s", val);
         ret = k5_get_creds(r, K5_CREDS_EPHEMERAL);
-        res = get_tgts_accumulate_ccache(r, ret);
-    } else {
-        /* Handled upstairs */
+        if (ret == 0)
+            heim_audit_addkv((heim_svc_req_desc)r, KDC_AUDIT_VIS,
+                             "ISSUE_krb5PrincipalName", "%s", val);
     }
-    return res;
+
+    /*
+     * If ret == 0 this will gather the TGT we acquired, else it will acquire
+     * the error we got.
+     */
+    ret = get_tgts_accumulate_ccache(r, ret);
+
+    /* Now we "pop" `r->for_cname' */
+    r->for_cname = save_for_cname;
+
+    hx509_xfree(s);
+    return MHD_YES;
 }
 
 /*
@@ -2172,7 +2235,10 @@ get_tgts(struct bx509_request_desc *r)
         ret = r->error_code;
     }
     if (ret == 0) {
-        /* Authorize requested client principal names (calls bad_req()) */
+        /*
+         * Check authorization of the authenticated client to the requested
+         * client principal names (calls bad_req()).
+         */
         r->error_code = 0;
         res = MHD_get_connection_values(r->connection, MHD_GET_ARGUMENT_KIND,
                                         get_tgts_param_authorize_cb, r);
@@ -2181,16 +2247,29 @@ get_tgts(struct bx509_request_desc *r)
 
         ret = r->error_code;
         if (ret == 0) {
+            /* Use the same configuration as /get-tgt (or should we?) */
             ret = kdc_authorize_csr(r->context, "get-tgt", r->req, p);
+
+            /*
+             * We tolerate EACCES because we support partial approval.
+             *
+             * (KRB5_PLUGIN_NO_HANDLE means no plugin handled the authorization
+             * check.)
+             */
+            if (ret == EACCES || ret == KRB5_PLUGIN_NO_HANDLE)
+                ret = 0;
             if (ret) {
                 krb5_free_principal(r->context, p);
                 return bad_403(r, ret, "Permission denied");
             }
         }
-        hx509_request_free(&r->req);
     }
     if (ret == 0) {
-        /* get_tgts_param_execute_cb() calls bad_req() */
+        /*
+         * Get the actual TGTs that were authorized.
+         *
+         * get_tgts_param_execute_cb() calls bad_req()
+         */
         r->error_code = 0;
         res = MHD_get_connection_values(r->connection, MHD_GET_ARGUMENT_KIND,
                                         get_tgts_param_execute_cb, r);
@@ -2199,6 +2278,8 @@ get_tgts(struct bx509_request_desc *r)
         ret = r->error_code;
     }
     krb5_free_principal(r->context, p);
+    hx509_request_free(&r->req);
+    r->req = NULL;
 
     /*
      * get_tgts_param_execute_cb() will write its JSON response to the file
index 7d77e7f812a4a0936958d8a252c7190cc77505e9..d90f056c54c2eee1aa7476248973523a768c8ad7 100644 (file)
@@ -197,8 +197,189 @@ cmd_append(struct rk_strpool **cmd, const char *s0, ...)
     return ret;
 }
 
+/* Like strpbrk(), but from the end of the string */
+static char *
+strrpbrk(char *s, const char *accept)
+{
+    char *last = NULL;
+    char *p = s;
+
+    do {
+        p = strpbrk(p, accept);
+        if (p != NULL) {
+            last = p;
+            p++;
+        }
+    } while (p != NULL);
+    return last;
+}
+
+/*
+ * For /get-tgts we need to support partial authorization of requests.  The
+ * hx509_request APIs support that.
+ *
+ * Here we just step through the IPC server's response and mark the
+ * corresponding request elements authorized so that /get-tgts can issue or not
+ * issue TGTs according to which requested principals are authorized and which
+ * are not.
+ */
 static int
-call_svc(krb5_context context, heim_ipc ipc, const char *cmd)
+mark_piecemeal_authorized(krb5_context context,
+                          hx509_request csr,
+                          heim_octet_string *rep)
+{
+    size_t san_idx = 0;
+    size_t eku_idx = 0;
+    char *s, *p, *rep2, *tok, *next = NULL;
+    int slow_path = 0;
+    int partial = 0;
+    int ret = 0;
+
+    /* We have a data, but we want a C string */
+    if ((rep2 = strndup(rep->data, rep->length)) == NULL)
+        return krb5_enomem(context);
+
+    /* The first token should be "denied"; skip it */
+    if ((s = strchr(rep2, ' ')) == NULL) {
+        free(rep2);
+        return EACCES;
+    }
+    s++;
+
+    while ((tok = strtok_r(s, ",", &next))) {
+        hx509_san_type san_type, san_type2;
+        char *s2 = NULL;
+
+        s = NULL; /* for strtok_r() */
+
+        if (strncmp(tok, "eku=", sizeof("eku=") -1) == 0) {
+            /*
+             * Very simplistic handling of partial authz for EKUs:
+             *
+             *  - denial of an EKU -> deny the whole request
+             *  - else below mark all EKUs approved
+             */
+            if (strstr(tok, ":denied")) {
+                krb5_set_error_message(context, EACCES, "CSR denied because "
+                                       "EKU denied: %s", tok);
+                ret = EACCES;
+                break;
+            }
+            continue;
+        }
+
+        /*
+         * For SANs we check that the nth SAN in the response matches the nth
+         * SAN in the hx509_request.
+         */
+
+        if (strncmp(tok, "san_pkinit=", sizeof("san_pkinit=") - 1) == 0) {
+            tok += sizeof("san_pkinit=") - 1;
+            san_type = HX509_SAN_TYPE_PKINIT;
+        } else if (strncmp(tok, "san_dnsname=", sizeof("san_dnsname=") -1) == 0) {
+            tok += sizeof("san_dnsname=") - 1;
+            san_type = HX509_SAN_TYPE_DNSNAME;
+        } else if (strncmp(tok, "san_email=", sizeof("san_email=") -1) == 0) {
+            tok += sizeof("san_email=") - 1;
+            san_type = HX509_SAN_TYPE_EMAIL;
+        } else if (strncmp(tok, "san_xmpp=", sizeof("san_xmpp=") -1) == 0) {
+            tok += sizeof("san_xmpp=") - 1;
+            san_type = HX509_SAN_TYPE_XMPP;
+        } else if (strncmp(tok, "san_ms_upn=", sizeof("san_ms_upn=") -1) == 0) {
+            tok += sizeof("san_ms_upn=") - 1;
+            san_type = HX509_SAN_TYPE_MS_UPN;
+        } else {
+            krb5_set_error_message(context, EACCES, "CSR denied because could "
+                                   "not parse token in response: %s", tok);
+            ret = EACCES;
+            break;
+        }
+
+        /*
+         * This token has to end in ":granted" or ":denied".  Using our
+         * `strrpbrk()' means we can deal with principals names that have ':'
+         * in them.
+         */
+        if ((p = strrpbrk(tok, ":")) == NULL) {
+            san_idx++;
+            continue;
+        }
+        *(p++) = '\0';
+
+        /* Now we get the nth SAN from the authorization */
+        ret = hx509_request_get_san(csr, san_idx, &san_type2, &s2);
+        if (ret == HX509_NO_ITEM) {
+            /* See below */
+            slow_path = 1;
+            break;
+        }
+
+        /* And we check that it matches the SAN in this token */
+        if (ret == 0) {
+            if (san_type != san_type2 ||
+                strcmp(tok, s2) != 0) {
+                /*
+                 * We expect the tokens in the reply to be in the same order as
+                 * in the request.  If not, we must take a slow path where we
+                 * have to sort requests and responses then iterate them in
+                 * order.
+                 */
+                slow_path = 1;
+                hx509_xfree(s2);
+                break;
+            }
+            hx509_xfree(s2);
+
+            if (strcmp(p, "granted") == 0) {
+                ret = hx509_request_authorize_san(csr, san_idx);
+            } else {
+                partial = 1;
+                ret = hx509_request_reject_san(csr, san_idx);
+            }
+            if (ret)
+                break;
+        }
+        san_idx++;
+    }
+
+    if (slow_path) {
+        /*
+         * FIXME?  Implement the slow path?
+         *
+         * Basically, we'd get all the SANs from the request into an array of
+         * {SAN, index} and sort that array, then all the SANs from the
+         * response into an array and sort it, then step a cursor through both,
+         * using the index from the first to mark SANs in the request
+         * authorized or rejected.
+         */
+        krb5_set_error_message(context, EACCES, "CSR denied because "
+                               "authorizer service did not include all "
+                               "piecemeal grants/denials in order");
+        ret = EACCES;
+    }
+
+    /* Mark all the EKUs authorized */
+    for (eku_idx = 0; ret == 0; eku_idx++)
+        ret = hx509_request_authorize_eku(csr, eku_idx);
+    if (ret == HX509_NO_ITEM)
+        ret = 0;
+    if (ret == 0 && partial) {
+        krb5_set_error_message(context, EACCES, "CSR partially authorized");
+        ret = EACCES;
+    }
+
+    free(rep2);
+    return ret;
+}
+
+static krb5_error_code mark_authorized(hx509_request);
+
+static int
+call_svc(krb5_context context,
+         heim_ipc ipc,
+         hx509_request csr,
+         const char *cmd,
+         int piecemeal_check_ok)
 {
     heim_octet_string req, resp;
     int ret;
@@ -207,40 +388,66 @@ call_svc(krb5_context context, heim_ipc ipc, const char *cmd)
     req.length = strlen(cmd);
     resp.length = 0;
     resp.data = NULL;
-    if ((ret = heim_ipc_call(ipc, &req, &resp, NULL))) {
-        if (resp.length && resp.length < INT_MAX) {
-            krb5_set_error_message(context, ret, "CSR denied: %.*s",
-                                   (int)resp.length, (const char *)resp.data);
-            ret = EACCES;
-        } else {
-            krb5_set_error_message(context, EACCES, "CSR denied because could "
-                                   "not reach CSR authorizer IPC service");
+    ret = heim_ipc_call(ipc, &req, &resp, NULL);
+
+    /* Check for all granted case */
+    if (ret == 0 &&
+        resp.length == sizeof("granted") - 1 &&
+        strncasecmp(resp.data, "granted", sizeof("granted") - 1) == 0) {
+        free(resp.data);
+        return mark_authorized(csr); /* Full approval */
+    }
+
+    /* Check for "denied ..." piecemeal authorization case */
+    if ((ret == 0 || ret == EACCES || ret == KRB5_PLUGIN_NO_HANDLE) &&
+        piecemeal_check_ok &&
+        resp.length > sizeof("denied") - 1 &&
+        strncasecmp(resp.data, "denied", sizeof("denied") - 1) == 0) {
+        /* Piecemeal authorization */
+        ret = mark_piecemeal_authorized(context, csr, &resp);
+
+        /* mark_piecemeal_authorized() should return EACCES; just in case: */
+        if (ret == 0)
             ret = EACCES;
-        }
+        free(resp.data);
         return ret;
     }
+
+    /* All other failure cases */
+
     if (resp.data == NULL || resp.length == 0) {
-        free(resp.data);
         krb5_set_error_message(context, ret, "CSR authorizer IPC service "
                                "failed silently");
+        free(resp.data);
         return EACCES;
     }
+
+    if (resp.length == sizeof("ignore") - 1 &&
+        strncasecmp(resp.data, "ignore", sizeof("ignore") - 1) == 0) {
+        /*
+         * In this case the server is saying "I can't handle this request, try
+         * some other authorizer plugin".
+         */
+        free(resp.data);
+        return KRB5_PLUGIN_NO_HANDLE;
+    }
+
     if (resp.length == sizeof("denied") - 1 &&
         strncasecmp(resp.data, "denied", sizeof("denied") - 1) == 0) {
-        free(resp.data);
         krb5_set_error_message(context, ret, "CSR authorizer rejected %s",
                                cmd);
-        return EACCES;
-    }
-    if (resp.length == sizeof("granted") - 1 &&
-        strncasecmp(resp.data, "granted", sizeof("granted") - 1) == 0) {
         free(resp.data);
-        return 0;
+        return EACCES;
     }
-    krb5_set_error_message(context, ret, "CSR authorizer failed %s: %.*s",
-                           cmd, resp.length < INT_MAX ? (int)resp.length : 0,
-                           resp.data);
-    return EACCES;
+
+    if (resp.length > INT_MAX)
+        krb5_set_error_message(context, ret, "CSR authorizer rejected %s", cmd);
+    else
+        krb5_set_error_message(context, ret, "CSR authorizer rejected %s: %.*s",
+                               cmd, resp.length, resp.data);
+
+    free(resp.data);
+    return ret;
 }
 
 static void
@@ -294,6 +501,7 @@ authorize(void *ctx,
     char *princ = NULL;
     char *s = NULL;
     int do_check = 0;
+    int piecemeal_check_ok = 1;
 
     if ((svc = krb5_config_get_string(context, NULL, app ? app : "kdc",
                                       "ipc_csr_authorizer", "service", NULL))
@@ -318,10 +526,22 @@ authorize(void *ctx,
 
     for (i = 0; ret == 0; i++) {
         hx509_san_type san_type;
+        size_t p;
 
         ret = hx509_request_get_san(csr, i, &san_type, &s);
         if (ret)
             break;
+
+        /*
+         * We cannot do a piecemeal check if any of the SANs could make the
+         * response ambiguous.
+         */
+        p = strcspn(s, ",= ");
+        if (s[p] != '\0')
+            piecemeal_check_ok = 0;
+        if (piecemeal_check_ok && strstr(s, ":granted") != NULL)
+            piecemeal_check_ok = 0;
+
         switch (san_type) {
         case HX509_SAN_TYPE_EMAIL:
             if ((ret = cmd_append(&cmd, " san_email=", s, NULL)))
@@ -383,12 +603,9 @@ authorize(void *ctx,
         if ((s = rk_strpoolcollect(cmd)) == NULL)
             goto enomem;
         cmd = NULL;
-        if ((ret = call_svc(context, ipc, s)))
+        if ((ret = call_svc(context, ipc, csr, s, piecemeal_check_ok)))
             goto out;
-    } /* else -> permit */
-
-    if ((ret = mark_authorized(csr)))
-        goto out;
+    } /* else there was nothing to check -> permit */
 
     *result = TRUE;
     ret = 0;