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
This commit is contained in:
Mia Kanashi 2026-05-07 00:17:42 +03:00 committed by William Lallemand
parent c9e76e5bb1
commit 187b1250dd
3 changed files with 193 additions and 3 deletions

View 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
------------------

View 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 */

View 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;
}