Index | Thread | Search

From:
Lloyd <ng2d68@proton.me>
Subject:
Re: acme-client(1): add support for let's encrypt iPAddress certificates
To:
"tech@openbsd.org" <tech@openbsd.org>
Date:
Sun, 31 Aug 2025 05:16:05 +0000

Download raw body.

Thread
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 <v.string> STRING
> 
> %token <v.number> NUMBER
> 
> %type <v.string> string
> 
> %type <v.number> keytype
> 
> +%type <v.number> 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 "<unknown>";
> 
> + }
> +}
> +
> 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)