From: Jonathan Matthew Subject: Re: acme-client: support for external account binding To: Theo Buehler Cc: tech@openbsd.org, sthen@openbsd.org, florian@openbsd.org Date: Wed, 6 May 2026 15:36:00 +1000 On Sat, May 02, 2026 at 07:53:50PM +0200, Theo Buehler wrote: > On Sat, May 02, 2026 at 07:09:37PM +0200, Florian Obser wrote: > > On 2026-05-01 14:09 +10, Jonathan Matthew wrote: > > > The ACME protocol includes a scheme allowing a client to bind an ACME > > > account key with a account in some non-ACME ("external") system run by > > > the CA. This is described in section 7.3.4 of RFC 8555. In short, the > > > CA gives you a key out-of-band and your ACME client HMACs your account > > > details with that key and sends that to the ACME server. > > > > I know some of these words! My Joo Janta 200 conveniently turned black > > when looking at the acctproc changes. That stuff is certainly over my > > head. In the past tb@ pointed out the errors of my ways, maybe we can > > trick him into reviewing those bits. > > I haven't checked against the spec, but it looks fine to me, just two > things: > > > > + /* sign with the EAB key */ > > > + dig = malloc(eab_key_len); > > > + HMAC(EVP_sha256(), eab_key, eab_key_len, sign, sign_len, dig, &digsz); > > Both malloc and HMAC should be error checked (against NULL). > > > > @@ -630,6 +630,14 @@ json_fmt_newacc(const char *contact) > > > return NULL; > > > } > > > } > > > + if (eab != NULL) { > > > + char *ecnt = NULL; > > > + c = asprintf(&ecnt, "%s\"externalAccountBinding\": %s, ", > > > + cnt == NULL ? "" : cnt, eab); > > Pretty sure this should return NULL if c == -1. Thanks, here's an updated version with comments addressed. Index: acctproc.c =================================================================== RCS file: /cvs/src/usr.sbin/acme-client/acctproc.c,v diff -u -p -u -p -r1.32 acctproc.c --- acctproc.c 29 Aug 2023 14:44:53 -0000 1.32 +++ acctproc.c 6 May 2026 03:58:31 -0000 @@ -28,6 +28,7 @@ #include #include #include +#include #include #include @@ -182,18 +183,13 @@ out: } static int -op_sign_rsa(char **prot, EVP_PKEY *pkey, const char *nonce, const char *url) +op_sign_rsa(char **jwk, EVP_PKEY *pkey) { char *exp = NULL, *mod = NULL; int rc = 0; RSA *r; - *prot = NULL; - - /* - * First, extract relevant portions of our private key. - * Finally, format the header combined with the nonce. - */ + *jwk = NULL; if ((r = EVP_PKEY_get0_RSA(pkey)) == NULL) warnx("EVP_PKEY_get0_RSA"); @@ -201,8 +197,8 @@ op_sign_rsa(char **prot, EVP_PKEY *pkey, warnx("bn2string"); else if ((exp = bn2string(RSA_get0_e(r))) == NULL) warnx("bn2string"); - else if ((*prot = json_fmt_protected_rsa(exp, mod, nonce, url)) == NULL) - warnx("json_fmt_protected_rsa"); + else if ((*jwk = json_fmt_jwk_rsa(exp, mod)) == NULL) + warnx("json_fmt_jwk_rsa"); else rc = 1; @@ -212,14 +208,14 @@ op_sign_rsa(char **prot, EVP_PKEY *pkey, } static int -op_sign_ec(char **prot, EVP_PKEY *pkey, const char *nonce, const char *url) +op_sign_ec(char **jwk, EVP_PKEY *pkey) { BIGNUM *X = NULL, *Y = NULL; EC_KEY *ec = NULL; char *x = NULL, *y = NULL; int rc = 0; - *prot = NULL; + *jwk = NULL; if ((ec = EVP_PKEY_get0_EC_KEY(pkey)) == NULL) warnx("EVP_PKEY_get0_EC_KEY"); @@ -234,8 +230,8 @@ op_sign_ec(char **prot, EVP_PKEY *pkey, warnx("bn2string"); else if ((y = bn2string(Y)) == NULL) warnx("bn2string"); - else if ((*prot = json_fmt_protected_ec(x, y, nonce, url)) == NULL) - warnx("json_fmt_protected_ec"); + else if ((*jwk = json_fmt_jwk_ec(x, y)) == NULL) + warnx("json_fmt_jwk_ec"); else rc = 1; @@ -262,6 +258,7 @@ op_sign(int fd, EVP_PKEY *pkey, enum acc char *prot = NULL, *prot64 = NULL; char *sign = NULL, *dig64 = NULL, *fin = NULL; char *url = NULL, *kid = NULL, *alg = NULL; + char *jwk = NULL; const unsigned char *digp; unsigned char *dig = NULL, *buf = NULL; size_t digsz; @@ -309,17 +306,21 @@ op_sign(int fd, EVP_PKEY *pkey, enum acc } else { switch (EVP_PKEY_base_id(pkey)) { case EVP_PKEY_RSA: - if (!op_sign_rsa(&prot, pkey, nonce, url)) + if (!op_sign_rsa(&jwk, pkey)) goto out; break; case EVP_PKEY_EC: - if (!op_sign_ec(&prot, pkey, nonce, url)) + if (!op_sign_ec(&jwk, pkey)) goto out; break; default: warnx("EVP_PKEY_base_id"); goto out; } + + prot = json_fmt_protected_jwk(alg, jwk, nonce, url); + if (prot == NULL) + goto out; } /* The header combined with the nonce, base64. */ @@ -434,6 +435,7 @@ out: free(pay64); free(url); free(nonce); + free(jwk); free(kid); free(prot); free(prot64); @@ -444,8 +446,96 @@ out: return rc; } +static int +op_eab(int fd, EVP_PKEY *pkey, const char *eab_kid, + const unsigned char *eab_key, int eab_key_len) +{ + char *url = NULL; + char *prot = NULL, *prot64 = NULL; + char *jwk = NULL, *pay64 = NULL; + char *sign = NULL; + char *dig64 = NULL, *fin = NULL; + unsigned char dig[EVP_MAX_MD_SIZE]; + unsigned int digsz; + int sign_len, rc = 0; + + if ((url = readstr(fd, COMM_URL)) == NULL) + goto out; + + /* protected component: algorithm, EAB key ID, new account URL */ + if ((prot = json_fmt_protected_eab(eab_kid, url)) == NULL) { + warnx("json_fmt_protected_eab"); + goto out; + } + + if ((prot64 = base64buf_url(prot, strlen(prot))) == NULL) { + warnx("base64buf_url"); + goto out; + } + + /* payload component: account public key */ + switch (EVP_PKEY_base_id(pkey)) { + case EVP_PKEY_RSA: + if (!op_sign_rsa(&jwk, pkey)) + goto out; + break; + case EVP_PKEY_EC: + if (!op_sign_ec(&jwk, pkey)) + goto out; + break; + default: + warnx("EVP_PKEY_base_id"); + goto out; + } + + if ((pay64 = base64buf_url(jwk, strlen(jwk))) == NULL) { + warnx("base64buf_url"); + goto out; + } + + sign_len = asprintf(&sign, "%s.%s", prot64, pay64); + if (sign_len == -1) { + warn("asprintf"); + sign = NULL; + goto out; + } + + /* sign with the EAB key */ + if (HMAC(EVP_sha256(), eab_key, eab_key_len, sign, sign_len, dig, + &digsz) == NULL) { + warnx("HMAC"); + goto out; + } + + if ((dig64 = base64buf_url((char *)dig, digsz)) == NULL) { + warnx("base64buf_url"); + goto out; + } + + if ((fin = json_fmt_signed(prot64, pay64, dig64)) == NULL) { + warnx("json_fmt_signed"); + goto out; + } else if (writestr(fd, COMM_REQ, fin) < 0) { + goto out; + } + + rc = 1; +out: + + free(fin); + free(dig64); + free(sign); + free(pay64); + free(jwk); + free(prot64); + free(prot); + free(url); + return rc; +} + int -acctproc(int netsock, const char *acctkey, enum keytype keytype) +acctproc(int netsock, const char *acctkey, enum keytype keytype, + const char *eab_kid, const unsigned char *eab_key, int eab_key_len) { FILE *f = NULL; EVP_PKEY *pkey = NULL; @@ -522,7 +612,7 @@ acctproc(int netsock, const char *acctke if ((lval = readop(netsock, COMM_ACCT)) == 0) op = ACCT_STOP; else if (lval == ACCT_SIGN || lval == ACCT_KID_SIGN || - lval == ACCT_THUMBPRINT) + lval == ACCT_THUMBPRINT || lval == ACCT_EAB) op = lval; if (ACCT__MAX == op) { @@ -542,6 +632,12 @@ acctproc(int netsock, const char *acctke if (op_thumbprint(netsock, pkey)) break; warnx("op_thumbprint"); + goto out; + case ACCT_EAB: + if (op_eab(netsock, pkey, eab_kid, eab_key, + eab_key_len)) + break; + warnx("op_eab"); goto out; default: abort(); Index: acme-client.1 =================================================================== RCS file: /cvs/src/usr.sbin/acme-client/acme-client.1,v diff -u -p -u -p -r1.43 acme-client.1 --- acme-client.1 18 Sep 2025 14:13:27 -0000 1.43 +++ acme-client.1 6 May 2026 03:58:31 -0000 @@ -24,6 +24,7 @@ .Nm acme-client .Op Fl Fnrv .Op Fl f Ar configfile +.Op Fl e Ar keyid:key .Ar handle .Sh DESCRIPTION .Nm @@ -67,10 +68,27 @@ location "/.well-known/acme-challenge/*" } .Ed .Pp +Some signing authorities require the client to perform external account +binding to associate the ACME account key generated by +.Nm +with an existing account in a non-ACME system such as a CA customer database. +To enable ACME account binding, the CA operating the ACME server needs to +provide the ACME client with a base64url-encoded MAC key and a key identifier +string, using some mechanism outside of ACME. +These can be supplied to +.Nm +using the +.Fl e +command line argument. +.Pp The options are as follows: .Bl -tag -width Ds .It Fl F Force certificate renewal, regardless of remaining lifetime. +.It Fl e Ar key-id:key +Specify external account binding parameters. +This consists of the key identifier string and the base64url-encoded MAC key +separated by a colon. .It Fl f Ar configfile Specify an alternative configuration file. .It Fl n Index: base64.c =================================================================== RCS file: /cvs/src/usr.sbin/acme-client/base64.c,v diff -u -p -u -p -r1.9 base64.c --- base64.c 24 Jan 2017 13:32:55 -0000 1.9 +++ base64.c 6 May 2026 03:58:31 -0000 @@ -19,6 +19,7 @@ #include #include +#include #include "extern.h" @@ -64,4 +65,70 @@ base64buf_url(const char *data, size_t l } return buf; +} + +static const u_int8_t index_64[128] = { + 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, + 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, + 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, + 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, + 255, 255, 255, 255, 255, 62, 255, 255, 52, 53, + 54, 55, 56, 57, 58, 59, 60, 61, 255, 255, + 255, 255, 255, 255, 255, 0, 1, 2, 3, 4, + 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, + 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, + 25, 255, 255, 255, 255, 63, 255, 26, 27, 28, + 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, + 49, 50, 51, 255, 255, 255, 255, 255 +}; +#define CHAR64(c) ( (c) > 127 ? 255 : index_64[(c)]) + +int +unbase64buf_url(const unsigned char *data, unsigned char **decoded) +{ + unsigned char *out, *bp; + const unsigned char *p; + unsigned char c1, c2, c3, c4; + int len; + + len = strlen(data); + out = malloc(len); + if (out == NULL) + return -1; + + p = data; + bp = out; + while (p < data + len) { + c1 = CHAR64(*p); + /* Invalid data */ + if (c1 == 255) + return -1; + + c2 = CHAR64(*(p + 1)); + if (c2 == 255) + return -1; + + *bp++ = (c1 << 2) | ((c2 & 0x30) >> 4); + if ((p + 2) >= data + len) + break; + + c3 = CHAR64(*(p + 2)); + if (c3 == 255) + return -1; + + *bp++ = ((c2 & 0x0f) << 4) | ((c3 & 0x3c) >> 2); + if ((p + 3) >= data + len) + break; + + c4 = CHAR64(*(p + 3)); + if (c4 == 255) + return -1; + *bp++ = ((c3 & 0x03) << 6) | c4; + + p += 4; + } + + *decoded = out; + return (bp - out); } Index: extern.h =================================================================== RCS file: /cvs/src/usr.sbin/acme-client/extern.h,v diff -u -p -u -p -r1.23 extern.h --- extern.h 23 Feb 2026 10:27:49 -0000 1.23 +++ extern.h 6 May 2026 03:58:31 -0000 @@ -34,6 +34,7 @@ enum acctop { ACCT_SIGN, ACCT_KID_SIGN, ACCT_THUMBPRINT, + ACCT_EAB, ACCT__MAX }; @@ -200,7 +201,8 @@ __BEGIN_DECLS * Start with our components. * These are all isolated and talk to each other using sockets. */ -int acctproc(int, const char *, enum keytype); +int acctproc(int, const char *, enum keytype, const char *, + const unsigned char *, int); int certproc(int, int); int chngproc(int, const char *); int dnsproc(int); @@ -209,7 +211,7 @@ int fileproc(int, const char *, const const char *); int keyproc(int, struct domain_c *); int netproc(int, int, int, int, int, int, int, - struct authority_c *, struct domain_c *); + struct authority_c *, struct domain_c *, int); /* * Debugging functions. @@ -240,6 +242,7 @@ int checkexit_ext(int *, pid_t, enum c */ size_t base64len(size_t); char *base64buf_url(const char *, size_t); +int unbase64buf_url(const unsigned char *, unsigned char **); /* * JSON parsing routines. @@ -259,18 +262,19 @@ 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_newacc(const char *, 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 *, - const char *); -char *json_fmt_protected_kid(const char*, const char *, const char *, - const char *); +char *json_fmt_jwk_rsa(const char *, const char *); +char *json_fmt_jwk_ec(const char *, const char *); +char *json_fmt_protected_jwk(const char *, const char *, + const char *, const char *); +char *json_fmt_protected_kid(const char *, const char *, + const char *, const char *); char *json_fmt_revokecert(const char *); char *json_fmt_thumb_rsa(const char *, const char *); char *json_fmt_thumb_ec(const char *, const char *); char *json_fmt_signed(const char *, const char *, const char *); +char *json_fmt_protected_eab(const char *, const char *); /* * Should we print debugging messages? Index: json.c =================================================================== RCS file: /cvs/src/usr.sbin/acme-client/json.c,v diff -u -p -u -p -r1.23 json.c --- json.c 23 Feb 2026 10:27:49 -0000 1.23 +++ json.c 6 May 2026 03:58:31 -0000 @@ -618,7 +618,7 @@ json_fmt_chkacc(void) * Format the "newAccount" resource request. */ char * -json_fmt_newacc(const char *contact) +json_fmt_newacc(const char *contact, const char *eab) { int c; char *p, *cnt = NULL; @@ -630,6 +630,18 @@ json_fmt_newacc(const char *contact) return NULL; } } + if (eab != NULL) { + char *ecnt = NULL; + c = asprintf(&ecnt, "%s\"externalAccountBinding\": %s, ", + cnt == NULL ? "" : cnt, eab); + if (c == -1) { + warn("asprintf"); + return NULL; + } + + free(cnt); + cnt = ecnt; + } c = asprintf(&p, "{" "%s" @@ -737,23 +749,35 @@ json_fmt_newcert(const char *cert) } /* - * Protected component of json_fmt_signed(). + * Format an RSA public key in JWK format. */ char * -json_fmt_protected_rsa(const char *exp, const char *mod, const char *nce, - const char *url) +json_fmt_jwk_rsa(const char *exp, const char *mod) { int c; char *p; - c = asprintf(&p, "{" - "\"alg\": \"RS256\", " - "\"jwk\": " - "{\"e\": \"%s\", \"kty\": \"RSA\", \"n\": \"%s\"}, " - "\"nonce\": \"%s\", " - "\"url\": \"%s\"" - "}", - exp, mod, nce, url); + c = asprintf(&p, "{\"e\": \"%s\", \"kty\": \"RSA\", \"n\": \"%s\"}", + exp, mod); + if (c == -1) { + warn("asprintf"); + p = NULL; + } + return p; +} + +/* + * Format an EC public key in JWK format. + */ +char * +json_fmt_jwk_ec(const char *x, const char *y) +{ + int c; + char *p; + + c = asprintf(&p, "{\"crv\": \"P-384\", \"kty\": \"EC\"," + " \"x\": \"%s\", \"y\": \"%s\"}", + x, y); if (c == -1) { warn("asprintf"); p = NULL; @@ -765,19 +789,19 @@ json_fmt_protected_rsa(const char *exp, * Protected component of json_fmt_signed(). */ char * -json_fmt_protected_ec(const char *x, const char *y, const char *nce, +json_fmt_protected_jwk(const char *alg, const char *jwk, const char *nce, const char *url) { int c; char *p; c = asprintf(&p, "{" - "\"alg\": \"ES384\", " - "\"jwk\": " - "{\"crv\": \"P-384\", \"kty\": \"EC\", \"x\": \"%s\", " - "\"y\": \"%s\"}, \"nonce\": \"%s\", \"url\": \"%s\"" + "\"alg\": \"%s\", " + "\"jwk\": %s, " + "\"nonce\": \"%s\", " + "\"url\": \"%s\"" "}", - x, y, nce, url); + alg, jwk, nce, url); if (c == -1) { warn("asprintf"); p = NULL; @@ -871,6 +895,28 @@ json_fmt_thumb_ec(const char *x, const c c = asprintf(&p, "{\"crv\":\"P-384\",\"kty\":\"EC\",\"x\":\"%s\"," "\"y\":\"%s\"}", x, y); + if (c == -1) { + warn("asprintf"); + p = NULL; + } + return p; +} + +/* + * Protected component of external account binding. + */ +char * +json_fmt_protected_eab(const char *keyid, const char *url) +{ + int c; + char *p; + + c = asprintf(&p, "{" + "\"alg\": \"HS256\", " + "\"kid\": \"%s\", " + "\"url\": \"%s\"" + "}", + keyid, url); if (c == -1) { warn("asprintf"); p = NULL; Index: main.c =================================================================== RCS file: /cvs/src/usr.sbin/acme-client/main.c,v diff -u -p -u -p -r1.58 main.c --- main.c 23 Feb 2026 10:27:49 -0000 1.58 +++ main.c 6 May 2026 03:58:31 -0000 @@ -43,12 +43,14 @@ main(int argc, char *argv[]) char *certdir = NULL; char *chngdir = NULL, *auth = NULL; char *conffile = CONF_FILE; + char *eab = NULL, *eab_key_enc = NULL; char *tmps, *tmpsd; + unsigned char *eab_key = NULL; int key_fds[2], acct_fds[2], chng_fds[2], cert_fds[2]; int file_fds[2], dns_fds[2], rvk_fds[2]; int force = 0; int c, rc, revocate = 0; - int popts = 0; + int popts = 0, eab_key_len; pid_t pids[COMP__MAX]; size_t ne; @@ -60,11 +62,14 @@ main(int argc, char *argv[]) if (setlocale(LC_CTYPE, "C") == NULL) errx(1, "setlocale"); - while ((c = getopt(argc, argv, "Fnrvf:")) != -1) + while ((c = getopt(argc, argv, "Fe:nrvf:")) != -1) switch (c) { case 'F': force = 1; break; + case 'e': + eab = strdup(optarg); + break; case 'f': if ((conffile = strdup(optarg)) == NULL) err(EXIT_FAILURE, "strdup"); @@ -166,6 +171,22 @@ main(int argc, char *argv[]) ne++; } + if (eab != NULL) { + eab_key_enc = eab; + eab = strsep(&eab_key_enc, ":"); + if (eab_key_enc == NULL) { + warnx("EAB parameters must be in the format keyid:key"); + ne++; + } else { + eab_key_len = unbase64buf_url( + (unsigned char *)eab_key_enc, &eab_key); + if (eab_key_len == -1) { + warnx("unable to decode EAB key"); + ne++; + } + } + } + if (ne > 0) return EXIT_FAILURE; @@ -222,7 +243,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, domain); + revocate, authority, domain, eab != NULL); exit(c ? EXIT_SUCCESS : EXIT_FAILURE); } @@ -267,7 +288,7 @@ main(int argc, char *argv[]) close(file_fds[0]); close(file_fds[1]); c = acctproc(acct_fds[0], authority->account, - authority->keytype); + authority->keytype, eab, eab_key, eab_key_len); exit(c ? EXIT_SUCCESS : EXIT_FAILURE); } @@ -378,6 +399,6 @@ main(int argc, char *argv[]) return rc != COMP__MAX ? EXIT_FAILURE : (c == 2 ? EXIT_SUCCESS : 2); usage: fprintf(stderr, - "usage: acme-client [-Fnrv] [-f configfile] handle\n"); + "usage: acme-client [-Fnrv] [-f configfile] [-e kid:key] handle\n"); return EXIT_FAILURE; } Index: netproc.c =================================================================== RCS file: /cvs/src/usr.sbin/acme-client/netproc.c,v diff -u -p -u -p -r1.46 netproc.c --- netproc.c 23 Feb 2026 10:27:49 -0000 1.46 +++ netproc.c 6 May 2026 03:58:31 -0000 @@ -408,14 +408,30 @@ sreq(struct conn *c, const char *addr, i * Returns non-zero on success. */ static int -donewacc(struct conn *c, const struct capaths *p, const char *contact) +donewacc(struct conn *c, const struct capaths *p, const char *contact, + int eab) { struct jsmnn *j = NULL; int rc = 0; char *req, *detail, *error = NULL, *accturi = NULL; + char *eab_json = NULL; long lc; - if ((req = json_fmt_newacc(contact)) == NULL) + if (eab) { + /* ask acct proc to produce eab json */ + if (writeop(c->fd, COMM_ACCT, ACCT_EAB) <= 0) { + return -1; + } else if (writestr(c->fd, COMM_URL, p->newaccount) <= 0) { + return -1; + } + + /* Now read back the signed payload. */ + if ((eab_json = readstr(c->fd, COMM_REQ)) == NULL) { + return -1; + } + } + + if ((req = json_fmt_newacc(contact, eab_json)) == NULL) warnx("json_fmt_newacc"); else if ((lc = sreq(c, p->newaccount, 0, req, &c->kid)) < 0) warnx("%s: bad comm", p->newaccount); @@ -455,7 +471,8 @@ donewacc(struct conn *c, const struct ca * Returns non-zero on success. */ static int -dochkacc(struct conn *c, const struct capaths *p, const char *contact) +dochkacc(struct conn *c, const struct capaths *p, const char *contact, + int eab) { int rc = 0; char *req, *accturi = NULL; @@ -470,7 +487,7 @@ dochkacc(struct conn *c, const struct ca else if (c->buf.buf == NULL || c->buf.sz == 0) warnx("%s: empty response", p->newaccount); else if (lc == 400) - rc = donewacc(c, p, contact); + rc = donewacc(c, p, contact, eab); else rc = 1; @@ -723,7 +740,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, - struct domain_c *domain) + struct domain_c *domain, int eab) { int rc = 0, retries = 0; size_t i; @@ -805,7 +822,7 @@ netproc(int kfd, int afd, int Cfd, int c c.newnonce = paths.newnonce; /* Check if our account already exists or create it. */ - if (!dochkacc(&c, &paths, authority->contact)) + if (!dochkacc(&c, &paths, authority->contact, eab)) goto out; /*