Index | Thread | Search

From:
Christopher Zimmermann <chrisz@openbsd.org>
Subject:
acme-client: add challenge hook to support dns-01
To:
Kristaps Dzonsons <kristaps@bsd.lv>
Cc:
ports@openbsd.org
Date:
Tue, 20 Feb 2024 22:32:11 +0100

Download raw body.

Thread
Hi,

this diff adds a challenge hook to acme-client. This hook can be used to 
fulfill challenges. For example by putting the requested files onto a 
remote http server (http-01 challenge) or by modifying dns records 
(dns-01 challenge). The latter are needed to obtain wildcard 
certificates.
Is this diff ok? Is the design of the hook interface sane? Any feedback 
is welcome.


Christopher
Index: etc/examples/acme-client.conf
===================================================================
RCS file: /cvs/src/etc/examples/acme-client.conf,v
retrieving revision 1.5
diff -u -p -r1.5 acme-client.conf
--- etc/examples/acme-client.conf	10 May 2023 07:34:57 -0000	1.5
+++ etc/examples/acme-client.conf	20 Feb 2024 21:20:26 -0000
@@ -25,6 +25,12 @@ authority buypass-test {
 
 domain example.com {
 	alternative names { secure.example.com }
+	# For wildcard certificates dns-01 challenges need
+	# to be handled by a hook script.
+	# An example script can be found in /etc/examples/acme-hook.sh
+	#alternative names { *.example.com }
+	#challengehook "/etc/acme/acme-hook.sh"
+	#delay 310
 	domain key "/etc/ssl/private/example.com.key"
 	domain full chain certificate "/etc/ssl/example.com.fullchain.pem"
 	# Test with the staging server to avoid aggressive rate-limiting.
Index: etc/examples/acme-hook.sh
===================================================================
RCS file: etc/examples/acme-hook.sh
diff -N etc/examples/acme-hook.sh
--- /dev/null	1 Jan 1970 00:00:00 -0000
+++ etc/examples/acme-hook.sh	20 Feb 2024 21:20:26 -0000
@@ -0,0 +1,40 @@
+#!/bin/ksh
+#
+# $OpenBSD: $
+#
+
+password=XXXXXXXX
+
+update() {
+  doas -u nobody curl -K - <<- END
+	no-progress-meter
+	retry		= 7
+	retry-connrefused
+	retry-delay	= 20
+	max-time	= 5
+	url		= dyn.dns.he.net
+	user		= $1:$password
+	data		= txt=$2
+	END
+}
+
+while read type domain token thumb _reserved
+do
+  if [ "$type" = "dns-01" ]
+  then
+    txt=`echo -n "$token.$thumb" |sha256 -b |tr '+/' '-_' |tr -d '='`
+    domain="_acme-challenge.$domain"
+    result=`update "$domain" "$txt"`
+    echo "$0: Setting $domain to $txt: $result" >&2
+    reset[${#reset[@]}]="$domain"
+    echo "HANDLED"
+  else
+    echo "UNHANDLED"
+  fi
+done
+
+for domain in "${reset[@]}"
+do
+  result=`update "$domain" "X"`
+  echo "$0: Resetting $domain: $result" >&2
+done
Index: usr.sbin/acme-client/acme-client.conf.5
===================================================================
RCS file: /cvs/src/usr.sbin/acme-client/acme-client.conf.5,v
retrieving revision 1.29
diff -u -p -r1.29 acme-client.conf.5
--- usr.sbin/acme-client/acme-client.conf.5	11 Jan 2021 07:23:42 -0000	1.29
+++ usr.sbin/acme-client/acme-client.conf.5	20 Feb 2024 21:20:26 -0000
@@ -193,10 +193,63 @@ The certificate authority (as declared a
 section) to use.
 If this setting is absent, the first authority specified is used.
 .It Ic challengedir Ar path
-The directory in which the challenge file will be stored.
+The directory in which the challenge file for
+.Dv http-01
+challenges will be stored if
+.Ar challengehook
+did not handle them.
 If it is not specified, a default of
 .Pa /var/www/acme
 will be used.
+.It Ic challengehook Ar command
+.Ar command
+receives challenges, one per line, on its
+.Va stdin
+and responds on
+.Va stdout
+with
+.Dv HANDLED
+when the challenge was handled or
+.Dv UNHANDLED
+when the challenge was not accepted.
+.Ic challengehook
+is meant primarily for
+.Dv dns-01
+challeges, but can be used handle other types of challenges, too.
+.Pp
+The challenges are presented on stdin in this format:
+
+.Ar type
+.Ar identifier
+.Ar token
+.Ar thumb
+.Ar reserved
+
+The most interesting
+.Ar type
+is
+.Dv dns-01.
+.Ar identifier
+is the domain name to which a _acme-challenge. TXT subdomain record
+needs to be installed.
+.Ar token
+and
+.Ar thumb
+are the token and thumb which are needed to construct the TXT record.
+.Ar reserved
+is reserved for future use.
+
+An example hook script can be found in
+.Pa /etc/examples/acme-hook.sh
+
+.It Ic delay Ar seconds
+After challenges are handled, delay for
+.Ar seconds
+before asking the
+.Ar authority
+to check challenges. A generous delay may be needed to wait for changes
+to DNS to propagate to all servers checked by the
+.Ar authority.
 .El
 .Sh FILES
 .Bl -tag -width /etc/examples/acme-client.conf -compact
Index: usr.sbin/acme-client/chngproc.c
===================================================================
RCS file: /cvs/src/usr.sbin/acme-client/chngproc.c,v
retrieving revision 1.17
diff -u -p -r1.17 chngproc.c
--- usr.sbin/acme-client/chngproc.c	5 May 2022 19:51:35 -0000	1.17
+++ usr.sbin/acme-client/chngproc.c	20 Feb 2024 21:20:26 -0000
@@ -24,20 +24,63 @@
 #include <stdlib.h>
 #include <string.h>
 #include <unistd.h>
+#include <sys/socket.h>
 
 #include "extern.h"
 
 int
-chngproc(int netsock, const char *root)
+chngproc(int netsock, const char *root, const char *hook)
 {
 	char		 *tok = NULL, *th = NULL, *fmt = NULL, **fs = NULL;
+	char		 *id = NULL, *type = NULL;
 	size_t		  i, fsz = 0;
 	int		  rc = 0, fd = -1, cc;
+	int		  hook_fds[2], hook_pid;
+	char		  buf[16];
 	long		  lval;
 	enum chngop	  op;
 	void		 *pp;
 
 
+	if (hook != NULL) {
+		if (socketpair(AF_UNIX, SOCK_STREAM, 0, hook_fds) == -1) {
+			warn("socketpair");
+			goto out;
+		}
+
+		if ((hook_pid = fork()) == -1) {
+			warn("fork");
+			goto out;
+		}
+
+		if (hook_pid == 0) {
+			char		*hook_buf;
+			char		*argv[32];
+			const char	*ifs = " \t\n";
+
+			close(hook_fds[0]);
+			if (dup2(hook_fds[1], STDIN_FILENO) != STDIN_FILENO ||
+			    dup2(hook_fds[1], STDOUT_FILENO) != STDOUT_FILENO) {
+				warn("dup");
+				goto out;
+			}
+
+			hook_buf = strdup(hook);
+			i = 0;
+			argv[i] = strtok(hook_buf, ifs);
+			while (argv[i] != NULL &&
+			    i + 1 < sizeof(argv) / sizeof(argv[0]))
+				argv[++i] = strtok(NULL, ifs);
+
+			if (i == 0 || argv[i] != NULL)
+				errx(1, "Empty challengehook or too many arguments");
+
+			execv(argv[0], argv);
+			err(1, "execv failed");
+		}
+		close(hook_fds[1]);
+	}
+
 	if (unveil(root, "wc") == -1) {
 		warn("unveil %s", root);
 		goto out;
@@ -60,7 +103,7 @@ chngproc(int netsock, const char *root)
 		else if (lval == CHNG_SYN)
 			op = lval;
 
-		if (op == CHNG__MAX) {
+		if (op >= CHNG__MAX) {
 			warnx("unknown operation from netproc");
 			goto out;
 		} else if (op == CHNG_STOP)
@@ -74,11 +117,15 @@ chngproc(int netsock, const char *root)
 		 * of tokens that we'll later clean up.
 		 */
 
+		if ((id = readstr(netsock, COMM_ID)) == NULL)
+			goto out;
+		if ((type = readstr(netsock, COMM_TYPE)) == NULL)
+			goto out;
 		if ((th = readstr(netsock, COMM_THUMB)) == NULL)
 			goto out;
-		else if ((tok = readstr(netsock, COMM_TOK)) == NULL)
+		if ((tok = readstr(netsock, COMM_TOK)) == NULL)
 			goto out;
-		else if (strlen(tok) < 1) {
+		if (strlen(tok) < 1) {
 			warnx("token is too short");
 			goto out;
 		}
@@ -91,67 +138,103 @@ chngproc(int netsock, const char *root)
 			}
 		}
 
-		if (asprintf(&fmt, "%s.%s", tok, th) == -1) {
-			warn("asprintf");
-			goto out;
-		}
+		if (hook != NULL) {
+			write(hook_fds[0], type, strlen(type));
+			write(hook_fds[0], "\t", 1);
+			write(hook_fds[0], id, strlen(id));
+			write(hook_fds[0], "\t", 1);
+			write(hook_fds[0], tok, strlen(tok));
+			write(hook_fds[0], "\t", 1);
+			write(hook_fds[0], th, strlen(th));
+			write(hook_fds[0], "\t", 1);
+			write(hook_fds[0], "reserved\n", 9);
+			cc = read(hook_fds[0], buf, sizeof(buf));
+			if (cc <= 0)
+				err(1, "reading from challengehook failed");
+		}
+		else
+			strcpy(buf, "UNHANDLED");
+
+		if (strncmp(buf, "HANDLED", 7) == 0) {
+			op = CHNG_ACK;
+		}
+		else if (strncmp(buf, "UNHANDLED", 9) == 0
+		    && strcmp(type, "http-01") == 0) {
+			/* Vector appending... */
+
+			pp = reallocarray(fs, (fsz + 1), sizeof(char *));
+			if (pp == NULL) {
+				warn("realloc");
+				goto out;
+			}
+			fs = pp;
+			if (asprintf(&fs[fsz], "%s/%s", root, tok) == -1) {
+				warn("asprintf");
+				goto out;
+			}
+			fsz++;
 
-		/* Vector appending... */
+			/*
+			 * Create and write to our challenge file.
+			 * Note: we use file descriptors instead of FILE
+			 * because we want to minimise our pledges.
+			 */
+			fd = open(fs[fsz - 1], O_WRONLY|O_CREAT|O_TRUNC, 0444);
+			if (fd == -1) {
+				warn("%s", fs[fsz - 1]);
+				goto out;
+			}
+			if (asprintf(&fmt, "%s.%s", tok, th) == -1) {
+				warn("asprintf");
+				goto out;
+			}
+			if (write(fd, fmt, strlen(fmt)) == -1) {
+				warn("%s", fs[fsz - 1]);
+				goto out;
+			}
+			free(fmt);
+			if (close(fd) == -1) {
+				warn("%s", fs[fsz - 1]);
+				goto out;
+			}
+			fd = -1;
 
-		pp = reallocarray(fs, (fsz + 1), sizeof(char *));
-		if (pp == NULL) {
-			warn("realloc");
-			goto out;
-		}
-		fs = pp;
-		if (asprintf(&fs[fsz], "%s/%s", root, tok) == -1) {
-			warn("asprintf");
-			goto out;
-		}
-		fsz++;
-		free(tok);
-		tok = NULL;
+			dodbg("%s: created", fs[fsz - 1]);
+			op = CHNG_ACK;
 
-		/*
-		 * Create and write to our challenge file.
-		 * Note: we use file descriptors instead of FILE
-		 * because we want to minimise our pledges.
-		 */
-		fd = open(fs[fsz - 1], O_WRONLY|O_CREAT|O_TRUNC, 0444);
-		if (fd == -1) {
-			warn("%s", fs[fsz - 1]);
-			goto out;
 		}
-		if (write(fd, fmt, strlen(fmt)) == -1) {
-			warn("%s", fs[fsz - 1]);
-			goto out;
+		else if (strncmp(buf, "UNHANDLED", 4) == 0) {
+			op = CHNG_FAIL;
 		}
-		if (close(fd) == -1) {
-			warn("%s", fs[fsz - 1]);
-			goto out;
+		else {
+			warnx("got unknown reply from hook: <%.*s>\n", cc, buf);
+			op = CHNG_FAIL;
 		}
-		fd = -1;
-
-		free(th);
-		free(fmt);
-		th = fmt = NULL;
-
-		dodbg("%s: created", fs[fsz - 1]);
 
-		/*
-		 * Write our acknowledgement.
-		 * Ignore reader failure.
-		 */
-
-		cc = writeop(netsock, COMM_CHNG_ACK, CHNG_ACK);
-		if (cc == 0)
+		if (writeop(netsock, COMM_CHNG_ACK, op) <= 0)
 			break;
-		if (cc < 0)
-			goto out;
+
+		free(type);
+		free(id);
+		free(th);
+		free(tok);
+		type = id = th = tok = fmt = NULL;
 	}
 
 	rc = 1;
+	
 out:
+	if (hook != NULL) {
+		if (shutdown(hook_fds[0], SHUT_WR))
+			err(1, "shutdown challengehook failed");
+		cc = read(hook_fds[0], buf, sizeof(buf));
+		if (cc == 0) ; /* EOF */
+		else if (cc < 0)
+			warn("reading from challengehook failed");
+		else if (cc > 0)
+			warn("unexpected read from challengehook");
+		close(hook_fds[0]);
+	}
 	close(netsock);
 	if (fd != -1)
 		close(fd);
@@ -160,9 +243,18 @@ out:
 			warn("%s", fs[i]);
 		free(fs[i]);
 	}
-	free(fs);
-	free(fmt);
-	free(th);
-	free(tok);
+	if(type)
+		free(type);
+	if(id)
+		free(id);
+	if(fs)
+		free(fs);
+	if(fmt)
+		free(fmt);
+	if(th)
+		free(th);
+	if(tok)
+		free(tok);
+
 	return rc;
 }
Index: usr.sbin/acme-client/extern.h
===================================================================
RCS file: /cvs/src/usr.sbin/acme-client/extern.h,v
retrieving revision 1.20
diff -u -p -r1.20 extern.h
--- usr.sbin/acme-client/extern.h	14 Sep 2020 16:00:17 -0000	1.20
+++ usr.sbin/acme-client/extern.h	20 Feb 2024 21:20:26 -0000
@@ -44,6 +44,7 @@ enum	chngop {
 	CHNG_STOP = 0,
 	CHNG_SYN,
 	CHNG_ACK,
+	CHNG_FAIL,
 	CHNG__MAX
 };
 
@@ -116,6 +117,8 @@ enum	comp {
 enum	comm {
 	COMM_REQ,
 	COMM_THUMB,
+	COMM_ID,
+	COMM_TYPE,
 	COMM_CERT,
 	COMM_PAY,
 	COMM_NONCE,
@@ -157,6 +160,9 @@ enum	chngstatus {
 };
 
 struct	chng {
+	STAILQ_ENTRY(chng) next;
+	char		*type; /* type of challenge */
+	char		*identifier; /* domain to be authenticated */
 	char		*uri; /* uri on ACME server */
 	char		*token; /* token we must offer */
 	char		*error; /* "detail" field in case of error */
@@ -164,6 +170,8 @@ struct	chng {
 	enum chngstatus	 status; /* challenge accepted? */
 };
 
+STAILQ_HEAD(chng_queue, chng);
+
 enum	orderstatus {
 	ORDER_INVALID = -1,
 	ORDER_PENDING = 0,
@@ -202,7 +210,7 @@ __BEGIN_DECLS
  */
 int		 acctproc(int, const char *, enum keytype);
 int		 certproc(int, int);
-int		 chngproc(int, const char *);
+int		 chngproc(int, const char *, const char *);
 int		 dnsproc(int);
 int		 revokeproc(int, const char *, int, int, const char *const *,
 			size_t);
@@ -211,7 +219,7 @@ int		 fileproc(int, const char *, const 
 int		 keyproc(int, const char *, const char **, size_t,
 			enum keytype);
 int		 netproc(int, int, int, int, int, int, int,
-			struct authority_c *, const char *const *,
+			struct authority_c *, int, const char *const *,
 			size_t);
 
 /*
@@ -253,7 +261,7 @@ struct jsmnn	*json_parse(const char *, s
 void		 json_free(struct jsmnn *);
 int		 json_parse_response(struct jsmnn *);
 void		 json_free_challenge(struct chng *);
-int		 json_parse_challenge(struct jsmnn *, struct chng *);
+struct chng_queue json_parse_challenge(struct jsmnn *);
 void		 json_free_order(struct order *);
 int		 json_parse_order(struct jsmnn *, struct order *);
 int		 json_parse_upd_order(struct jsmnn *, struct order *);
Index: usr.sbin/acme-client/json.c
===================================================================
RCS file: /cvs/src/usr.sbin/acme-client/json.c,v
retrieving revision 1.21
diff -u -p -r1.21 json.c
--- usr.sbin/acme-client/json.c	14 Sep 2020 16:00:17 -0000	1.21
+++ usr.sbin/acme-client/json.c	20 Feb 2024 21:20:26 -0000
@@ -22,6 +22,7 @@
 #include <stdlib.h>
 #include <string.h>
 #include <unistd.h>
+#include <sys/queue.h>
 
 #include "jsmn.h"
 #include "extern.h"
@@ -254,9 +255,8 @@ json_getarray(struct jsmnn *n, const cha
 		if (n->d.obj[i].lhs->type != JSMN_STRING &&
 		    n->d.obj[i].lhs->type != JSMN_PRIMITIVE)
 			continue;
-		else if (strcmp(name, n->d.obj[i].lhs->d.str))
-			continue;
-		break;
+		if (strcmp(name, n->d.obj[i].lhs->d.str) == 0)
+			break;
 	}
 	if (i == n->fields)
 		return NULL;
@@ -331,10 +331,12 @@ json_getstr(struct jsmnn *n, const char 
 void
 json_free_challenge(struct chng *p)
 {
-
+	free(p->type);
+	free(p->identifier);
 	free(p->uri);
 	free(p->token);
-	p->uri = p->token = NULL;
+	free(p->error);
+	free(p);
 }
 
 /*
@@ -370,43 +372,64 @@ json_parse_response(struct jsmnn *n)
  * information, into a structure.
  * We only care about the HTTP-01 response.
  */
-int
-json_parse_challenge(struct jsmnn *n, struct chng *p)
+struct chng_queue
+json_parse_challenge(struct jsmnn *n)
 {
-	struct jsmnn	*array, *obj, *error;
+	struct jsmnn	*array, *obj, *identifier, *error;
+	struct chng_queue chngs = STAILQ_HEAD_INITIALIZER(chngs);
+	struct chng	*p;
 	size_t		 i;
-	int		 rc;
-	char		*type;
 
 	if (n == NULL)
-		return 0;
+		return chngs;
+
+	identifier = json_getobj(n, "identifier");
+	if (identifier == NULL)
+		return chngs;
 
 	array = json_getarray(n, "challenges");
 	if (array == NULL)
-		return 0;
+		return chngs;
 
 	for (i = 0; i < array->fields; i++) {
 		obj = json_getarrayobj(array->d.array[i]);
 		if (obj == NULL)
 			continue;
-		type = json_getstr(obj, "type");
-		if (type == NULL)
-			continue;
-		rc = strcmp(type, "http-01");
-		free(type);
-		if (rc)
-			continue;
+		p = malloc(sizeof(struct chng));
+		if (p == NULL) {
+			warn("malloc");
+			goto fail;
+		}
+		p->identifier = json_getstr(identifier, "value");
+		p->type = json_getstr(obj, "type");
 		p->uri = json_getstr(obj, "url");
 		p->token = json_getstr(obj, "token");
+		if (p->identifier == NULL || p->type == NULL || p->uri == NULL
+		    || p->token == NULL) {
+			warnx("malformed challenge");
+			goto fail;
+		}
 		p->status = json_parse_response(obj);
+		p->retry = 0;
 		if (p->status == CHNG_INVALID) {
 			error = json_getobj(obj, "error");
 			p->error = json_getstr(error, "detail");
 		}
-		return p->uri != NULL && p->token != NULL;
+		else
+			p->error = NULL;
+		STAILQ_INSERT_TAIL(&chngs, p, next);
+	}
+
+	return chngs;
+
+fail:
+	while (!STAILQ_EMPTY(&chngs)) {
+		p = STAILQ_FIRST(&chngs);
+		STAILQ_REMOVE_HEAD(&chngs, next);
+		json_free_challenge(p);
 	}
 
-	return 0;
+	return chngs;
 }
 
 static enum orderstatus
Index: usr.sbin/acme-client/main.c
===================================================================
RCS file: /cvs/src/usr.sbin/acme-client/main.c,v
retrieving revision 1.55
diff -u -p -r1.55 main.c
--- usr.sbin/acme-client/main.c	5 May 2022 19:51:35 -0000	1.55
+++ usr.sbin/acme-client/main.c	20 Feb 2024 21:20:26 -0000
@@ -220,7 +220,7 @@ main(int argc, char *argv[])
 		c = netproc(key_fds[1], acct_fds[1],
 		    chng_fds[1], cert_fds[1],
 		    dns_fds[1], rvk_fds[1],
-		    revocate, authority,
+		    revocate, authority, domain->delay, 
 		    (const char *const *)alts, altsz);
 		exit(c ? EXIT_SUCCESS : EXIT_FAILURE);
 	}
@@ -286,7 +286,7 @@ main(int argc, char *argv[])
 		close(rvk_fds[0]);
 		close(file_fds[0]);
 		close(file_fds[1]);
-		c = chngproc(chng_fds[0], chngdir);
+		c = chngproc(chng_fds[0], chngdir, domain->challengehook);
 		exit(c ? EXIT_SUCCESS : EXIT_FAILURE);
 	}
 
Index: usr.sbin/acme-client/netproc.c
===================================================================
RCS file: /cvs/src/usr.sbin/acme-client/netproc.c,v
retrieving revision 1.33
diff -u -p -r1.33 netproc.c
--- usr.sbin/acme-client/netproc.c	14 Dec 2022 18:32:26 -0000	1.33
+++ usr.sbin/acme-client/netproc.c	20 Feb 2024 21:20:26 -0000
@@ -15,6 +15,7 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
+#include <stdio.h>
 #include <assert.h>
 #include <ctype.h>
 #include <err.h>
@@ -505,14 +506,13 @@ doupdorder(struct conn *c, struct order 
 /*
  * Request a challenge for the given domain name.
  * This must be called for each name "alt".
- * On non-zero exit, fills in "chng" with the challenge.
  */
-static int
-dochngreq(struct conn *c, const char *auth, struct chng *chng)
+static struct chng_queue
+dochngreq(struct conn *c, const char *auth)
 {
-	int		 rc = 0;
 	long		 lc;
 	struct jsmnn	*j = NULL;
+	struct chng_queue chngs = STAILQ_HEAD_INITIALIZER(chngs);
 
 	dodbg("%s: %s", __func__, auth);
 
@@ -522,15 +522,13 @@ dochngreq(struct conn *c, const char *au
 		warnx("%s: bad HTTP: %ld", auth, lc);
 	else if ((j = json_parse(c->buf.buf, c->buf.sz)) == NULL)
 		warnx("%s: bad JSON object", auth);
-	else if (!json_parse_challenge(j, chng))
-		warnx("%s: bad challenge", auth);
 	else
-		rc = 1;
+		chngs = json_parse_challenge(j);
 
-	if (rc == 0 || verbose > 1)
+	if (STAILQ_EMPTY(&chngs) || verbose > 1)
 		buf_dump(&c->buf);
 	json_free(j);
-	return rc;
+	return chngs;
 }
 
 /*
@@ -673,7 +671,7 @@ dodirs(struct conn *c, const char *addr,
  */
 int
 netproc(int kfd, int afd, int Cfd, int cfd, int dfd, int rfd,
-    int revocate, struct authority_c *authority,
+    int revocate, struct authority_c *authority, int delay,
     const char *const *alts, size_t altsz)
 {
 	int		 rc = 0;
@@ -682,7 +680,8 @@ netproc(int kfd, int afd, int Cfd, int c
 	struct conn	 c;
 	struct capaths	 paths;
 	struct order	 order;
-	struct chng	*chngs = NULL;
+	struct chng_queue chngs = STAILQ_HEAD_INITIALIZER(chngs);
+	struct chng	 *chng;
 	long		 lval;
 
 	memset(&paths, 0, sizeof(struct capaths));
@@ -782,12 +781,6 @@ netproc(int kfd, int afd, int Cfd, int c
 	if (!doneworder(&c, alts, altsz, &order, &paths))
 		goto out;
 
-	chngs = calloc(order.authsz, sizeof(struct chng));
-	if (chngs == NULL) {
-		warn("calloc");
-		goto out;
-	}
-
 	/*
 	 * Get thumbprint from acctproc. We will need it to construct
 	 * a response to the challenge
@@ -812,42 +805,57 @@ netproc(int kfd, int afd, int Cfd, int c
 				goto out;
 			}
 			for (i = 0; i < order.authsz; i++) {
-				if (!dochngreq(&c, order.auths[i], &chngs[i]))
+				struct chng_queue newchngs;
+
+				newchngs = dochngreq(&c, order.auths[i]);
+				if (STAILQ_EMPTY(&newchngs))
 					goto out;
 
+				STAILQ_CONCAT(&chngs, &newchngs);
+			}
+
+			STAILQ_FOREACH(chng, &chngs, next) {
 				dodbg("challenge, token: %s, uri: %s, status: "
-				    "%d", chngs[i].token, chngs[i].uri,
-				    chngs[i].status);
+				    "%d", chng->token, chng->uri, chng->status);
 
-				if (chngs[i].status == CHNG_VALID ||
-				    chngs[i].status == CHNG_INVALID)
+				if (chng->status == CHNG_VALID ||
+				    chng->status == CHNG_INVALID)
 					continue;
 
-				if (chngs[i].retry++ >= RETRY_MAX) {
+				if (chng->retry++ >= RETRY_MAX) {
 					warnx("%s: too many tries",
-					    chngs[i].uri);
+					    chng->uri);
 					goto out;
 				}
 
 				if (writeop(Cfd, COMM_CHNG_OP, CHNG_SYN) <= 0)
 					goto out;
-				else if (writestr(Cfd, COMM_THUMB, thumb) <= 0)
+				if (writestr(Cfd, COMM_ID, chng->identifier) <= 0)
 					goto out;
-				else if (writestr(Cfd, COMM_TOK,
-				    chngs[i].token) <= 0)
+				if (writestr(Cfd, COMM_TYPE, chng->type) <= 0)
+					goto out;
+				if (writestr(Cfd, COMM_THUMB, thumb) <= 0)
+					goto out;
+				if (writestr(Cfd, COMM_TOK, chng->token) <= 0)
 					goto out;
 
 				/* Read that the challenge has been made. */
 				if (readop(Cfd, COMM_CHNG_ACK) != CHNG_ACK)
-					goto out;
+					chng->status = CHNG_INVALID;
 
 			}
+
+			if (delay >= 0) {
+				dodbg("delay for %ds\n", delay);
+				sleep(delay);
+			}
+
 			/* Write to the CA that it's ready. */
-			for (i = 0; i < order.authsz; i++) {
-				if (chngs[i].status == CHNG_VALID ||
-				    chngs[i].status == CHNG_INVALID)
+			STAILQ_FOREACH(chng, &chngs, next) {
+				if (chng->status == CHNG_VALID ||
+				    chng->status == CHNG_INVALID)
 					continue;
-				if (!dochngresp(&c, &chngs[i]))
+				if (!dochngresp(&c, chng))
 					goto out;
 			}
 			break;
@@ -880,15 +888,18 @@ netproc(int kfd, int afd, int Cfd, int c
 
 	if (order.status != ORDER_VALID) {
 		for (i = 0; i < order.authsz; i++) {
-			dochngreq(&c, order.auths[i], &chngs[i]);
-			if (chngs[i].error != NULL) {
-				if (stravis(&error, chngs[i].error, VIS_SAFE)
-				    != -1) {
+			struct chng_queue newchngs;
+
+			newchngs = dochngreq(&c, order.auths[i]);
+
+			STAILQ_FOREACH(chng, &newchngs, next)
+				if (chng->error != NULL
+				    && stravis(&error, chng->error,
+					    VIS_SAFE) != -1) {
 					warnx("%s", error);
 					free(error);
 					error = NULL;
 				}
-			}
 		}
 		goto out;
 	}
@@ -917,10 +928,11 @@ out:
 	free(thumb);
 	free(c.kid);
 	free(c.buf.buf);
-	if (chngs != NULL)
-		for (i = 0; i < order.authsz; i++)
-			json_free_challenge(&chngs[i]);
-	free(chngs);
+	while (!STAILQ_EMPTY(&chngs)) {
+		chng = STAILQ_FIRST(&chngs);
+		STAILQ_REMOVE_HEAD(&chngs, next);
+		json_free_challenge(chng);
+	}
 	json_free_capaths(&paths);
 	return rc;
 }
Index: usr.sbin/acme-client/parse.h
===================================================================
RCS file: /cvs/src/usr.sbin/acme-client/parse.h,v
retrieving revision 1.15
diff -u -p -r1.15 parse.h
--- usr.sbin/acme-client/parse.h	14 Sep 2020 16:00:17 -0000	1.15
+++ usr.sbin/acme-client/parse.h	20 Feb 2024 21:20:26 -0000
@@ -45,6 +45,7 @@ struct domain_c {
 	TAILQ_ENTRY(domain_c)	 entry;
 	TAILQ_HEAD(, altname_c)	 altname_list;
 	int			 altname_count;
+	int			 delay;
 	enum keytype		 keytype;
 	char			*handle;
 	char			*domain;
@@ -53,6 +54,7 @@ struct domain_c {
 	char			*chain;
 	char			*fullchain;
 	char			*auth;
+	char			*challengehook;
 	char			*challengedir;
 };
 
Index: usr.sbin/acme-client/parse.y
===================================================================
RCS file: /cvs/src/usr.sbin/acme-client/parse.y,v
retrieving revision 1.45
diff -u -p -r1.45 parse.y
--- usr.sbin/acme-client/parse.y	15 Dec 2022 08:06:13 -0000	1.45
+++ usr.sbin/acme-client/parse.y	20 Feb 2024 21:20:26 -0000
@@ -102,6 +102,7 @@ typedef struct {
 
 %token	AUTHORITY URL API ACCOUNT CONTACT
 %token	DOMAIN ALTERNATIVE NAME NAMES CERT FULL CHAIN KEY SIGN WITH CHALLENGEDIR
+%token	CHALLENGEHOOK DELAY
 %token	YES NO
 %token	INCLUDE
 %token	ERROR
@@ -393,6 +394,28 @@ domainoptsl	: ALTERNATIVE NAMES '{' optn
 				err(EXIT_FAILURE, "strdup");
 			domain->challengedir = s;
 		}
+		| CHALLENGEHOOK STRING {
+			char *s;
+			if (domain->challengehook != NULL) {
+				yyerror("duplicate challengehook");
+				YYERROR;
+			}
+			if ((s = strdup($2)) == NULL)
+				err(EXIT_FAILURE, "strdup");
+			domain->challengehook = s;
+		}
+		| DELAY NUMBER {
+			if (domain->delay >= 0) {
+				yyerror("duplicate delay");
+				YYERROR;
+			}
+			if ($2 < 0) {
+				yyerror("invalid delay: %lld ", $2);
+				YYERROR;
+			}
+			domain->delay = $2;
+
+		}
 		;
 
 altname_l	: altname optcommanl altname_l
@@ -462,7 +485,9 @@ lookup(char *s)
 		{"certificate",		CERT},
 		{"chain",		CHAIN},
 		{"challengedir",	CHALLENGEDIR},
+		{"challengehook",	CHALLENGEHOOK},
 		{"contact",		CONTACT},
+		{"delay",		DELAY},
 		{"domain",		DOMAIN},
 		{"ecdsa",		ECDSA},
 		{"full",		FULL},
@@ -964,6 +989,7 @@ conf_new_domain(struct acme_conf *c, cha
 		return (NULL);
 	if ((d = calloc(1, sizeof(struct domain_c))) == NULL)
 		err(EXIT_FAILURE, "%s", __func__);
+	d->delay = -1;
 	TAILQ_INSERT_TAIL(&c->domain_list, d, entry);
 
 	d->handle = s;
@@ -1085,6 +1111,10 @@ print_config(struct acme_conf *xconf)
 			printf("\tsign with \"%s\"\n", d->auth);
 		if (d->challengedir != NULL)
 			printf("\tchallengedir \"%s\"\n", d->challengedir);
+		if (d->challengehook != NULL)
+			printf("\tchallengehook \"%s\"\n", d->challengehook);
+		if (d->delay >= 0)
+			printf("\tdelay \"%d\"\n", d->delay);
 		printf("}\n\n");
 	}
 }
@@ -1100,7 +1130,7 @@ domain_valid(const char *cp)
 {
 
 	for ( ; *cp != '\0'; cp++)
-		if (!(*cp == '.' || *cp == '-' ||
+		if (!(*cp == '.' || *cp == '-' || *cp == '*' ||
 		    *cp == '_' || isalnum((unsigned char)*cp)))
 			return 0;
 	return 1;