From: Stuart Henderson Subject: Re: acme-client(1): add support for let's encrypt iPAddress certificates To: Lloyd , Peter Hessler , "tech@openbsd.org" Date: Wed, 17 Dec 2025 12:40:08 +0000 > Diff below merges this to -current. Works for me with a shortlived IP > address cert on letsencrypt staging, with a standard cert on letsencrypt > prod, and src/regress/usr.sbin/acme-client (using pebble) is still > happy. this is now live in prod on letsencrypt, but beware if testing, there is a bug. if you list IPv6 addresses, it hits "domain list changed, forcing renewal" on every renewal. this is because, when setting up found_altnames, it's doing a memcmp() between the expanded v6 address and the compressed one, i.e. memcmp("xxxx:xxxx:0001:0101:0000:0000:0000:0002", "xxxx:xxxx:1:101::2", 39 updated diff below uses inet_ntop, rather than hand-rolled functions, to generate strings from the addresses in an existing cert. I also dropped the "with cn" option and setting Subject in the CSR. (I think v6 addresses probably ought to be normalised to the inet_ntop form when read from the config file too - I haven't done that in this diff. Looks like letsencrypt won't issue a cert if you try to use the expanded form anyway). Index: acme-client.conf.5 =================================================================== RCS file: /cvs/src/usr.sbin/acme-client/acme-client.conf.5,v diff -u -p -r1.32 acme-client.conf.5 --- acme-client.conf.5 16 Sep 2025 15:06:02 -0000 1.32 +++ acme-client.conf.5 17 Dec 2025 12:39:56 -0000 @@ -115,6 +115,12 @@ The certificates to be obtained through Each domain section begins with the .Ic domain keyword followed by an identifier for this domain block. +If requesting a certificate with only an IP address, +or if requesting two certificates of different types (EC and RSA) +for the same name, +an additional +.Cm domain name +directive must follow. .El .Pp It is followed by a block of options enclosed in curly brackets: @@ -122,19 +128,20 @@ It is followed by a block of options enc .It Ic domain name Ar name The .Ar name -to be used as the common name component of the subject of the -X.509 certificate. -This is optional. +to be used as the primary Subject Alternative Name +in the X.509 certificate. +This is optional for SANs of type DNS. If not specified, the .Ar handle -of the domain block will be used as common name. +of the domain block will be used as the primary SAN. +To use a SAN of type iPAddress, specify an IP address prefixed with +.Cm ip: . .It Ic alternative names Brq ... A list of alternative names, comma or space separated, for which the certificate will be valid. -The common name is included automatically if this option is present, -but there is no automatic conversion/inclusion between "www." and -plain domain name forms. +To use a SAN of type iPAddress, specify an IP address prefixed with +.Cm ip: . .It Ic domain key Ar file Op Ar keytype The private key file for which the certificate will be obtained. .Ar keytype Index: extern.h =================================================================== RCS file: /cvs/src/usr.sbin/acme-client/extern.h,v diff -u -p -r1.22 extern.h --- extern.h 16 Sep 2025 15:06:02 -0000 1.22 +++ extern.h 17 Dec 2025 12:39:56 -0000 @@ -204,15 +204,12 @@ int acctproc(int, const char *, enum k int certproc(int, int); int chngproc(int, const char *); int dnsproc(int); -int revokeproc(int, const char *, int, int, const char *const *, - size_t); +int revokeproc(int, const char *, int, int, struct domain_c *); int fileproc(int, const char *, const char *, const char *, const char *); -int keyproc(int, const char *, const char **, size_t, - enum keytype); +int keyproc(int, struct domain_c *); int netproc(int, int, int, int, int, int, int, - struct authority_c *, const char *const *, - size_t, const char *); + struct authority_c *, struct domain_c *); /* * Debugging functions. @@ -263,7 +260,7 @@ char *json_getstr(struct jsmnn *, const char *json_fmt_newcert(const char *); char *json_fmt_chkacc(void); char *json_fmt_newacc(const char *); -char *json_fmt_neworder(const char *const *, size_t, const char *); +char *json_fmt_neworder(struct domain_c *); char *json_fmt_protected_rsa(const char *, const char *, const char *, const char *); char *json_fmt_protected_ec(const char *, const char *, const char *, Index: json.c =================================================================== RCS file: /cvs/src/usr.sbin/acme-client/json.c,v diff -u -p -r1.22 json.c --- json.c 16 Sep 2025 15:06:02 -0000 1.22 +++ json.c 17 Dec 2025 12:39:56 -0000 @@ -647,20 +647,32 @@ json_fmt_newacc(const char *contact) * Format the "newOrder" resource request */ char * -json_fmt_neworder(const char *const *alts, size_t altsz, const char *profile) +json_fmt_neworder(struct domain_c *domain) { - size_t i; - int c; - char *p, *t; + int c; + char *p, *t; + struct altname_c *ac, *first; if ((p = strdup("{ \"identifiers\": [")) == NULL) goto err; t = p; - for (i = 0; i < altsz; i++) { - c = asprintf(&p, - "%s { \"type\": \"dns\", \"value\": \"%s\" }%s", - t, alts[i], i + 1 == altsz ? "" : ","); + first = TAILQ_FIRST(&domain->altname_list); + TAILQ_FOREACH(ac, &domain->altname_list, entry) { + switch (ac->idtype) { + case ID_DNS: + c = asprintf(&p, + "%s%s { \"type\": \"dns\", " + "\"value\": \"%s\" }", + t, ac == first ? "" : ",", ac->domain); + break; + case ID_IP: + c = asprintf(&p, + "%s%s { \"type\": \"ip\", " + "\"value\": \"%s\" }", + t, ac == first ? "" : ",", ac->domain); + break; + } free(t); if (c == -1) { warn("asprintf"); @@ -669,10 +681,11 @@ json_fmt_neworder(const char *const *alt } t = p; } - if (profile == NULL) + if (domain->profile == NULL) c = asprintf(&p, "%s ] }", t); else - c = asprintf(&p, "%s ], \"profile\": \"%s\" }", t, profile); + c = asprintf(&p, "%s ], \"profile\": \"%s\" }", t, + domain->profile); free(t); if (c == -1) { warn("asprintf"); Index: keyproc.c =================================================================== RCS file: /cvs/src/usr.sbin/acme-client/keyproc.c,v diff -u -p -r1.18 keyproc.c --- keyproc.c 28 Aug 2022 18:30:29 -0000 1.18 +++ keyproc.c 17 Dec 2025 12:39:56 -0000 @@ -74,13 +74,12 @@ add_ext(STACK_OF(X509_EXTENSION) *sk, in * jail and, on success, ship it to "netsock" as an X509 request. */ int -keyproc(int netsock, const char *keyfile, const char **alts, size_t altsz, - enum keytype keytype) +keyproc(int netsock, struct domain_c *domain) { char *der64 = NULL, *der = NULL, *dercp; char *sans = NULL, *san = NULL; FILE *f; - size_t i, sansz; + size_t sansz; void *pp; EVP_PKEY *pkey = NULL; X509_REQ *x = NULL; @@ -88,6 +87,8 @@ keyproc(int netsock, const char *keyfile int len, rc = 0, cc, nid, newkey = 0; mode_t prev; STACK_OF(X509_EXTENSION) *exts = NULL; + struct altname_c *ac, *first; + const char *keyfile = domain->key; /* * First, open our private key file read-only or write-only if @@ -117,7 +118,7 @@ keyproc(int netsock, const char *keyfile } if (newkey) { - switch (keytype) { + switch (domain->keytype) { case KT_ECDSA: if ((pkey = ec_key_create(f, keyfile)) == NULL) goto out; @@ -155,20 +156,6 @@ keyproc(int netsock, const char *keyfile goto out; } - /* Now specify the common name that we'll request. */ - - if ((name = X509_NAME_new()) == NULL) { - warnx("X509_NAME_new"); - goto out; - } else if (!X509_NAME_add_entry_by_txt(name, "CN", - MBSTRING_ASC, (u_char *)alts[0], -1, -1, 0)) { - warnx("X509_NAME_add_entry_by_txt: CN=%s", alts[0]); - goto out; - } else if (!X509_REQ_set_subject_name(x, name)) { - warnx("X509_req_set_issuer_name"); - goto out; - } - /* * Now add the SAN extensions. * This was lifted more or less directly from demos/x509/mkreq.c @@ -195,9 +182,18 @@ keyproc(int netsock, const char *keyfile * domains: NOT an entry per domain! */ - for (i = 0; i < altsz; i++) { - cc = asprintf(&san, "%sDNS:%s", - i ? "," : "", alts[i]); + first = TAILQ_FIRST(&domain->altname_list); + TAILQ_FOREACH(ac, &domain->altname_list, entry) { + switch (ac->idtype) { + case ID_DNS: + cc = asprintf(&san, "%sDNS:%s", + ac == first ? "" : ",", ac->domain); + break; + case ID_IP: + cc = asprintf(&san, "%sIP:%s", + ac == first ? "" : ",", ac->domain); + break; + } if (cc == -1) { warn("asprintf"); goto out; Index: main.c =================================================================== RCS file: /cvs/src/usr.sbin/acme-client/main.c,v diff -u -p -r1.57 main.c --- main.c 16 Sep 2025 15:06:02 -0000 1.57 +++ main.c 17 Dec 2025 12:39:56 -0000 @@ -40,7 +40,6 @@ enum comp proccomp; int main(int argc, char *argv[]) { - const char **alts = NULL; char *certdir = NULL; char *chngdir = NULL, *auth = NULL; char *conffile = CONF_FILE; @@ -51,7 +50,7 @@ main(int argc, char *argv[]) int c, rc, revocate = 0; int popts = 0; pid_t pids[COMP__MAX]; - size_t i, altsz, ne; + size_t ne; struct acme_conf *conf = NULL; struct authority_c *authority = NULL; @@ -112,7 +111,7 @@ main(int argc, char *argv[]) if ((tmpsd = dirname(tmps)) == NULL) err(EXIT_FAILURE, "dirname"); if ((certdir = strdup(tmpsd)) == NULL) - err(EXIT_FAILURE, "strdup"); + err(EXIT_FAILURE, "strdup"); free(tmps); tmps = tmpsd = NULL; @@ -174,15 +173,12 @@ main(int argc, char *argv[]) return EXIT_SUCCESS; /* Set the zeroth altname as our domain. */ - altsz = domain->altname_count + 1; - alts = calloc(altsz, sizeof(char *)); - if (alts == NULL) - err(EXIT_FAILURE, "calloc"); - alts[0] = domain->domain; - i = 1; - /* XXX get rid of alts[] later */ - TAILQ_FOREACH(ac, &domain->altname_list, entry) - alts[i++] = ac->domain; + + ac = calloc(1, sizeof(struct altname_c)); + ac->domain = domain->domain; + ac->idtype = domain->idtype; + TAILQ_INSERT_HEAD(&domain->altname_list, ac, entry); + domain->altname_count++; /* * Open channels between our components. @@ -223,9 +219,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, - (const char *const *)alts, altsz, - domain->profile); + revocate, authority, domain); exit(c ? EXIT_SUCCESS : EXIT_FAILURE); } @@ -250,9 +244,7 @@ main(int argc, char *argv[]) close(chng_fds[0]); close(file_fds[0]); close(file_fds[1]); - c = keyproc(key_fds[0], domain->key, - (const char **)alts, altsz, - domain->keytype); + c = keyproc(key_fds[0], domain); exit(c ? EXIT_SUCCESS : EXIT_FAILURE); } @@ -355,8 +347,7 @@ main(int argc, char *argv[]) if (pids[COMP_REVOKE] == 0) { proccomp = COMP_REVOKE; c = revokeproc(rvk_fds[0], domain->cert != NULL ? domain->cert : - domain->fullchain, force, revocate, - (const char *const *)alts, altsz); + domain->fullchain, force, revocate, domain); exit(c ? EXIT_SUCCESS : EXIT_FAILURE); } Index: netproc.c =================================================================== RCS file: /cvs/src/usr.sbin/acme-client/netproc.c,v diff -u -p -r1.45 netproc.c --- netproc.c 16 Sep 2025 15:06:02 -0000 1.45 +++ netproc.c 17 Dec 2025 12:39:56 -0000 @@ -492,15 +492,15 @@ dochkacc(struct conn *c, const struct ca * Submit a new order for a certificate. */ static int -doneworder(struct conn *c, const char *const *alts, size_t altsz, - struct order *order, const struct capaths *p, const char *profile) +doneworder(struct conn *c, struct domain_c *domain, struct order *order, + const struct capaths *p) { struct jsmnn *j = NULL; int rc = 0; char *req; long lc; - if ((req = json_fmt_neworder(alts, altsz, profile)) == NULL) + if ((req = json_fmt_neworder(domain)) == NULL) warnx("json_fmt_neworder"); else if ((lc = sreq(c, p->neworder, 1, req, &order->uri)) < 0) warnx("%s: bad comm", p->neworder); @@ -723,7 +723,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, - const char *const *alts, size_t altsz, const char *profile) + struct domain_c *domain) { int rc = 0, retries = 0; size_t i; @@ -828,7 +828,7 @@ netproc(int kfd, int afd, int Cfd, int c memset(&order, 0, sizeof(order)); - if (!doneworder(&c, alts, altsz, &order, &paths, profile)) + if (!doneworder(&c, domain, &order, &paths)) goto out; chngs = calloc(order.authsz, sizeof(struct chng)); Index: parse.h =================================================================== RCS file: /cvs/src/usr.sbin/acme-client/parse.h,v diff -u -p -r1.17 parse.h --- parse.h 16 Sep 2025 15:06:02 -0000 1.17 +++ parse.h 17 Dec 2025 12:39:56 -0000 @@ -32,6 +32,11 @@ enum keytype { KT_ECDSA }; +enum identifiertype { + ID_DNS = 0, /* RFC 8555 */ + ID_IP /* RFC 8738 */ +}; + struct authority_c { TAILQ_ENTRY(authority_c) entry; char *name; @@ -47,6 +52,7 @@ struct domain_c { TAILQ_HEAD(, altname_c) altname_list; int altname_count; enum keytype keytype; + enum identifiertype idtype; char *handle; char *domain; char *key; @@ -60,7 +66,8 @@ struct domain_c { struct altname_c { TAILQ_ENTRY(altname_c) entry; - char *domain; + char *domain; + enum identifiertype idtype; }; struct keyfile { @@ -87,5 +94,6 @@ struct authority_c *authority_find0(stru struct domain_c *domain_find_handle(struct acme_conf *, char *); int domain_valid(const char *); +int altname_valid(const char *); #endif /* PARSE_H */ Index: parse.y =================================================================== RCS file: /cvs/src/usr.sbin/acme-client/parse.y,v diff -u -p -r1.47 parse.y --- parse.y 16 Sep 2025 15:06:02 -0000 1.47 +++ parse.y 17 Dec 2025 12:39:56 -0000 @@ -103,7 +103,6 @@ typedef struct { %token AUTHORITY URL API ACCOUNT CONTACT %token DOMAIN ALTERNATIVE NAME NAMES CERT FULL CHAIN KEY SIGN WITH %token CHALLENGEDIR PROFILE -%token YES NO %token INCLUDE %token ERROR %token RSA ECDSA @@ -221,7 +220,7 @@ authorityoptsl : API URL STRING { err(EXIT_FAILURE, "strdup"); auth->api = s; } - | ACCOUNT KEY STRING keytype{ + | ACCOUNT KEY STRING keytype { char *s; if (auth->account != NULL) { yyerror("duplicate account"); @@ -299,8 +298,21 @@ domainoptsl : ALTERNATIVE NAMES '{' optn yyerror("duplicate domain name"); YYERROR; } - if ((s = strdup($3)) == NULL) - err(EXIT_FAILURE, "strdup"); + if (!altname_valid($3)) { + yyerror("bad domain name syntax"); + YYERROR; + } + if ((strncmp($3, "ip:", 3) == 0) && + (strlen($3) > 3)) { + domain->idtype = ID_IP; + if ((s = strdup(($3) + 3)) == NULL) + err(EXIT_FAILURE, "strdup"); + } + else { + domain->idtype = ID_DNS; + if ((s = strdup($3)) == NULL) + err(EXIT_FAILURE, "strdup"); + } domain->domain = s; } | DOMAIN KEY STRING keytype { @@ -408,6 +420,16 @@ domainoptsl : ALTERNATIVE NAMES '{' optn err(EXIT_FAILURE, "strdup"); domain->profile = s; } + | PROFILE STRING { + char *s; + if (domain->profile != NULL) { + yyerror("duplicate profile"); + YYERROR; + } + if ((s = strdup($2)) == NULL) + err(EXIT_FAILURE, "strdup"); + domain->profile = s; + } ; altname_l : altname optcommanl altname_l @@ -417,15 +439,26 @@ altname_l : altname optcommanl altname_l altname : STRING { char *s; struct altname_c *ac; - if (!domain_valid($1)) { - yyerror("bad domain syntax"); + if (!altname_valid($1)) { + yyerror("bad altname syntax"); YYERROR; } if ((ac = calloc(1, sizeof(struct altname_c))) == NULL) err(EXIT_FAILURE, "calloc"); - if ((s = strdup($1)) == NULL) { - free(ac); - err(EXIT_FAILURE, "strdup"); + if ((strncmp($1, "ip:", 3) == 0) && + (strlen($1) > 3)) { + ac->idtype = ID_IP; + if ((s = strdup(($1) + 3)) == NULL) { + free(ac); + err(EXIT_FAILURE, "strdup"); + } + } + else { + ac->idtype = ID_DNS; + if ((s = strdup($1)) == NULL) { + free(ac); + err(EXIT_FAILURE, "strdup"); + } } ac->domain = s; TAILQ_INSERT_TAIL(&domain->altname_list, ac, entry); @@ -1079,12 +1112,15 @@ print_config(struct acme_conf *xconf) f = 0; printf("domain %s {\n", d->handle); if (d->domain != NULL) - printf("\tdomain name \"%s\"\n", d->domain); + printf("\tdomain name \"%s%s\"\n", + (d->idtype == ID_IP) ? "ip:" : "", d->domain); TAILQ_FOREACH(ac, &d->altname_list, entry) { if (!f) printf("\talternative names {"); if (ac->domain != NULL) { - printf("%s%s", f ? ", " : " ", ac->domain); + printf("%s%s%s", f ? ", " : " ", + (d->idtype == ID_IP) ? "ip:" : "", + ac->domain); f = 1; } } @@ -1123,6 +1159,17 @@ domain_valid(const char *cp) for ( ; *cp != '\0'; cp++) if (!(*cp == '.' || *cp == '-' || *cp == '_' || isalnum((unsigned char)*cp))) + return 0; + return 1; +} + +int +altname_valid(const char *cp) +{ + + for ( ; *cp != '\0'; cp++) + if (!(*cp == '.' || *cp == '-' || + *cp == ':' || isalnum((unsigned char)*cp))) return 0; return 1; } Index: revokeproc.c =================================================================== RCS file: /cvs/src/usr.sbin/acme-client/revokeproc.c,v diff -u -p -r1.26 revokeproc.c --- revokeproc.c 18 Sep 2025 13:22:36 -0000 1.26 +++ revokeproc.c 17 Dec 2025 12:39:56 -0000 @@ -15,6 +15,8 @@ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */ +#include +#include #include #include #include @@ -74,19 +76,18 @@ X509notbefore(X509 *x) int revokeproc(int fd, const char *certfile, int force, - int revocate, const char *const *alts, size_t altsz) + int revocate, struct domain_c *domain) { GENERAL_NAMES *sans = NULL; char *der = NULL, *dercp, *der64 = NULL; - int rc = 0, cc, i, len; - size_t *found = NULL; + int rc = 0, cc, sanidx, len, j, k; + int *found_altnames = NULL; FILE *f = NULL; X509 *x = NULL; long lval; enum revokeop op, rop; time_t notafter, notbefore, cert_validity; time_t remaining_validity, renew_allow; - size_t j; /* * First try to open the certificate before we drop privileges @@ -162,7 +163,8 @@ revokeproc(int fd, const char *certfile, /* An array of buckets: the number of entries found. */ - if ((found = calloc(altsz, sizeof(size_t))) == NULL) { + if ((found_altnames = (int *)calloc(domain->altname_count, + sizeof(int))) == NULL) { warn("calloc"); goto out; } @@ -172,49 +174,80 @@ revokeproc(int fd, const char *certfile, * configuration file and that all domains are represented only once. */ - for (i = 0; i < sk_GENERAL_NAME_num(sans); i++) { + for (sanidx = 0; sanidx < sk_GENERAL_NAME_num(sans); sanidx++) { GENERAL_NAME *gen_name; - const ASN1_IA5STRING *name; - const unsigned char *name_buf; + unsigned char *name_buf; int name_len; - int name_type; + struct altname_c *ac; - gen_name = sk_GENERAL_NAME_value(sans, i); + gen_name = sk_GENERAL_NAME_value(sans, sanidx); assert(gen_name != NULL); - name = GENERAL_NAME_get0_value(gen_name, &name_type); - if (name_type != GEN_DNS) + if (gen_name->type == GEN_IPADD) { + char ip[INET6_ADDRSTRLEN]; + name_len = gen_name->d.iPAddress->length; + if (name_len == 4) + asprintf((char **)&name_buf, "%s", + inet_ntop(AF_INET, + gen_name->d.iPAddress->data, + ip, INET6_ADDRSTRLEN)); + else if (name_len == 16) + asprintf((char **)&name_buf, "%s", + inet_ntop(AF_INET6, + gen_name->d.iPAddress->data, + ip, INET6_ADDRSTRLEN)); + else { + warnx("invalid name_len %d", name_len); + continue; + } + } + else if (gen_name->type == GEN_DNS) { + name_len = gen_name->d.dNSName->length; + asprintf((char **)&name_buf, "%.*s", + name_len, gen_name->d.dNSName->data); + } + else continue; - /* name_buf isn't a C string and could contain embedded NULs. */ - name_buf = ASN1_STRING_get0_data(name); - name_len = ASN1_STRING_length(name); + /* now we have a real C string */ + name_len = strlen(name_buf); - for (j = 0; j < altsz; j++) { - if ((size_t)name_len != strlen(alts[j])) - continue; - if (memcmp(name_buf, alts[j], name_len) == 0) + j = 0; + TAILQ_FOREACH(ac, &domain->altname_list, entry) { + int xx; + xx = memcmp(name_buf, ac->domain, name_len); + warnx("<< memcmp(%s, %s, %d) = %d >>", name_buf, ac->domain, name_len, xx); + if (xx == 0) { + found_altnames[j]++; break; + } + /* increment if didn't match */ + j++; } - if (j == altsz) { + if (j >= domain->altname_count) { + warnx("j >= domain->altname_count"); + /* we haven't matched any */ if (revocate) { char *visbuf; visbuf = calloc(4, name_len + 1); if (visbuf == NULL) { - warn("%s: unexpected SAN", certfile); + warn("%s: unexpected SAN in " + "certificate", certfile); goto out; } strvisx(visbuf, name_buf, name_len, VIS_SAFE); - warnx("%s: unexpected SAN entry: %s", - certfile, visbuf); + warnx("%s: unexpected SAN entry in " + "certificate: %s", certfile, visbuf); free(visbuf); goto out; } force = 2; continue; } - if (found[j]++) { + /* should not reach here if j is out of bounds */ + if (found_altnames[j] > 1) { + warnx("<< dup san entry %.*s >>", name_len, name_buf); if (revocate) { warnx("%s: duplicate SAN entry: %.*s", certfile, name_len, name_buf); @@ -224,13 +257,23 @@ revokeproc(int fd, const char *certfile, } } - for (j = 0; j < altsz; j++) { - if (found[j]) + for (j = 0; j < domain->altname_count; j++) { + struct altname_c *ac; + + if (found_altnames[j]) continue; if (revocate) { - warnx("%s: domain not listed: %s", certfile, alts[j]); + k = 0; + TAILQ_FOREACH(ac, &domain->altname_list, entry) { + if (j == k) + break; + k++; + } + warnx("%s: domain not listed: %s", certfile, + ac->domain); goto out; } + warnx("<< altname not found, force=2 >>"); force = 2; } @@ -340,7 +383,7 @@ out: X509_free(x); GENERAL_NAMES_free(sans); free(der); - free(found); + free(found_altnames); free(der64); ERR_print_errors_fp(stderr); ERR_free_strings();