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