ACME
ACME RFC 8555 + RFC 9773 ARI client — CA/B 47-day cert phase-in, ARI renewal windows, account key rotation.
The handle owns the lifecycle: directory fetch (RFC 8555 §7.1.1), account create (§7.3), new order (§7.4), challenge dispatch (HTTP-01 / DNS-01 — operator runs the challenge response, the framework drives polling), finalize (§7.4), cert retrieve (§7.4.2). RFC 9773 ARI lets the CA push a renewal window: renewIfDue consults the directory's renewalInfo endpoint with the ACMECertID derived from the cert's AKI + serial. Before suggestedWindow.start the call audits acme.cert.renew.skipped; at or past it the verdict is { shouldRenew: true } and audits acme.cert.renewed.scheduled. Operators wire this with b.network.tls.expiryMonitor so the renewal trigger composes into the existing cert-rotation flow.
Directory URL: NO default. Operator passes the production CA's directory URL (Let's Encrypt prod / Pebble in tests / any RFC 8555-compliant CA). The framework refuses to default to a single CA — the operator's CA choice is policy, not framework decision.
JWS algorithm: ES256 (P-256 + SHA-256) — RFC 8555 §6.2 mandates this for account-key signatures. ACME predates the JOSE PQC algorithm registry; until CAs publish PQC-capable directories, the wire format is classical. The framework's audit chain stays PQC-signed regardless.
Validation: throw at config-time on bad opts; throw on bad CA- response shape (operator-meaningful); audit on cert.* lifecycle events.
b.acme.create(opts) #
{
directory: string, // required — CA directory URL (no default)
accountKey: { privatePem, publicPem, jwk, kty, crv }, // required — ES256 P-256 key material
contact: Array, // optional — mailto: URIs
audit: object, // optional — b.audit sink for cert.* lifecycle events
timeoutMs: number, // default 30s — per-HTTP-call timeout
pollIntervalMs: number, // default 2s — polling interval for order / authorization status
pollMaxMs: number, // default 5min — total polling cap
maxBytes: number, // default 2 MiB — response body cap
}
Build an ACME client handle bound to the operator's chosen directory URL and account key. The returned object exposes fetchDirectory, newAccount, newOrder, finalize, retrieveCert, revokeCert, and renewIfDue (RFC 9773 ARI). The handle owns nonce management, JWS signing (ES256 per RFC 8555 §6.2), polling with an exponential backoff cap, and cert / renewal-window audit emission. Throws AcmeError at config-time on bad opts (missing directory URL, missing accountKey, malformed contact list).
var crypto = require("crypto");
var pair = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
var acme = b.acme.create({
directory: "https://acme-staging-v02.api.letsencrypt.org/directory",
accountKey: {
privatePem: pair.privateKey.export({ type: "pkcs8", format: "pem" }),
publicPem: pair.publicKey.export({ type: "spki", format: "pem" }),
kty: "EC",
crv: "P-256",
},
contact: ["mailto:ops@example.com"],
});
typeof acme.fetchDirectory;
// → "function"
b.acme.create.fetchAuthorization(authUrl) #
POST-as-GET an authorization URL. Returns the parsed authorization object — { status, identifier, challenges, expires, wildcard? } per RFC 8555 §7.5. The challenges array lists every challenge type the CA offers for this authorization (http-01, dns-01, tls-alpn-01); each entry carries { type, url, token, status }.
var auth = await client.fetchAuthorization(order.authorizations[0]);
var http01 = auth.challenges.find(function (c) { return c.type === "http-01"; });
typeof http01.token; // → "string"
typeof http01.url; // → "string"
b.acme.create.notifyChallengeReady(challengeUrl) #
POST an empty JSON object ({}) to a challenge URL to signal that the operator has provisioned the challenge response and the CA may now begin its validation attempt. Returns the updated challenge object (status typically processing immediately after this call).
Per RFC 8555 §7.5.1: the empty-object POST is the operator's commitment that the validation surface is ready. The CA's validation runs asynchronously; poll the authorization with waitForAuthorization afterwards.
await myHttp01Server.add(challenge.token, client.keyAuthorization(challenge.token));
var updated = await client.notifyChallengeReady(challenge.url);
typeof updated.status; // → "string" ("processing" | "valid" | "invalid")
b.acme.create.waitForAuthorization(authUrl, opts?) #
{
intervalMs: number, // default — uses the client's pollIntervalMs
timeoutMs: number, // default — uses the client's pollMaxMs
}
Poll an authorization URL until status === "valid" (success) or status === "invalid" (CA refused). Throws on invalid OR on timeout (default pollMaxMs set at b.acme.create time).
await client.notifyChallengeReady(http01.url);
var auth = await client.waitForAuthorization(authUrl);
auth.status; // → "valid"
b.acme.create.buildCsr(opts) #
{
privateKey: crypto.KeyObject, // required — Node-crypto private key handle
publicKey: crypto.KeyObject, // required — matching public key handle
domains: Array, // required — non-empty; first is CN, all are SANs
}
Build a PKCS#10 (RFC 2986) Certificate Signing Request and sign it with the leaf private key. Subject is CN=; every domain (including the first) appears as a dNSName in the Subject Alternative Name extension. Returns a PEM-encoded -----BEGIN CERTIFICATE REQUEST----- block ready to feed to finalize(order, csrPem).
Supports ECDSA P-256 / P-384 leaf keys (signed with ecdsa-with-SHA256 / ecdsa-with-SHA384 respectively) and RSA 2048 / 3072 / 4096 (signed with sha256WithRSAEncryption). Ed25519 is rejected at the CSR layer because CA support is uneven; operators wanting Ed25519 certs build the CSR externally.
var pair = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
var csr = client.buildCsr({
privateKey: pair.privateKey,
publicKey: pair.publicKey,
domains: ["example.com", "www.example.com"],
});
csr.indexOf("-----BEGIN CERTIFICATE REQUEST-----"); // → 0
b.acme.create.revokeCert(certDerBuf, opts?) #
{
reason: number, // RFC 5280 §5.3.1 reason code; default 0 (unspecified)
useCertKey: boolean, // RESERVED — cert-key-signed revocation is not yet
// implemented; account-key signing (the default)
// covers mainstream CAs. Passing true throws.
certPrivateKey: KeyObject, // RESERVED — consumed only by the cert-key path above
}
RFC 8555 §7.6 — revoke a previously issued certificate. Accepts the DER-encoded cert (base64url-encoded automatically) plus an optional reason code per RFC 5280 §5.3.1 (0=unspecified, 1=keyCompromise, 3=affiliationChanged, 4=superseded, 5=cessationOfOperation). Signs with the account key — the only supported path today, and sufficient for mainstream CAs. (The cert-key-signed variant — useCertKey / certPrivateKey — is reserved and not yet implemented; passing useCertKey:true throws.)
await acme.revokeCert(certDerBuffer, { reason: 4 }); // 4 = superseded
b.acme.create.accountKeyRollover(newPrivateKey) #
RFC 8555 §7.3.5 — rotate the account key. Inner JWS payload commits the old + new public JWKs; outer JWS signed by old key authorizes the rotation. After success, future signed-posts use the new key. The instance is mutated; callers using multiple acme instances must rotate each independently.
var newKey = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" }).privateKey;
await acme.accountKeyRollover(newKey);
b.acme.create.deactivateAccount() #
RFC 8555 §7.3.6 — deactivate the account. The CA refuses subsequent requests signed by this account key. Irreversible — operators must register a new account via newAccount() afterwards.
await acme.deactivateAccount();
b.acme.create.tlsAlpn01KeyAuthorization(token) #
RFC 8737 — TLS-ALPN-01 challenge variant. Returns the SHA-256 digest of the key authorization (the value the operator embeds in the acme-tls/1 SNI cert's id-pe-acmeIdentifier extension). Operator wires the digest into a one-off cert presented during the CA's ALPN-ALPN-1 probe. Pairs with HTTP-01 + DNS-01 as the three RFC 8555 / RFC 8737 challenge types.
var digest = acme.tlsAlpn01KeyAuthorization(challengeToken);
// embed `digest` in the acme-tls/1 cert's acmeIdentifier extension.
b.acme.create.listProfiles() #
Returns the CA-advertised certificate profile catalog as { name: description } per draft-aaron-acme-profiles. Operators pass the chosen name through newOrder({ profile: name }); CAs use the profile to select certificate lifetime + key-usage + validation rigor. As CA/B Forum 47-day cert TTLs phase in (Mar 2026 ballot SC-081v3), profile-name vocabulary becomes the operator-facing handle for "long-lived" vs "47-day" vs "short- lived". Returns an empty object when the directory has no meta.profiles map (CA hasn't adopted the draft). Refreshes the directory cache when none has been fetched yet.
await acme.fetchDirectory();
var profiles = acme.listProfiles();
// → { "default": "Standard 90-day certificate",
// "shortlived": "47-day certificate (CA/B Forum SC-081v3)",
// "tlsserver": "TLS server profile with Must-Staple" }
await acme.newOrder({ identifiers: [{ type: "dns", value: "example.com" }],
profile: "shortlived" });
b.acme.create.dnsAccount01ChallengeRecord(token, opts?) #
{
identifier: string, // host being validated (required)
ttl: number, // suggested DNS TTL in seconds; default: 60
}
Build the DNS TXT record an operator publishes to satisfy a dns-account-01 challenge per draft-ietf-acme-dns-account-label. Unlike dns-01 (record at _acme-challenge.), dns-account-01 scopes the record by account so the same domain can be validated from multiple ACME accounts without record-name collisions; the record name becomes _ where accountLabel is the SHA-256 truncated-base32 of the account URL.
Returns { name, value, ttl } where name is the FQDN to publish the TXT record at (with operator-supplied identifier substituted in) and value is the SHA-256 of the key authorization in unpadded base64url (same as dns-01). Refuses when newAccount has not run (no accountUrl yet); refuses non-string token / identifier.
await acme.newAccount({ contact: ["mailto:ops@example.com"] });
var rec = acme.dnsAccount01ChallengeRecord("token123", {
identifier: "example.com",
});
// rec.name → "_._acme-challenge.example.com"
// rec.value → ")>"
// rec.ttl → 60
Last updated 2026-08-08T16:39:49.652Z by seeder.