Download raw body.
acme-client(1): add support for let's encrypt iPAddress certificates
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)
acme-client(1): add support for let's encrypt iPAddress certificates