Index | Thread | Search

From:
Lloyd <ng2d68@proton.me>
Subject:
acme-client(1): add support for let's encrypt iPAddress certificates
To:
"tech@openbsd.org" <tech@openbsd.org>
Date:
Sat, 26 Jul 2025 07:07:06 +0000

Download raw body.

Thread
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)