]> git.kaiwu.me - haproxy.git/commitdiff
MINOR: acme: implement EAB - external account binding
authorMia Kanashi <chad@redpilled.dev>
Wed, 6 May 2026 21:17:42 +0000 (00:17 +0300)
committerWilliam Lallemand <wlallemand@haproxy.com>
Thu, 7 May 2026 13:19:15 +0000 (15:19 +0200)
Patch introduces ACME EAB support.

Configuring EAB requires two parts: Key ID and MAC Key.
Key ID is an ASCII string that specifies the name of the record CA
should look up. MAC Key is a base64url encoded key that is used
for the sake of JWS signing, using HS256 or other algorithms.
They are the credentials so must be stored securely.

A thing about EAB is that it is required only during account creation
so it is unexpectedly complex to think about.
Some CAs provide EAB credential pair that is reused between
multiple account order requests, for example ZeroSSL, but others like
Google Trusted Services require an unique EAB credential for each new
account creation request.

There are a lot of ways config could be implemented, I decided to make
so that Key ID and MAC Key are stored in separate files on disk,
that decision was made because of the security concerns.
File based approach in particular works well with systemd credentials,
works well with systems that have config world readable, or immutable,
and is compatible with existing setups that specify credentials in a
file.

EAB is configured through options like this in an acme section:

eab-mac-alg HS512
eab-mac-key pebble.eab.mac-key
eab-key-id pebble.eab.key-id

I decided to not error out on empty files, but issue a log msg instead,
so that credentials can be removed without changing the haproxy config.

Used read_line_to_trash function from tools.c for reading files,
that is something that could be replaced by a dedicated function too.

No backport needed

doc/configuration.txt
include/haproxy/acme-t.h
src/acme.c

index 3316db7c9e6e40680a659742ec43f3d5372bcc92..829d46ef399a1a03cca057afbcfd593dc3fbfb32 100644 (file)
@@ -32660,6 +32660,33 @@ Example:
       curves P-384
       map virt@acme
 
+eab-key-id <filename>
+  Configure the path to the EAB key id file. The credential is provided by
+  the CA and must be placed at the specified path before starting HAProxy.
+  It is used during account creation only.
+
+  The file must contain a plain ASCII string.
+
+  EAB credentials are only required during the initial ACME account creation
+  and can be removed afterwards, either from the config or by emptying the
+  files. An empty file is silently ignored. Whitespace is not ignored, except
+  for the trailing newline.
+
+  See also: "eab-mac-key", "eab-mac-alg"
+
+eab-mac-key <filename>
+  Configure the path to the EAB MAC key file. The credential is provided by
+  the CA and must be placed at the specified path before starting HAProxy.
+  It is used during account creation only.
+
+  The file must contain a base64url encoded MAC key.
+
+  EAB credentials are only required during the initial ACME account creation
+  and can be removed afterwards, either from the config or by emptying the
+  files. An empty file is silently ignored. Whitespace is not ignored, except
+  for the trailing newline.
+
+  See also: "eab-key-id", "eab-mac-alg"
 
 12.9. Healthchecks
 ------------------
index 24df7c44a5db665a9536bf8663382a6970615ab2..87f3bfffa3a83cd760f6223ffdf1e789a5a7b687 100644 (file)
@@ -4,6 +4,7 @@
 
 #include <haproxy/acme_resolvers-t.h>
 #include <haproxy/istbuf.h>
+#include <haproxy/buf-t.h>
 #include <haproxy/openssl-compat.h>
 
 #if defined(HAVE_ACME)
@@ -40,6 +41,12 @@ struct acme_cfg {
                int bits;                   /* bits for RSA */
                int curves;                 /* NID of curves */
        } key;
+       struct {
+               char *kid_file;        /* EAB key id filename */
+               char *mac_key_file;    /* base64url encoded EAB hmac key filename */
+               char *kid;             /* EAB key id */
+               struct buffer mac_key; /* raw EAB hmac key */
+       } eab;
        char *challenge;            /* HTTP-01, DNS-01, etc */
        char *profile;              /* ACME profile */
        char *vars;                 /* variables put in the dpapi sink */
index 46d5ca90ac87a5c9687421058b0eea8c26cfe516..289c67343d31b6e6c33bd4597d890e66cae88bb1 100644 (file)
@@ -418,6 +418,38 @@ static int cfg_parse_acme_kws(char **args, int section_type, struct proxy *curpx
                        ha_alert("parsing [%s:%d]: out of memory.\n", file, linenum);
                        goto out;
                }
+       } else if (strcmp(args[0], "eab-key-id") == 0) {
+               if (!*args[1]) {
+                       ha_alert("parsing [%s:%d]: keyword '%s' in '%s' section requires an argument\n", file, linenum, args[0], cursection);
+                       err_code |= ERR_ALERT | ERR_FATAL;
+                       goto out;
+               }
+               if (alertif_too_many_args(1, file, linenum, args, &err_code))
+                       goto out;
+
+               ha_free(&cur_acme->eab.kid_file);
+               cur_acme->eab.kid_file = strdup(args[1]);
+               if (!cur_acme->eab.kid_file) {
+                       err_code |= ERR_ALERT | ERR_FATAL;
+                       ha_alert("parsing [%s:%d]: out of memory.\n", file, linenum);
+                       goto out;
+               }
+       } else if (strcmp(args[0], "eab-mac-key") == 0) {
+               if (!*args[1]) {
+                       ha_alert("parsing [%s:%d]: keyword '%s' in '%s' section requires an argument\n", file, linenum, args[0], cursection);
+                       err_code |= ERR_ALERT | ERR_FATAL;
+                       goto out;
+               }
+               if (alertif_too_many_args(1, file, linenum, args, &err_code))
+                       goto out;
+
+               ha_free(&cur_acme->eab.mac_key_file);
+               cur_acme->eab.mac_key_file = strdup(args[1]);
+               if (!cur_acme->eab.mac_key_file) {
+                       err_code |= ERR_ALERT | ERR_FATAL;
+                       ha_alert("parsing [%s:%d]: out of memory.\n", file, linenum);
+                       goto out;
+               }
        } else if (strcmp(args[0], "challenge") == 0) {
                if ((!*args[1]) ||
                    ((strcasecmp("http-01", args[1]) != 0) &&
@@ -814,6 +846,75 @@ static int cfg_postsection_acme()
                }
        }
 
+       if (cur_acme->eab.kid_file != NULL && cur_acme->eab.mac_key_file != NULL) {
+               int rv = 0;
+               rv = read_line_to_trash("%s", cur_acme->eab.kid_file);
+               if (rv >= 1) {
+                       /* if read at least one character successfully */
+                       const char *p;
+
+                       cur_acme->eab.kid = my_strndup(trash.area, trash.data);
+                       if (!cur_acme->eab.kid) {
+                               ha_alert("acme: out of memory.\n");
+                               err_code |= ERR_ALERT | ERR_FATAL | ERR_ABORT;
+                               goto out;
+                       }
+
+                       /* technically ACME RFC allows any ASCII string here,
+                        * but in practice CAs usually provide key id as a base64url encoded secret or an UUID
+                        * this warning may need to be adjusted in the future */
+                       for (p = cur_acme->eab.kid; *p; p++) {
+                               if (!isalnum((uchar)*p) && *p != '-' && *p != '_') {
+                                       ha_warning("acme: section '%s': EAB key id contains strange character '%c'.\n",  cur_acme->name, *p);
+                                       break; /* no need to print this warning many times */
+                               }
+                       }
+               } else if (rv == 0) {
+                       /* empty files are allowed, but issue a log message */
+                       ha_notice("acme: section '%s': EAB key id from '%s' is empty.\n", cur_acme->name, cur_acme->eab.kid_file);
+               } else {
+                       ha_alert("acme: section '%s': couldn't load EAB key id from '%s', code %d.\n", cur_acme->name, cur_acme->eab.kid_file, rv);
+                       err_code |= ERR_ALERT | ERR_FATAL | ERR_ABORT;
+                       goto out;
+               }
+
+               rv = read_line_to_trash("%s", cur_acme->eab.mac_key_file);
+               if (rv >= 1) {
+                       struct buffer *dec_mac = get_trash_chunk();
+                       int bytes = 0;
+
+                       bytes = base64urldec(trash.area, trash.data, dec_mac->area, dec_mac->size);
+                       if (bytes < 0) {
+                               ha_alert("acme: section '%s': failed to base64url decode EAB MAC key.\n", cur_acme->name);
+                               err_code |= ERR_ALERT | ERR_FATAL | ERR_ABORT;
+                               goto out;
+                       }
+                       dec_mac->data = bytes;
+
+                       if (bytes < 32) {
+                               ha_alert("acme: section '%s': EAB MAC key from '%s' is only %d bytes long, but at least 32 bytes is required for the specified MAC type.\n",
+                                    cur_acme->name, cur_acme->eab.kid_file, bytes);
+                               err_code |= ERR_ALERT | ERR_FATAL | ERR_ABORT;
+                               goto out;
+                       }
+
+                       if (chunk_dup(&cur_acme->eab.mac_key, dec_mac) == NULL) {
+                               ha_alert("acme: out of memory.\n");
+                               err_code |= ERR_ALERT | ERR_FATAL | ERR_ABORT;
+                               goto out;
+                       }
+               } else if (rv == 0) {
+                       ha_notice("acme: section '%s': EAB MAC key from '%s' is empty.\n", cur_acme->name, cur_acme->eab.mac_key_file);
+               } else {
+                       ha_alert("acme: section '%s': couldn't load EAB MAC key from '%s', code %d.\n", cur_acme->name, cur_acme->eab.mac_key_file, rv);
+                       err_code |= ERR_ALERT | ERR_FATAL | ERR_ABORT;
+                       goto out;
+               }
+       } else if ((cur_acme->eab.kid_file == NULL) != (cur_acme->eab.mac_key_file == NULL)) {
+               ha_alert("acme: section '%s': EAB MAC key and key id are mutually dependent, specify both or neither.\n", cur_acme->name);
+               err_code |= ERR_ALERT | ERR_FATAL | ERR_ABORT;
+               goto out;
+       }
 
        if (global_ssl.crt_base && *cur_acme->account.file != '/') {
                int rv;
@@ -988,6 +1089,10 @@ void deinit_acme()
                ha_free(&acme_cfgs->challenge);
                ha_free(&acme_cfgs->map);
                ha_free(&acme_cfgs->profile);
+               ha_free(&acme_cfgs->eab.kid_file);
+               ha_free(&acme_cfgs->eab.mac_key_file);
+               chunk_destroy(&acme_cfgs->eab.mac_key);
+               ha_free(&acme_cfgs->eab.kid);
 
                free(acme_cfgs);
                acme_cfgs = next;
@@ -1010,6 +1115,8 @@ static struct cfg_kw_list cfg_kws_acme = {ILH, {
        { CFG_ACME, "challenge-ready",  cfg_parse_acme_kws },
        { CFG_ACME, "dns-delay",  cfg_parse_acme_kws },
        { CFG_ACME, "dns-timeout",  cfg_parse_acme_kws },
+       { CFG_ACME, "eab-key-id",  cfg_parse_acme_kws },
+       { CFG_ACME, "eab-mac-key",  cfg_parse_acme_kws },
        { CFG_ACME, "acme-vars",  cfg_parse_acme_vars_provider },
        { CFG_ACME, "provider-name",  cfg_parse_acme_vars_provider },
        { CFG_GLOBAL, "acme.scheduler", cfg_parse_global_acme_sched },
@@ -1269,6 +1376,46 @@ error:
        return ret;
 }
 
+int acme_jws_eab_payload(struct ist url, EVP_PKEY *acc_key, struct buffer mac_key, char *kid, struct buffer *output, char **errmsg)
+{
+       struct buffer *b64payload = NULL;
+       struct buffer *b64prot = NULL;
+       struct buffer *b64sign = NULL;
+       struct buffer *jwk = NULL;
+       enum jwt_alg alg = JWS_ALG_HS256;
+       int ret = 1;
+
+       b64payload = alloc_trash_chunk();
+       b64prot = alloc_trash_chunk();
+       jwk = alloc_trash_chunk();
+       b64sign = alloc_trash_chunk();
+
+       if (!b64payload || !b64prot || !jwk || !b64sign || !output) {
+               memprintf(errmsg, "out of memory");
+               goto error;
+       }
+
+       jwk->data = EVP_PKEY_to_pub_jwk(acc_key, jwk->area, jwk->size);
+
+       b64payload->data = jws_b64_payload(jwk->area, b64payload->area, b64payload->size);
+       b64prot->data = jws_b64_protected(alg, kid, NULL, NULL, url.ptr, b64prot->area, b64prot->size);
+       b64sign->data = jws_b64_hmac_signature(mac_key.area, mac_key.data, alg, b64prot->area, b64payload->area, b64sign->area, b64sign->size);
+       output->data = jws_flattened(b64prot->area, b64payload->area, b64sign->area, output->area, output->size);
+
+       if (output->data == 0)
+               goto error;
+
+       ret = 0;
+
+error:
+       free_trash_chunk(b64payload);
+       free_trash_chunk(b64prot);
+       free_trash_chunk(jwk);
+       free_trash_chunk(b64sign);
+
+       return ret;
+}
+
 /*
  * Update every certificate instances for the new store
  *
@@ -2211,20 +2358,28 @@ int acme_req_account(struct task *task, struct acme_ctx *ctx, int newaccount, ch
 {
        struct buffer *req_in = NULL;
        struct buffer *req_out = NULL;
+       struct buffer *eab_req_out = NULL;
        const struct http_hdr hdrs[] = {
                { IST("Content-Type"), IST("application/jose+json") },
                { IST_NULL, IST_NULL }
        };
        int ret = 1;
 
-        if ((req_in = alloc_trash_chunk()) == NULL)
+       if ((req_in = alloc_trash_chunk()) == NULL)
                goto error;
-        if ((req_out = alloc_trash_chunk()) == NULL)
+       if ((req_out = alloc_trash_chunk()) == NULL)
+               goto error;
+       if ((eab_req_out = alloc_trash_chunk()) == NULL)
                goto error;
 
        if (newaccount) {
                chunk_appendf(req_in, "{");
-               if (ctx->cfg->account.contact != NULL)
+               if (ctx->cfg->eab.mac_key.data > 0 && ctx->cfg->eab.kid != NULL) {
+                       if (acme_jws_eab_payload(ctx->resources.newAccount, ctx->cfg->account.pkey, ctx->cfg->eab.mac_key, ctx->cfg->eab.kid, eab_req_out, errmsg) != 0)
+                               goto out;
+                       chunk_appendf(req_in, "\"externalAccountBinding\": %.*s,", (int)eab_req_out->data, eab_req_out->area);
+               }
+               if (ctx->cfg->account.contact)
                        chunk_appendf(req_in, "\"contact\": [ \"mailto:%s\" ],", ctx->cfg->account.contact);
                chunk_appendf(req_in, "\"termsOfServiceAgreed\": true");
                chunk_appendf(req_in, "}");
@@ -2246,6 +2401,7 @@ error:
 out:
        free_trash_chunk(req_in);
        free_trash_chunk(req_out);
+       free_trash_chunk(eab_req_out);
 
        return ret;
 }