This patch implements ACME EAB.
Patch introduces two new functions related to JWS handling:
jws_b64_hmac_signature in jws.c, and acme_jws_eab_payload in acme.c,
which are like jws_b64_signature and acme_jws_payload respectively.
They duplicate basically the same functionality but for the use case
of HMAC, OpenSSL allows to use EVP_PKEY for HMAC functionality, but
although isn't deprecated it was removed in BoringSSL, and was removed
(due to BoringSSL roots) but then readded back in AWS-LC, because of
"legacy clients" (citing them), for that reason alone I say that having
a dedicated function for hmac is better, HMAC() macro seems to be widely
supported unlike other ways of doing same thing.
Also touches and rewrites jws_b64_protected() to work for the use case
of EAB, in EAB JWS nonce must not be set.
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.
It is not clear whether providing a config for algorithms other than
HS256 makes any sense, standard ACME implementation of Go decided not to
for example. There are really no benefits, HS256 support is mandatory,
but not for others, so I decided to split that part into a separate
commit. Keep in mind that HS256 is only used for initial JWS signing
of initial account binding process, but not for anything else.
A thing about EAB is that it is /required only during account creation/
so it is unexpectedly complex to think about.
Also 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.
Other servers that implement ACME, for example stalwart-mail, Caddy,
allow to specify values directly in the config fields, but then
they have template directives that allow to expand contents of a file
into them.
In Caddy you can do one of the following:
acme_eab {
key_id {file./etc/caddy/eab.key_id}
mac_key {file./etc/caddy/eab.mac_key}
}
acme_eab {
key_id kid-1
mac_key HjudV5qnbreN-n9WyFSH
}
acme_eab {
key_id {env.ACME_EAB_KEY_ID}
mac_key {env.ACME_EAB_MAC_KEY}
}
HAProxy allows to expand environment variables, but not expand files
like that.
So I decided to implement a purely file based approach for now,
as it provides the most operational flexibility, and in particular works
well with systemd credentials.
Another way I though to implement it is by combining multiple creds
in a single file, for example using a format like: <id>:<key>
Or using a map file per acme section with contents as following:
id "kid-1"
key "whatever"
But the multi-file approach seemed the most compatible with existing
setups using placeholders. Although, ID and the Key do come in pairs,
so it makes not much sense to separate them logically speaking,
I haven't seen anyone else to do it like that in a single file,
unless it was a config file.
I decided to use options like this in an acme section:
eab-mac-alg HS512
eab-mac-key pebble.eab.key
eab-key-id pebble.eab.id
It could have been instead something like this too:
eab alg HS512 mac-key pebble.eab.key key-id pebble.eab.id
The reason I want with the first is just because it was easier to do :)
I also decided to not error out on empty files, but warn instead,
as credential existance is always checked for.
Used read_line_to_trash function from tools.c for reading files,
that is something that could be replaced by a dedicated function too.
There is no need to explicitly configure anything in the config,
If no eab-* keywords are used, filename is implicitly created based
on a section name, and then haproxy tries to load it. Whether it is a
good idea or not... I'm not sure.
There are a lot of decisions here that are slightly tricky,
even for this simple feature, if there are any ideas on how to change it
for the better, it should be done. I hope other HAProxy users will
guide the overall direction here.
No backport needed :)
---
doc/configuration.txt | 14 ++++
include/haproxy/acme-t.h | 7 ++
include/haproxy/jws.h | 1 +
src/acme.c | 163 +++++++++++++++++++++++++++++++++++++--
src/jws.c | 80 ++++++++++++++-----
5 files changed, 239 insertions(+), 26 deletions(-)
diff --git a/doc/configuration.txt b/doc/configuration.txt
index 8a673c29a..d2b91eed2 100644
--- a/doc/configuration.txt
+++ b/doc/configuration.txt
@@ -32638,6 +32638,20 @@ Example:
curves P-384
map virt@acme
+eab-mac-key <filename>
+eab-key-id <filename>
+ Configure the path to the EAB MAC key and EAB key id credential pair. You
+ should get credentials from you CA and place them at the specified path
+ before launching HAProxy. If not explicitly specified, HAProxy will try to
+ load a file based on an acme section name similar to "account-key" keyword,
+ "<name>.eab.id" for the key id, "<name>.eab.key" for the mac key, where
+ "<name>" is the name of the current acme section. If the file doesn't exist,
+ or is empty, HAProxy will ignore it. Whitespace in the file is NOT ignored.
+
+ EAB key id file should be a plain ASCII string that CA provides as an id.
+ EAB MAC key file should be a base64url encoded MAC key that CA provides.
+ EAB credentials are used only during the initial ACME account creation, and
+ can be removed afterwards.
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 <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 */
diff --git a/include/haproxy/jws.h b/include/haproxy/jws.h
index f68147cff..4f5fb8c40 100644
--- a/include/haproxy/jws.h
+++ b/include/haproxy/jws.h
@@ -11,6 +11,7 @@ size_t EVP_PKEY_to_pub_jwk(EVP_PKEY *pkey, char *dst, size_t
dsize);
enum jwt_alg EVP_PKEY_to_jws_alg(EVP_PKEY *pkey);
size_t jws_b64_payload(char *payload, char *dst, size_t dsize);
size_t jws_b64_protected(enum jwt_alg alg, char *kid, char *jwk, char *nonce,
char *url, char *dst, size_t dsize);
+size_t jws_b64_hmac_signature(char *key, size_t key_len, enum jwt_alg alg,
char *b64protected, char *b64payload, char *dst, size_t dsize);
size_t jws_b64_signature(EVP_PKEY *pkey, enum jwt_alg alg, char *b64protected,
char *b64payload, char *dst, size_t dsize);
size_t jws_flattened(char *protected, char *payload, char *signature, char
*dst, size_t dsize);
size_t jws_thumbprint(EVP_PKEY *pkey, char *dst, size_t dsize);
diff --git a/src/acme.c b/src/acme.c
index dc038bcab..e7f7d1a77 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) &&
@@ -799,21 +831,87 @@ static int cfg_postsection_acme()
char *errmsg = NULL;
char *path;
char store_path[PATH_MAX]; /* complete path with crt_base */
+ int rv = 0;
struct stat st;
/* if dns-persist-01 is set, add an extra INITIAL_DNS check */
if (strcasecmp(cur_acme->challenge, "dns-persist-01") == 0)
cur_acme->cond_ready |= ACME_RDY_INITIAL_DNS;
- /* if account key filename is unspecified, choose a filename for it */
- if (!cur_acme->account.file) {
- if (!memprintf(&cur_acme->account.file, "%s.account.key",
cur_acme->name)) {
+ /* if either account key, eab kid, or mac key filename is unspecified,
choose a filename for it */
+ if ((!cur_acme->account.file && !memprintf(&cur_acme->account.file,
"%s.account.key", cur_acme->name))
+ || (!cur_acme->eab.kid_file && !memprintf(&cur_acme->eab.kid_file,
"%s.eab.id", cur_acme->name))
+ || (!cur_acme->eab.mac_key_file &&
!memprintf(&cur_acme->eab.mac_key_file, "%s.eab.key", cur_acme->name))) {
+ err_code |= ERR_ALERT | ERR_FATAL | ERR_ABORT;
+ ha_alert("acme: out of memory.\n");
+ goto out;
+ }
+
+ if (global_ssl.crt_base && *cur_acme->eab.kid_file != '/')
+ rv = read_line_to_trash("%s/%s", global_ssl.crt_base,
cur_acme->eab.kid_file);
+ else
+ rv = read_line_to_trash("%s", cur_acme->eab.kid_file);
+
+ /* if read at least one character successfully */
+ if (rv >= 1) {
+ 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 */
+ 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 warning */
+ ha_warning("acme: section '%s': EAB key id from '%s' is
empty.\n", cur_acme->name, cur_acme->eab.kid_file);
+ }
+
+ if (global_ssl.crt_base && *cur_acme->eab.mac_key_file != '/')
+ rv = read_line_to_trash("%s/%s", global_ssl.crt_base,
cur_acme->eab.mac_key_file);
+ else
+ rv = read_line_to_trash("%s", cur_acme->eab.mac_key_file);
+
+ if (rv >= 1) {
+ struct buffer *dec_mac = get_trash_chunk();
+
+ rv = base64dec(trash.area, trash.data, dec_mac->area,
dec_mac->size);
+ if (rv < 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 = rv;
+
+ if (rv < 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, rv);
+ 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_warning("acme: section '%s': EAB mac key from '%s' is
empty.\n", cur_acme->name, cur_acme->eab.mac_key_file);
}
+ if ((cur_acme->eab.mac_key.data == 0) != (cur_acme->eab.kid == 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 +1086,8 @@ void deinit_acme()
ha_free(&acme_cfgs->challenge);
ha_free(&acme_cfgs->map);
ha_free(&acme_cfgs->profile);
+ chunk_destroy(&acme_cfgs->eab.mac_key);
+ ha_free(&acme_cfgs->eab.kid);
free(acme_cfgs);
acme_cfgs = next;
@@ -1010,6 +1110,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 +1371,46 @@ int acme_jws_payload(struct buffer *req, struct ist
nonce, struct ist url, EVP_P
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 +2353,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 +2396,7 @@ int acme_req_account(struct task *task, struct acme_ctx
*ctx, int newaccount, ch
out:
free_trash_chunk(req_in);
free_trash_chunk(req_out);
+ free_trash_chunk(eab_req_out);
return ret;
}
diff --git a/src/jws.c b/src/jws.c
index e4ea30de6..f8fb4738f 100644
--- a/src/jws.c
+++ b/src/jws.c
@@ -219,6 +219,7 @@ size_t EVP_PKEY_to_pub_jwk(EVP_PKEY *pkey, char *dst,
size_t dsize)
/*
* Generate the JWS payload and converts it to base64url.
* Use either <kid> or <jwk>, but won't use both
+ * <nonce> is optional.
*
* Return the size of the data or 0
*/
@@ -226,13 +227,14 @@ size_t EVP_PKEY_to_pub_jwk(EVP_PKEY *pkey, char *dst,
size_t dsize)
size_t jws_b64_protected(enum jwt_alg alg, char *kid, char *jwk, char *nonce,
char *url,
char *dst, size_t dsize)
{
- char *acc;
- char *acctype;
int ret = 0;
struct buffer *json = NULL;
const char *algstr;
switch (alg) {
+ case JWS_ALG_HS256: algstr = "HS256"; break;
+ case JWS_ALG_HS384: algstr = "HS384"; break;
+ case JWS_ALG_HS512: algstr = "HS512"; break;
case JWS_ALG_RS256: algstr = "RS256"; break;
case JWS_ALG_RS384: algstr = "RS384"; break;
case JWS_ALG_RS512: algstr = "RS512"; break;
@@ -246,24 +248,16 @@ size_t jws_b64_protected(enum jwt_alg alg, char *kid,
char *jwk, char *nonce, ch
if ((json = alloc_trash_chunk()) == NULL)
goto out;
- /* kid or jwk ? */
- acc = kid ? kid : jwk;
- acctype = kid ? "kid" : "jwk";
-
- ret = snprintf(json->area, json->size, "{\n"
- " \"alg\": \"%s\",\n"
- " \"%s\": %s%s%s,\n"
- " \"nonce\": \"%s\",\n"
- " \"url\": \"%s\"\n"
- "}\n",
- algstr, acctype, kid ? "\"" : "", acc, kid ? "\"" : "",
nonce, url);
- if (ret >= json->size) {
- ret = 0;
- goto out;
- }
-
-
- json->data = ret;
+ chunk_appendf(json, "{");
+ if (kid)
+ chunk_appendf(json, "\"kid\": \"%s\",", kid);
+ else
+ chunk_appendf(json, "\"jwk\": %s,", jwk);
+ if (nonce)
+ chunk_appendf(json, "\"nonce\": \"%s\",", nonce);
+ chunk_appendf(json, "\"alg\": \"%s\",", algstr);
+ chunk_appendf(json, "\"url\": \"%s\"", url);
+ chunk_appendf(json, "}");
ret = a2base64url(json->area, json->data, dst, dsize);
out:
@@ -458,6 +452,52 @@ size_t jws_b64_signature(EVP_PKEY *pkey, enum jwt_alg alg,
char *b64protected, c
return 0;
}
+
+/*
+ * Generate a JWS HMAC signature using the base64url protected buffer and the
base64url payload buffer
+ *
+ * Return the size of the data or 0
+ */
+size_t jws_b64_hmac_signature(char *key, size_t key_len, enum jwt_alg alg,
char *b64protected, char *b64payload, char *dst, size_t dsize)
+{
+ const EVP_MD *evp_alg = NULL;
+ int ret = 0;
+ unsigned char mac[EVP_MAX_MD_SIZE] = {};
+ unsigned int mac_len = 0;
+ struct buffer *sig_data = NULL;
+
+ if ((sig_data = alloc_trash_chunk()) == NULL)
+ goto out;
+
+ switch (alg) {
+ case JWS_ALG_HS256: evp_alg = EVP_sha256(); break;
+ case JWS_ALG_HS384: evp_alg = EVP_sha384(); break;
+ case JWS_ALG_HS512: evp_alg = EVP_sha512(); break;
+ default:
+ goto out;
+ }
+
+ if (!chunk_memcat(sig_data, b64protected, strlen(b64protected)) ||
+ !chunk_memcat(sig_data, ".", 1) ||
+ !chunk_memcat(sig_data, b64payload, strlen(b64payload)))
+ goto out;
+
+ if (HMAC(evp_alg, key, (int)key_len,
+ (unsigned char*)sig_data->area, sig_data->data,
+ mac, &mac_len) == NULL)
+ goto out;
+
+ ret = a2base64url((const char *)mac, mac_len, dst, dsize);
+
+out:
+ free_trash_chunk(sig_data);
+
+ if (ret > 0)
+ return ret;
+ return 0;
+}
+
+
/*
* Fill a <dst> buffer of <dsize> size with a jwk thumbprint from a pkey
*
--
2.53.0