@acme-js/acme
v0.2.0
Published
Minimal, embeddable ACME (RFC 8555) client for Node.js. Zero runtime dependencies, dns-01 only, plugin-based DNS providers.
Maintainers
Readme
@acme-js/acme
Minimal, embeddable ACME (RFC 8555) client for Node.js — a JS alternative to
acme.sh. Designed to be embedded by larger host applications to issue,
maintain, and renew certificates via dns-01 validation.
- Zero runtime dependencies. Uses only
node:crypto(Web Crypto), nativefetch, andnode:dns. - Node 20+, ESM-only.
- dns-01 only with a plugin-based DNS provider model.
- Stateless API. Every call takes keys + URLs explicitly. The library never owns timers, files, or persistent state — the host owns lifecycle.
- Hand-rolled PKCS#10 CSR encoder (~150 LOC) — the only mandatory non-native piece.
- Provider-agnostic core. Adding a CA is one ~10-line file; adding a DNS provider is one factory.
- Hardened by default. HTTPS + same-host URL allowlist, per-request
timeouts,
AbortSignalcancellation, strict base64url decoding.
Documentation
📖 Full docs in ./docs/ — start there.
| If you want to… | Read | |---|---| | Get started in 5 minutes | Guide → Quickstart | | Look up a function's signature | API reference | | Configure Cloudflare / DigitalOcean / Porkbun / ZeroSSL | Providers | | Write a DNS provider for Route 53 / Gandi / etc. | Custom DNS provider guide | | Debug a failing issuance | Troubleshooting |
Install
pnpm add @acme-js/acme
# or
npm install @acme-js/acme
# or
yarn add @acme-js/acmeRequires Node 20 or newer. ESM-only (import — no require).
Quickstart
Let's Encrypt staging + Cloudflare, ~25 lines:
import {
AcmeTransport,
DIRECTORY_LE_STAGING,
createAccount,
generateAccountKey,
generateCertKey,
createCsr,
issueCertificate,
} from '@acme-js/acme'
import { cloudflare } from '@acme-js/acme/dns/cloudflare'
// --- one-time account setup (persist the JWK after this!) ---
const accountKeyPair = await generateAccountKey()
const accountJwk = await crypto.subtle.exportKey('jwk', accountKeyPair.publicKey)
const transport = new AcmeTransport({
directoryUrl: DIRECTORY_LE_STAGING,
accountKey: accountKeyPair.privateKey,
accountJwk,
})
const account = await createAccount({
transport,
contact: ['mailto:[email protected]'],
termsOfServiceAgreed: true,
})
// 💾 Persist: accountJwk, accountKeyPair.privateKey (as JWK), account.kid
// --- per-issue ---
const certKeyPair = await generateCertKey()
const csr = await createCsr({
domains: ['example.com', '*.example.com'],
privateKey: certKeyPair.privateKey,
publicKey: certKeyPair.publicKey,
})
const cert = await issueCertificate({
transport,
domains: ['example.com', '*.example.com'],
certKey: certKeyPair.privateKey,
csr: csr.der,
dnsProvider: cloudflare({ apiToken: process.env.CF_API_TOKEN! }),
})
console.log(cert.cert) // PEM leaf
console.log(cert.chain) // PEM intermediates
console.log(cert.notAfter) // DateSwap DIRECTORY_LE_STAGING for DIRECTORY_LE_PROD when ready. Staging
issues untrusted certs but has generous rate limits — perfect for testing.
See the guide for workflows: persisting state, renewal, resumable orders, wildcards, cancellation, logging, custom fetch, and more.
Supported providers
ACME / CA (directory URL constants)
| Provider | Constant | EAB |
|---|---|---|
| Let's Encrypt production | DIRECTORY_LE_PROD | optional |
| Let's Encrypt staging | DIRECTORY_LE_STAGING | optional |
| ZeroSSL | DIRECTORY_ZEROSSL (from acmejs/acme/zerossl) | required |
ZeroSSL accepts user-supplied EAB credentials OR programmatic fetching:
// Option A: user-supplied (recommended)
import { DIRECTORY_ZEROSSL } from '@acme-js/acme/acme/zerossl'
await createAccount({
transport,
eab: { kid: process.env.ZEROSSL_EAB_KID!, hmacKey: process.env.ZEROSSL_EAB_HMAC_KEY! },
})
// Option B: fetched programmatically
import { DIRECTORY_ZEROSSL, getZeroSslEabCredentials } from '@acme-js/acme/acme/zerossl'
const eab = await getZeroSslEabCredentials({ apiKey: process.env.ZEROSSL_ACCESS_KEY! })
await createAccount({ transport, eab })DNS (challenge providers)
| Provider | Import path | Auth |
|---|---|---|
| Cloudflare | acmejs/dns/cloudflare | API Token (recommended) or Global API Key |
| DigitalOcean | acmejs/dns/digitalocean | Bearer API token |
| Porkbun | acmejs/dns/porkbun | API Key + Secret Key (in POST body) |
A minimal in-memory provider (for tests) is ~30 lines; a full real-DNS provider is ~100–200 lines (auth, zone detection, error handling, propagation polling). See Custom DNS provider guide.
Security model
acmejs is designed to be embedded in larger applications, so the threat model matters up front. Highlights:
- HTTPS + same-host by default. Server-returned URLs (
finalize,certificate,challenges[].url, etc.) must be HTTPS and on the same host as the directory — a compromised CA cannot redirect signed POSTs to internal services (SSRF). PassallowCrossHost: trueto opt out (Pebble, multi-host CAs). - Per-request timeout (30s default) via
AbortController. AbortSignalcancellation on every polling function.- Strict base64url decoding for HMAC keys and signatures.
- Non-extractable cert keys by default (
generateCertKeyExtractable()if you need to persist). - Domain identifiers validated before flowing into DNS provider URLs.
- ZeroSSL access key sent in POST body, not URL query string.
setTxtis idempotent across all bundled DNS providers. Each one wipes pre-existing TXT records at the challenge hostname before creating the new one, so partial-failure retries and multi-identifier orders (apex + wildcard sharing_acme-challenge.<zone>) don't leave stale records that Boulder reads as(and N more)validation failures.- Auth errors surface loudly in all DNS providers (Cloudflare 401/403/429, DigitalOcean 401, Porkbun body-level error codes) instead of silently masquerading as "no zone found".
- Propagation timings are user-configurable (
propagationDelayMsetc.) on every bundled provider — important when the CA's resolver caches stale state longer than the provider's authoritative NS.
Full details: Guide → Security model.
Testing
pnpm test # unit tests (RFC vectors + mocked fetch)
pnpm test:integration # Pebble integration (requires docker-compose)Unit tests use fixed RFC vectors (RFC 7638 Appendix A thumbprint) and an
openssl req reference CSR. Provider tests mock fetch — no network. See
Troubleshooting → Pebble
for the integration setup.
License
MIT
