From 187b1250ddef9ca1b0ec08361df8849f6b90de73 Mon Sep 17 00:00:00 2001 From: Mia Kanashi Date: Thu, 7 May 2026 00:17:42 +0300 Subject: [PATCH] MINOR: acme: implement EAB - external account binding 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 | 27 +++++++ include/haproxy/acme-t.h | 7 ++ src/acme.c | 162 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 193 insertions(+), 3 deletions(-) diff --git a/doc/configuration.txt b/doc/configuration.txt index 3316db7c9..829d46ef3 100644 --- a/doc/configuration.txt +++ b/doc/configuration.txt @@ -32660,6 +32660,33 @@ Example: curves P-384 map virt@acme +eab-key-id + 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 + 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 ------------------ diff --git a/include/haproxy/acme-t.h b/include/haproxy/acme-t.h index 24df7c44a..87f3bfffa 100644 --- a/include/haproxy/acme-t.h +++ b/include/haproxy/acme-t.h @@ -4,6 +4,7 @@ #include #include +#include #include #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 */ diff --git a/src/acme.c b/src/acme.c index 46d5ca90a..289c67343 100644 --- a/src/acme.c +++ b/src/acme.c @@ -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; }