From: Lloyd Subject: Re: acme-client(1): add support for let's encrypt iPAddress certificates To: "tech@openbsd.org" Date: Sun, 31 Aug 2025 05:16:05 +0000 Any feedback on this LE patch from July or was anyone able to test further? I nearly forgot I submitted this. There is some discussion to be had on the internal handling of SANs in acme-client(1) now that both IP addresses and host names are supported as valid Subject Alternative Names with LE. Regards Lloyd > Hi all, > > I extended the ACME profile patch posted by sthen@ a few months ago to hack > together basic working support for Let's Encrypt IP address certificates. > > Some notes and limitations: > > * To issue an IP address cert, you must select the 'shortlived' profile. > > * Their new profiles disallow the X.509 Common Name field. The "no cn" > directive has been added to acme-client.conf(5) to support this. > > * IP certs must use an identity type of iPAddress in the SAN field in the CSR. > Unfortunately, acme-client(1) makes a lot of assumptions that we will only > ever use SAN type DNS and this is hard coded in a few places. There is some > internal ugliness where it packs all the altnames into an array of strings > and assumes they are all dns hostnames. Ideally these become structs where > each SAN identifier can have an idtype associated with it. > > * The 'identifiertype ip' directive will assume all domain/altnames are of type > iPAddress. This works fine for a single IP or multiple of the same, but will > not allow you to mix and match SAN types in the same request. It's unclear, > but it appears Let's Encrypt would allow both types in the same CSR. Maybe. > > * There is no good way to fix this without breaking the "alternative names" > syntax in acme-client.conf, because in this brave new [pki] world this is no > longer a simple list of strings, each string needs a type associated with it. > > * One option could be using a different directive for IP altnames, but > acme-client assumes the "domain" name is altname[0]. Regex parsing is likely > too complex. Maybe prefixing every string with "ip:" including the domain? > Something to figure out but the altnames parsing scheme needs to be reworked. > > * Let's Encrypt's documentation for all of this is a bit... wanting. > > With this patch the following config will issue a working cert (currently you > must use staging unless you are whitelisted): > > domain 1.2.3.4 { > domain key "/etc/ssl/private/1.2.3.4.key" > domain full chain certificate "/etc/ssl/1.2.3.4.pem" > profile shortlived > identifiertype ip > no cn > sign with letsencrypt-staging > } > > Note this patch is against 7.7 as I am not cool enough to follow -current. > > Cheers > Lloyd > > Index: acme-client.conf.5 > =================================================================== > RCS file: /cvs/src/usr.sbin/acme-client/acme-client.conf.5,v > retrieving revision 1.29 > diff -u -p -u -p -r1.29 acme-client.conf.5 > --- acme-client.conf.5 11 Jan 2021 07:23:42 -0000 1.29 > +++ acme-client.conf.5 26 Jul 2025 05:48:47 -0000 > @@ -187,6 +187,21 @@ A backup with name > is created if > .Ar file > exists. > +.It Ic identifiertype Ar idtype > +The type of identity requested in the X.509 Subject Alternative > +Name extension. The default > +.Ar idtype > +of > +.Cm dns > +is almost always appropriate. For a SAN of type iPAddress, set this to > +.Cm ip . > +.It Ic no cn > +Suppress the X.509 Common Name attribute in the certificate > +request. Use this if your CA's Certificate Policy requires it. > +By default, the CN is always present. > +.It Ic profile Ar profile > +The certificate profile to be requested. > +If this setting is absent, no profile request is made. > .It Ic sign with Ar authority > The certificate authority (as declared above in the > .Sx AUTHORITIES > Index: extern.h > =================================================================== > RCS file: /cvs/src/usr.sbin/acme-client/extern.h,v > retrieving revision 1.21 > diff -u -p -u -p -r1.21 extern.h > --- extern.h 21 May 2024 05:00:48 -0000 1.21 > +++ extern.h 26 Jul 2025 05:48:47 -0000 > @@ -209,10 +209,10 @@ int revokeproc(int, const char *, int, > int fileproc(int, const char *, const char *, const char *, > const char *); > int keyproc(int, const char *, const char **, size_t, > - enum keytype); > + enum keytype, enum identifiertype, int); > int netproc(int, int, int, int, int, int, int, > struct authority_c *, const char *const *, > - size_t); > + size_t, const char , enum identifiertype); > > / > * Debugging functions. > @@ -263,7 +263,8 @@ 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); > +char *json_fmt_neworder(const char *const *, size_t, const char *, > + enum identifiertype); > 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 > retrieving revision 1.21 > diff -u -p -u -p -r1.21 json.c > --- json.c 14 Sep 2020 16:00:17 -0000 1.21 > +++ json.c 26 Jul 2025 05:48:47 -0000 > @@ -647,7 +647,8 @@ json_fmt_newacc(const char *contact) > * Format the "newOrder" resource request > */ > char * > -json_fmt_neworder(const char *const *alts, size_t altsz) > +json_fmt_neworder(const char *const *alts, size_t altsz, const char *profile, > + enum identifiertype idtype) > { > size_t i; > int c; > @@ -658,9 +659,20 @@ json_fmt_neworder(const char *const *alt > > t = p; > for (i = 0; i < altsz; i++) { > - c = asprintf(&p, > - "%s { \"type\": \"dns\", \"value\": \"%s\" }%s", > - t, alts[i], i + 1 == altsz ? "" : ","); > + switch (idtype) { > + case ID_DNS: > + c = asprintf(&p, > + "%s { \"type\": \"dns\", " > + "\"value\": \"%s\" }%s", > + t, alts[i], i + 1 == altsz ? "" : ","); > + break; > + case ID_IP: > + c = asprintf(&p, > + "%s { \"type\": \"ip\", " > + "\"value\": \"%s\" }%s", > + t, alts[i], i + 1 == altsz ? "" : ","); > + break; > + } > free(t); > if (c == -1) { > warn("asprintf"); > @@ -669,7 +681,10 @@ json_fmt_neworder(const char *const *alt > } > t = p; > } > - c = asprintf(&p, "%s ] }", t); > + if (profile == NULL) > + c = asprintf(&p, "%s ] }", t); > + else > + c = asprintf(&p, "%s ], \"profile\": \"%s\" }", t, profile); > free(t); > if (c == -1) { > warn("asprintf"); > Index: keyproc.c > =================================================================== > RCS file: /cvs/src/usr.sbin/acme-client/keyproc.c,v > retrieving revision 1.18 > diff -u -p -u -p -r1.18 keyproc.c > --- keyproc.c 28 Aug 2022 18:30:29 -0000 1.18 > +++ keyproc.c 26 Jul 2025 05:48:47 -0000 > @@ -75,7 +75,7 @@ add_ext(STACK_OF(X509_EXTENSION) *sk, in > */ > int > keyproc(int netsock, const char *keyfile, const char **alts, size_t altsz, > - enum keytype keytype) > + enum keytype keytype, enum identifiertype idtype, int no_cn) > { > char *der64 = NULL, *der = NULL, *dercp; > char *sans = NULL, *san = NULL; > @@ -155,20 +155,20 @@ 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; > + if (!no_cn) { > + / 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 > @@ -196,8 +196,16 @@ keyproc(int netsock, const char *keyfile > */ > > for (i = 0; i < altsz; i++) { > - cc = asprintf(&san, "%sDNS:%s", > - i ? "," : "", alts[i]); > + switch (idtype) { > + case ID_DNS: > + cc = asprintf(&san, "%sDNS:%s", > + i ? "," : "", alts[i]); > + break; > + case ID_IP: > + cc = asprintf(&san, "%sIP:%s", > + i ? "," : "", alts[i]); > + break; > + } > if (cc == -1) { > warn("asprintf"); > goto out; > Index: main.c > =================================================================== > RCS file: /cvs/src/usr.sbin/acme-client/main.c,v > retrieving revision 1.56 > diff -u -p -u -p -r1.56 main.c > --- main.c 19 Jun 2024 13:13:25 -0000 1.56 > +++ main.c 26 Jul 2025 05:48:47 -0000 > @@ -224,7 +224,8 @@ main(int argc, char *argv[]) > chng_fds[1], cert_fds[1], > dns_fds[1], rvk_fds[1], > revocate, authority, > - (const char *const *)alts, altsz); > + (const char *const *)alts, altsz, > + domain->profile, domain->idtype); > > exit(c ? EXIT_SUCCESS : EXIT_FAILURE); > } > > @@ -251,7 +252,8 @@ main(int argc, char *argv[]) > close(file_fds[1]); > c = keyproc(key_fds[0], domain->key, > > (const char **)alts, altsz, > - domain->keytype); > > + domain->keytype, domain->idtype, > > + domain->no_cn); > > exit(c ? EXIT_SUCCESS : EXIT_FAILURE); > } > > Index: netproc.c > =================================================================== > RCS file: /cvs/src/usr.sbin/acme-client/netproc.c,v > retrieving revision 1.37.4.1 > diff -u -p -u -p -r1.37.4.1 netproc.c > --- netproc.c 10 Jun 2025 18:21:49 -0000 1.37.4.1 > +++ netproc.c 26 Jul 2025 05:48:47 -0000 > @@ -441,14 +441,15 @@ dochkacc(struct conn *c, const struct ca > */ > static int > doneworder(struct conn *c, const char *const *alts, size_t altsz, > - struct order *order, const struct capaths *p) > + struct order *order, const struct capaths *p, const char *profile, > + enum identifiertype idtype) > { > struct jsmnn *j = NULL; > int rc = 0; > char *req; > long lc; > > - if ((req = json_fmt_neworder(alts, altsz)) == NULL) > + if ((req = json_fmt_neworder(alts, altsz, profile, idtype)) == NULL) > warnx("json_fmt_neworder"); > else if ((lc = sreq(c, p->neworder, 1, req, &order->uri)) < 0) > > warnx("%s: bad comm", p->neworder); > > @@ -671,7 +672,8 @@ 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 *const *alts, size_t altsz, const char *profile, > + enum identifiertype idtype) > { > int rc = 0, retries = 0; > size_t i; > @@ -762,7 +764,7 @@ netproc(int kfd, int afd, int Cfd, int c > * Following that, submit the request to the CA then notify the > * certproc, which will in turn notify the fileproc. > * XXX currently we can only sign with the account key, the RFC > - * also mentions signing with the privat key of the cert itself. > + * also mentions signing with the private key of the cert itself. > / > if (revocate) { > if ((cert = readstr(rfd, COMM_CSR)) == NULL) > @@ -776,7 +778,7 @@ netproc(int kfd, int afd, int Cfd, int c > > memset(&order, 0, sizeof(order)); > > - if (!doneworder(&c, alts, altsz, &order, &paths)) > + if (!doneworder(&c, alts, altsz, &order, &paths, profile, idtype)) > goto out; > > chngs = calloc(order.authsz, sizeof(struct chng)); > Index: parse.h > =================================================================== > RCS file: /cvs/src/usr.sbin/acme-client/parse.h,v > retrieving revision 1.15 > diff -u -p -u -p -r1.15 parse.h > --- parse.h 14 Sep 2020 16:00:17 -0000 1.15 > +++ parse.h 26 Jul 2025 05:48:47 -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; > @@ -54,6 +59,9 @@ struct domain_c { > char *fullchain; > char *auth; > char *challengedir; > + char *profile; > + enum identifiertype idtype; > + int no_cn; > }; > > struct altname_c { > Index: parse.y > =================================================================== > RCS file: /cvs/src/usr.sbin/acme-client/parse.y,v > retrieving revision 1.45 > diff -u -p -u -p -r1.45 parse.y > --- parse.y 15 Dec 2022 08:06:13 -0000 1.45 > +++ parse.y 26 Jul 2025 05:48:47 -0000 > @@ -71,6 +71,7 @@ struct domain_c *conf_new_domain(struct > struct keyfile *conf_new_keyfile(struct acme_conf *, char ); > void clear_config(struct acme_conf ); > const char kt2txt(enum keytype); > +const char id2txt(enum identifiertype); > void print_config(struct acme_conf *); > int conf_check_file(char *); > > @@ -101,15 +102,17 @@ typedef struct { > %} > > %token AUTHORITY URL API ACCOUNT CONTACT > -%token DOMAIN ALTERNATIVE NAME NAMES CERT FULL CHAIN KEY SIGN WITH CHALLENGEDIR > -%token YES NO > +%token DOMAIN ALTERNATIVE NAME NAMES CERT FULL CHAIN KEY SIGN WITH > +%token CHALLENGEDIR PROFILE IDENTIFIERTYPE NO CN > %token INCLUDE > %token ERROR > %token RSA ECDSA > +%token DNS IP > %token STRING > > %token NUMBER > > %type string > > %type keytype > > +%type idtype > > > %% > > @@ -219,7 +222,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"); > @@ -283,6 +286,11 @@ keytype : RSA { $$ = KT_RSA; } > | { $$ = KT_RSA; } > ; > > +idtype : DNS { $$ = ID_DNS; } > + | IP { $$ = ID_IP; } > + | { $$ = ID_DNS; } > + ; > + > domainopts_l : domainopts_l domainoptsl nl > | domainoptsl optnl > ; > @@ -393,6 +401,22 @@ domainoptsl : ALTERNATIVE NAMES '{' optn > err(EXIT_FAILURE, "strdup"); > domain->challengedir = 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; > > + } > + | IDENTIFIERTYPE idtype { > + domain->idtype = $2; > > + } > + | NO CN { > + domain->no_cn = 1; > > + } > ; > > altname_l : altname optcommanl altname_l > @@ -462,14 +486,20 @@ lookup(char s) > {"certificate", CERT}, > {"chain", CHAIN}, > {"challengedir", CHALLENGEDIR}, > + {"cn", CN}, > {"contact", CONTACT}, > + {"dns", DNS}, > {"domain", DOMAIN}, > {"ecdsa", ECDSA}, > {"full", FULL}, > + {"identifiertype", IDENTIFIERTYPE}, > {"include", INCLUDE}, > + {"ip", IP}, > {"key", KEY}, > {"name", NAME}, > {"names", NAMES}, > + {"no", NO}, > + {"profile", PROFILE}, > {"rsa", RSA}, > {"sign", SIGN}, > {"url", URL}, > @@ -1039,6 +1069,19 @@ kt2txt(enum keytype kt) > } > } > > +const char > +id2txt(enum identifiertype id) > +{ > + switch (id) { > + case ID_DNS: > + return "dns"; > + case ID_IP: > + return "ip"; > + default: > + return ""; > > + } > +} > + > void > print_config(struct acme_conf *xconf) > { > @@ -1081,6 +1124,11 @@ print_config(struct acme_conf *xconf) > if (d->fullchain != NULL) > > printf("\tdomain full chain certificate \"%s\"\n", > d->fullchain); > > + if (d->profile != NULL) > > + printf("\tprofile \"%s\"\n", d->profile); > > + printf("\tidentifiertype \"%s\"\n", id2txt(d->idtype)); > > + if (d->no_cn != 0) > > + printf("\tno cn\n"); > if (d->auth != NULL) > > printf("\tsign with \"%s\"\n", d->auth); > > if (d->challengedir != NULL)