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


Reply via email to