Implements draft DNS-PERSIST-01 challenge based on https://datatracker.ietf.org/doc/html/draft-ietf-acme-dns-persist
Blog post: https://letsencrypt.org/2026/02/18/dns-persist-01 This challenge is designed to use preprovisioned DNS records, unlike DNS-01 challenge it doesn't need per provider API integration. In short instead of validating order by crafting a custom response based on input recieved from ACME server, like other challenges do in particular DNS-01, HTTP-01, TLS-ALPN-01, in this challenge you authorize domain statically, ACME account key functions similar to a private key and accounturi in the record functions like a public key, ACME server verifies that account uri matches account key and authorizes based on that. You only need to write DNS record one time, accounturi binds to an account key, and will only change if new account key is created, although it is possible to rotate account key without changing account uri. Main benefits of this challenge in contrast to DNS-01: 1. Security, no need to give reverse proxy write access to the DNS. 2. Simplicity, no complex per provider integrations like Lego needed. 3. Robustness, no worrying about DNS record cache each renewal. It would be used like this: 1. generate an account key ahead of time 2. add required DNS record manually or automatically using IaC tools 3. start HAProxy with the same account key used Intended way to use this challenge is with a code that will print and maybe sets DNS records ahead of time. For example that could be integrated into the IaC provisioning step. This challenge type is extremely recent though, so those integrations are yet to be written. It is possible to do this challenge without extra tools too, with pebble / challtestsrv steps would be as following: After starting HAProxy it will print required records in the logs. With challtestsrv you can then set those records like this: curl -d '{ "host":"_validation-persist.localhost.", "value": "pebble.letsencrypt.org; accounturi=...; policy=wildcard"} ' http://localhost:8055/set-txt After setting the records run renew with the name of the certificate: echo "acme renew @cert/localhost.pem" \ | socat stdio tcp4-connect:127.0.0.1:9999 Or just restart HAProxy. Unlike with DNS-01 you don't have to worry about DNS records changing, if there is any problem with DNS records you can just retry. --- src/acme.c | 91 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 76 insertions(+), 15 deletions(-) diff --git a/src/acme.c b/src/acme.c index c3611a199..b7281954f 100644 --- a/src/acme.c +++ b/src/acme.c @@ -2,6 +2,8 @@ /* * Implements the ACMEv2 RFC 8555 protocol + * Implements the following extensions to the protocol: + * draft-ietf-acme-dns-persist - DNS-PERSIST-01 challenge */ #include <stddef.h> @@ -404,8 +406,11 @@ static int cfg_parse_acme_kws(char **args, int section_type, struct proxy *curpx goto out; } } else if (strcmp(args[0], "challenge") == 0) { - if ((!*args[1]) || (strcasecmp("http-01", args[1]) != 0 && (strcasecmp("dns-01", args[1]) != 0))) { - ha_alert("parsing [%s:%d]: keyword '%s' in '%s' section requires a challenge type: http-01 or dns-01\n", file, linenum, args[0], cursection); + if ((!*args[1]) || + ((strcasecmp("http-01", args[1]) != 0) && + (strcasecmp("dns-01", args[1]) != 0) && + (strcasecmp("dns-persist-01", args[1]) != 0))) { + ha_alert("parsing [%s:%d]: keyword '%s' in '%s' must be one of the following: http-01, dns-01, dns-persist-01\n", file, linenum, args[0], cursection); err_code |= ERR_ALERT | ERR_FATAL; goto out; } @@ -1599,6 +1604,7 @@ int acme_res_auth(struct task *task, struct acme_ctx *ctx, struct acme_auth *aut struct buffer *t1 = NULL, *t2 = NULL; int ret = 1; int i; + int wildcard = 0; hc = ctx->hc; if (!hc) @@ -1691,20 +1697,69 @@ int acme_res_auth(struct task *task, struct acme_ctx *ctx, struct acme_auth *aut goto error; } - ret = mjson_get_string(tokptr, toklen, "$.token", trash.area, trash.size); - if (ret == -1) { - memprintf(errmsg, "couldn't get a token in challenges[%d] from Authorization URL \"%s\"", i, auth->auth.ptr); - goto error; - } - trash.data = ret; - auth->token = istdup(ist2(trash.area, trash.data)); - if (!isttest(auth->token)) { - memprintf(errmsg, "out of memory"); - goto error; + if (strcasecmp(ctx->cfg->challenge, "dns-persist-01") != 0) { + ret = mjson_get_string(tokptr, toklen, "$.token", trash.area, trash.size); + if (ret == -1) { + memprintf(errmsg, "couldn't get a token in challenges[%d] from Authorization URL \"%s\"", i, auth->auth.ptr); + goto error; + } + trash.data = ret; + auth->token = istdup(ist2(trash.area, trash.data)); + if (!isttest(auth->token)) { + memprintf(errmsg, "out of memory"); + goto error; + } } - /* compute a response for the TXT entry */ - if (strcasecmp(ctx->cfg->challenge, "dns-01") == 0) { + if (strcasecmp(ctx->cfg->challenge, "dns-persist-01") == 0) { + /* Clients MUST consider a challenge malformed if the issuer-domain-names array is empty + or if it contains more than 10 entries, and MUST reject such challenges. + https://datatracker.ietf.org/doc/html/draft-ietf-acme-dns-persist#section-3.1-2.4.4 + */ + + struct buffer *record_values = NULL; + int n = 0; + + record_values = get_trash_chunk(); + + for (n = 0; ; n++) { + char dom_path[] = "$.issuer-domain-names[XXX]"; + + if (snprintf(dom_path, sizeof(dom_path), "$.issuer-domain-names[%d]", n) >= sizeof(dom_path)) + goto error; + + /* break the loop at the end of the list */ + if (mjson_find(tokptr, toklen, dom_path, NULL, NULL) == MJSON_TOK_INVALID) + break; + + if (n >= 10) { + memprintf(errmsg, "more then 10 entries in acme issuer-domain-names"); + goto error; + } + + ret = mjson_get_string(tokptr, toklen, dom_path, trash.area, trash.size); + if (ret == -1) { + memprintf(errmsg, "found values other than strings in acme issuer-domain-names"); + goto error; + } + trash.data = ret; + + /* collect allowed domain names for better reporting */ + chunk_appendf(record_values, "%s\"%.*s; accounturi=%.*s%s\"", n == 0 ? "" : " OR ", + (int)trash.data, trash.area, (int)ctx->kid.len, ctx->kid.ptr, + wildcard ? "; policy=wildcard" : ""); + } + + if (n == 0) { + memprintf(errmsg, "0 entries in acme issuer-domain-names"); + goto error; + } + + /* TODO: currently this can log more records than required when wildcards are involved */ + send_log(NULL, LOG_INFO, "acme: %s: dns-persist-01 requires to set the \"_validation-persist.%.*s\" TXT record to %.*s\n", + ctx->store->path, (int)auth->dns.len, auth->dns.ptr, (int)record_values->data, record_values->area); + } + else if (strcasecmp(ctx->cfg->challenge, "dns-01") == 0) { struct sink *dpapi; struct ist line[16]; int nmsg = 0; @@ -1712,6 +1767,7 @@ int acme_res_auth(struct task *task, struct acme_ctx *ctx, struct acme_auth *aut dns_record = get_trash_chunk(); + /* compute a response for the TXT entry */ if (acme_txt_record(ist(ctx->cfg->account.thumbprint), auth->token, dns_record) == 0) { memprintf(errmsg, "couldn't compute the dns-01 challenge"); goto error; @@ -1749,13 +1805,18 @@ int acme_res_auth(struct task *task, struct acme_ctx *ctx, struct acme_auth *aut dpapi = sink_find("dpapi"); if (dpapi) sink_write(dpapi, LOG_HEADER_NONE, 0, line, nmsg); - } else { + } + else if (strcasecmp(ctx->cfg->challenge, "http-01") == 0) { /* only useful for http-01 */ if (acme_add_challenge_map(ctx->cfg->map, auth->token.ptr, ctx->cfg->account.thumbprint, errmsg) != 0) { memprintf(errmsg, "couldn't add the token to the '%s' map: %s", ctx->cfg->map, *errmsg); goto error; } } + else { + memprintf(errmsg, "impossible acme challenge: %s", ctx->cfg->challenge); + goto error; + } /* we only need one challenge, and iteration is only used to found the right one */ break; -- 2.52.0

