From: Jonathan Matthew Subject: acme-client: support for external account binding To: tech@openbsd.org Cc: sthen@openbsd.org, florian@openbsd.org Date: Fri, 1 May 2026 14:09:11 +1000 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. The CA we use at work does ACME, but it requires this external account binding before it will do anything, so I've implemented support for it in acme-client in the diff below. Since it's only part of the new account process, when you're registering your account key with the ACME service, I've made this a command line argument rather than something you put in the config file. It's something you'd do during initial setup of a new system, so I'm not really concerned about exposing the key details to untrusted users on the system. Other clients don't seem to be either. While building the new account request, the net process asks the account process to generate the binding JWS using the CA-provided key, includes that in the request, and then asks the account process to sign the whole thing. Seems a bit fiddly but, to my mind, better than having the account process add it into the new account request itself. Sadly this adds yet another base64 decoder implementation, because there wasn't one using the base64url encoding that acme-client could get to. This one is based on the libc bcrypt implementation, because it's short and uncomplicated and doesn't care about padding. I've tested this against pebble and digicert's acme service, and the existing regress tests pass. I can also add regress test coverage for this feature later. ok? 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 30 Apr 2026 05:10:56 -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,94 @@ 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 = NULL; + 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 */ + dig = malloc(eab_key_len); + HMAC(EVP_sha256(), eab_key, eab_key_len, sign, sign_len, dig, &digsz); + + 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(dig); + 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 +610,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 +630,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 30 Apr 2026 05:10:56 -0000 @@ -67,10 +67,25 @@ 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 MAC key and a key identifier, 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 MAC key and key identifier for external account binding. .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 30 Apr 2026 05:10:56 -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 30 Apr 2026 05:10:56 -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 30 Apr 2026 05:10:56 -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,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); + + free(cnt); + cnt = ecnt; + } c = asprintf(&p, "{" "%s" @@ -737,23 +745,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 +785,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 +891,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 30 Apr 2026 05:10:56 -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; 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, i, eab_len, 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:k: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,27 @@ main(int argc, char *argv[]) ne++; } + if (eab != NULL) { + eab_len = strlen(eab); + for (i = 0; i < eab_len; i++) { + if (eab[i] == ':') { + eab[i] = '\0'; + break; + } + } + + if (i == eab_len) { + warnx("EAB data must be in the format keyid:key"); + ne++; + } + + eab_key_len = unbase64buf_url((unsigned char *)eab + i + 1, &eab_key); + if (eab_key_len == -1) { + warnx("unable to decode EAB key"); + ne++; + } + } + if (ne > 0) return EXIT_FAILURE; @@ -222,7 +248,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 +293,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 +404,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 30 Apr 2026 05:10:56 -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,10 +487,12 @@ 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; + rc = donewacc(c, p, contact, eab); + if (c->kid == NULL) rc = 0; else { @@ -723,7 +742,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 +824,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; /*