BREAKING CHANGE(acme): Replace external acme-client with a built-in RFC8555-compliant ACME implementation and update public APIs accordingly

This commit is contained in:
2026-02-15 20:20:46 +00:00
parent 3fa34fa373
commit cf4b758800
31 changed files with 4717 additions and 3530 deletions

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartacme',
version: '8.0.0',
version: '9.0.0',
description: 'A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power.'
}

View File

@@ -0,0 +1,45 @@
import type { AcmeHttpClient } from './acme.classes.http-client.js';
import type { IAcmeAccount, IAcmeAccountCreateRequest } from './acme.interfaces.js';
/**
* ACME account management - registration and key management
*/
export class AcmeAccount {
private httpClient: AcmeHttpClient;
private accountUrl: string | null = null;
constructor(httpClient: AcmeHttpClient) {
this.httpClient = httpClient;
}
/**
* Register or retrieve an ACME account.
* Uses JWK (not kid) since account URL is not yet known.
* Captures account URL from Location header for subsequent requests.
*/
async create(request: IAcmeAccountCreateRequest): Promise<IAcmeAccount> {
const dir = await this.httpClient.getDirectory();
const response = await this.httpClient.signedRequest(dir.newAccount, request, {
useJwk: true,
});
// Capture account URL from Location header (used as kid for future requests)
const location = response.headers['location'];
if (location) {
this.accountUrl = location;
this.httpClient.kid = location;
}
return response.data as IAcmeAccount;
}
/**
* Get the account URL (kid) for use in JWS headers
*/
getAccountUrl(): string {
if (!this.accountUrl) {
throw new Error('Account not yet created - call create() first');
}
return this.accountUrl;
}
}

View File

@@ -0,0 +1,45 @@
import * as crypto from 'node:crypto';
import { AcmeCrypto } from './acme.classes.crypto.js';
import type { AcmeHttpClient } from './acme.classes.http-client.js';
import type { IAcmeChallenge } from './acme.interfaces.js';
/**
* ACME challenge operations - key authorization computation and challenge completion
*/
export class AcmeChallengeManager {
private httpClient: AcmeHttpClient;
private accountKeyPem: string;
constructor(httpClient: AcmeHttpClient, accountKeyPem: string) {
this.httpClient = httpClient;
this.accountKeyPem = accountKeyPem;
}
/**
* Compute the key authorization for a challenge.
* For http-01: returns `token.thumbprint`
* For dns-01: returns `base64url(sha256(token.thumbprint))`
*
* This is a synchronous, pure-crypto computation.
*/
getKeyAuthorization(challenge: IAcmeChallenge): string {
const jwk = AcmeCrypto.getJwk(this.accountKeyPem);
const thumbprint = AcmeCrypto.getJwkThumbprint(jwk);
const keyAuth = `${challenge.token}.${thumbprint}`;
if (challenge.type === 'dns-01') {
// DNS-01 uses base64url(SHA-256(keyAuthorization))
return crypto.createHash('sha256').update(keyAuth).digest().toString('base64url');
}
// HTTP-01 and others use the raw key authorization
return keyAuth;
}
/**
* Notify the ACME server to validate a challenge (POST {} to challenge URL)
*/
async complete(challenge: IAcmeChallenge): Promise<void> {
await this.httpClient.signedRequest(challenge.url, {});
}
}

View File

@@ -0,0 +1,99 @@
import { AcmeCrypto } from './acme.classes.crypto.js';
import { ACME_DIRECTORY_URLS } from './acme.classes.directory.js';
import { AcmeHttpClient, type TAcmeLogger } from './acme.classes.http-client.js';
import { AcmeAccount } from './acme.classes.account.js';
import { AcmeOrderManager } from './acme.classes.order.js';
import { AcmeChallengeManager } from './acme.classes.challenge.js';
import type {
IAcmeAccount,
IAcmeAccountCreateRequest,
IAcmeAuthorization,
IAcmeChallenge,
IAcmeIdentifier,
IAcmeOrder,
} from './acme.interfaces.js';
export interface IAcmeClientOptions {
directoryUrl: string;
accountKeyPem: string;
logger?: TAcmeLogger;
}
/**
* Top-level ACME client facade.
* Composes HTTP transport, account management, order lifecycle, and challenge handling.
*/
export class AcmeClient {
private httpClient: AcmeHttpClient;
private account: AcmeAccount;
private orderManager: AcmeOrderManager;
private challengeManager: AcmeChallengeManager;
/** Well-known CA directory URLs */
static directory = ACME_DIRECTORY_URLS;
/** Crypto utilities */
static crypto = AcmeCrypto;
constructor(options: IAcmeClientOptions) {
this.httpClient = new AcmeHttpClient(options.directoryUrl, options.accountKeyPem, options.logger);
this.account = new AcmeAccount(this.httpClient);
this.orderManager = new AcmeOrderManager(this.httpClient);
this.challengeManager = new AcmeChallengeManager(this.httpClient, options.accountKeyPem);
}
/**
* Register or retrieve an ACME account
*/
async createAccount(request: IAcmeAccountCreateRequest): Promise<IAcmeAccount> {
return this.account.create(request);
}
/**
* Create a new certificate order
*/
async createOrder(opts: { identifiers: IAcmeIdentifier[] }): Promise<IAcmeOrder> {
return this.orderManager.create(opts);
}
/**
* Get all authorizations for an order
*/
async getAuthorizations(order: IAcmeOrder): Promise<IAcmeAuthorization[]> {
return this.orderManager.getAuthorizations(order);
}
/**
* Compute the key authorization string for a challenge (sync)
*/
getChallengeKeyAuthorization(challenge: IAcmeChallenge): string {
return this.challengeManager.getKeyAuthorization(challenge);
}
/**
* Notify the ACME server to validate a challenge
*/
async completeChallenge(challenge: IAcmeChallenge): Promise<void> {
return this.challengeManager.complete(challenge);
}
/**
* Poll an ACME resource until it reaches valid/ready status
*/
async waitForValidStatus(item: { url: string }): Promise<any> {
return this.orderManager.waitForValidStatus(item);
}
/**
* Finalize an order by submitting the CSR
*/
async finalizeOrder(order: IAcmeOrder, csrPem: string): Promise<void> {
return this.orderManager.finalize(order, csrPem);
}
/**
* Download the certificate chain (PEM)
*/
async getCertificate(order: IAcmeOrder): Promise<string> {
return this.orderManager.getCertificate(order);
}
}

View File

@@ -0,0 +1,220 @@
import * as crypto from 'node:crypto';
import type { IAcmeCsrOptions } from './acme.interfaces.js';
/**
* All cryptographic operations for the ACME protocol.
* Uses node:crypto for key gen, JWK, JWS signing.
* Uses @peculiar/x509 for CSR generation (no native Node.js CSR API).
*/
export class AcmeCrypto {
/**
* Generate an RSA private key in PEM format
*/
static createRsaPrivateKey(modulusLength = 2048): string {
const { privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
return privateKey;
}
/**
* Export public JWK from PEM private key, keys sorted alphabetically per RFC 7638
*/
static getJwk(keyPem: string): Record<string, string> {
const keyObj = crypto.createPublicKey(keyPem);
const jwk = keyObj.export({ format: 'jwk' }) as Record<string, any>;
if (jwk.kty === 'RSA') {
return { e: jwk.e, kty: jwk.kty, n: jwk.n };
} else if (jwk.kty === 'EC') {
return { crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y };
}
throw new Error(`Unsupported key type: ${jwk.kty}`);
}
/**
* Compute JWK Thumbprint (SHA-256, base64url) per RFC 7638
*/
static getJwkThumbprint(jwk: Record<string, string>): string {
let canonical: string;
if (jwk.kty === 'RSA') {
canonical = JSON.stringify({ e: jwk.e, kty: jwk.kty, n: jwk.n });
} else if (jwk.kty === 'EC') {
canonical = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
} else {
throw new Error(`Unsupported key type: ${jwk.kty}`);
}
const hash = crypto.createHash('sha256').update(canonical).digest();
return hash.toString('base64url');
}
/**
* Create a flattened JWS for ACME requests (RFC 7515)
* payload=null means POST-as-GET (empty string payload)
*/
static createJws(
keyPem: string,
url: string,
payload: any | null,
options: { nonce: string; kid?: string; jwk?: Record<string, string> },
): { protected: string; payload: string; signature: string } {
const header: Record<string, any> = {
alg: AcmeCrypto.getAlg(keyPem),
nonce: options.nonce,
url,
};
if (options.kid) {
header.kid = options.kid;
} else if (options.jwk) {
header.jwk = options.jwk;
} else {
header.jwk = AcmeCrypto.getJwk(keyPem);
}
const protectedB64 = Buffer.from(JSON.stringify(header)).toString('base64url');
const payloadB64 =
payload !== null ? Buffer.from(JSON.stringify(payload)).toString('base64url') : '';
const signingInput = `${protectedB64}.${payloadB64}`;
const keyObj = crypto.createPrivateKey(keyPem);
const alg = AcmeCrypto.getAlg(keyPem);
let signature: Buffer;
if (alg.startsWith('RS')) {
signature = crypto.sign('sha256', Buffer.from(signingInput), keyObj);
} else if (alg.startsWith('ES')) {
signature = crypto.sign('sha256', Buffer.from(signingInput), {
key: keyObj,
dsaEncoding: 'ieee-p1363',
});
} else {
throw new Error(`Unsupported algorithm: ${alg}`);
}
return {
protected: protectedB64,
payload: payloadB64,
signature: signature.toString('base64url'),
};
}
/**
* Create a CSR (PKCS#10) via @peculiar/x509
* Returns [privateKeyPem, csrPem]
*/
static async createCsr(
options: IAcmeCsrOptions,
existingKeyPem?: string,
): Promise<[string, string]> {
const x509 = await import('@peculiar/x509');
const { webcrypto } = crypto;
x509.cryptoProvider.set(webcrypto as any);
let keys: CryptoKeyPair;
let keyPem: string;
if (existingKeyPem) {
keys = await AcmeCrypto.importKeyPairToWebCrypto(existingKeyPem, webcrypto);
keyPem = existingKeyPem;
} else {
keys = (await webcrypto.subtle.generateKey(
{
name: 'RSASSA-PKCS1-v1_5',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-256',
},
true,
['sign', 'verify'],
)) as CryptoKeyPair;
const pkcs8 = await webcrypto.subtle.exportKey('pkcs8', keys.privateKey);
const b64 = Buffer.from(pkcs8).toString('base64');
const lines = b64.match(/.{1,64}/g)!;
keyPem = `-----BEGIN PRIVATE KEY-----\n${lines.join('\n')}\n-----END PRIVATE KEY-----\n`;
}
// Collect all DNS names for SAN (CN is always included)
const sanNames: string[] = [options.commonName];
if (options.altNames) {
for (const name of options.altNames) {
if (!sanNames.includes(name)) {
sanNames.push(name);
}
}
}
const csr = await x509.Pkcs10CertificateRequestGenerator.create({
name: `CN=${options.commonName}`,
keys,
signingAlgorithm: { name: 'RSASSA-PKCS1-v1_5' },
extensions: [
new x509.SubjectAlternativeNameExtension(
sanNames.map((name) => ({ type: 'dns' as const, value: name })),
),
],
});
// Convert to PEM
const csrPem = csr.toString('pem');
return [keyPem, csrPem];
}
/**
* Convert PEM to raw DER Buffer (strip headers, decode base64)
*/
static pemToBuffer(pem: string): Buffer {
const lines = pem
.split('\n')
.filter((line) => !line.startsWith('-----') && line.trim().length > 0);
return Buffer.from(lines.join(''), 'base64');
}
/**
* Determine JWS algorithm from key type
*/
private static getAlg(keyPem: string): string {
const keyObj = crypto.createPrivateKey(keyPem);
const keyType = keyObj.asymmetricKeyType;
if (keyType === 'rsa') return 'RS256';
if (keyType === 'ec') {
const details = keyObj.asymmetricKeyDetails;
if (details?.namedCurve === 'prime256v1' || details?.namedCurve === 'P-256') return 'ES256';
if (details?.namedCurve === 'secp384r1' || details?.namedCurve === 'P-384') return 'ES384';
return 'ES256';
}
throw new Error(`Unsupported key type: ${keyType}`);
}
/**
* Import a PEM private key into WebCrypto as a CryptoKeyPair
*/
private static async importKeyPairToWebCrypto(
keyPem: string,
wc: typeof crypto.webcrypto,
): Promise<CryptoKeyPair> {
const keyObj = crypto.createPrivateKey(keyPem);
const pkcs8Der = keyObj.export({ type: 'pkcs8', format: 'der' });
const privateKey = await wc.subtle.importKey(
'pkcs8',
pkcs8Der,
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
true,
['sign'],
);
const pubKeyObj = crypto.createPublicKey(keyPem);
const spkiDer = pubKeyObj.export({ type: 'spki', format: 'der' });
const publicKey = await wc.subtle.importKey(
'spki',
spkiDer,
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
true,
['verify'],
);
return { privateKey: privateKey as unknown as CryptoKey, publicKey: publicKey as unknown as CryptoKey };
}
}

View File

@@ -0,0 +1,13 @@
/**
* ACME directory URL constants for well-known CAs
*/
export const ACME_DIRECTORY_URLS = {
letsencrypt: {
production: 'https://acme-v02.api.letsencrypt.org/directory',
staging: 'https://acme-staging-v02.api.letsencrypt.org/directory',
},
buypass: {
production: 'https://api.buypass.com/acme/directory',
staging: 'https://api.test4.buypass.no/acme/directory',
},
} as const;

View File

@@ -0,0 +1,55 @@
/**
* Structured ACME protocol error with RFC 8555 fields.
* Provides type URN, subproblems, Retry-After, and retryability classification.
*/
export class AcmeError extends Error {
public readonly status: number;
public readonly type: string;
public readonly detail: string;
public readonly subproblems: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>;
public readonly url: string;
public readonly retryAfter: number;
constructor(options: {
message?: string;
status: number;
type?: string;
detail?: string;
subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>;
url?: string;
retryAfter?: number;
}) {
const type = options.type || '';
const detail = options.detail || '';
const url = options.url || '';
const msg =
options.message ||
`ACME error: ${type || 'unknown'} (HTTP ${options.status}) at ${url || 'unknown'} - ${detail || 'no detail'}`;
super(msg);
this.name = 'AcmeError';
this.status = options.status;
this.type = type;
this.detail = detail;
this.subproblems = options.subproblems || [];
this.url = url;
this.retryAfter = options.retryAfter || 0;
}
/**
* True for HTTP 429 or ACME rateLimited type URN
*/
get isRateLimited(): boolean {
return this.status === 429 || this.type === 'urn:ietf:params:acme:error:rateLimited';
}
/**
* True for transient/retryable errors: 429, 503, 5xx, badNonce.
* False for definitive client errors: 400 (non-badNonce), 403, 404, 409.
*/
get isRetryable(): boolean {
if (this.type === 'urn:ietf:params:acme:error:badNonce') return true;
if (this.status === 429 || this.status === 503) return true;
if (this.status >= 500) return true;
return false;
}
}

View File

@@ -0,0 +1,236 @@
import * as https from 'node:https';
import * as http from 'node:http';
import { AcmeCrypto } from './acme.classes.crypto.js';
import { AcmeError } from './acme.classes.error.js';
import type { IAcmeDirectory, IAcmeHttpResponse } from './acme.interfaces.js';
export type TAcmeLogger = (level: string, message: string, data?: any) => void;
/**
* JWS-signed HTTP transport for ACME protocol.
* Handles nonce management, bad-nonce retries, and signed requests.
*/
export class AcmeHttpClient {
private directoryUrl: string;
private accountKeyPem: string;
private directory: IAcmeDirectory | null = null;
private nonce: string | null = null;
public kid: string | null = null;
private logger?: TAcmeLogger;
constructor(directoryUrl: string, accountKeyPem: string, logger?: TAcmeLogger) {
this.directoryUrl = directoryUrl;
this.accountKeyPem = accountKeyPem;
this.logger = logger;
}
private log(level: string, message: string, data?: any): void {
if (this.logger) {
this.logger(level, message, data);
}
}
/**
* GET the ACME directory (cached after first call)
*/
async getDirectory(): Promise<IAcmeDirectory> {
if (this.directory) return this.directory;
const response = await this.httpRequest(this.directoryUrl, 'GET');
if (response.status !== 200) {
throw new AcmeError({
status: response.status,
type: response.data?.type || '',
detail: `Failed to fetch ACME directory`,
url: this.directoryUrl,
});
}
this.directory = response.data as IAcmeDirectory;
return this.directory;
}
/**
* Fetch a fresh nonce via HEAD to newNonce
*/
async getNonce(): Promise<string> {
if (this.nonce) {
const n = this.nonce;
this.nonce = null;
return n;
}
const dir = await this.getDirectory();
const response = await this.httpRequest(dir.newNonce, 'HEAD');
const nonce = response.headers['replay-nonce'];
if (!nonce) {
throw new Error('No replay-nonce header in newNonce response');
}
return nonce;
}
/**
* Send a JWS-signed POST request to an ACME endpoint.
* Handles nonce rotation and bad-nonce retries (up to 5).
* payload=null means POST-as-GET.
*/
async signedRequest(
url: string,
payload: any | null,
options?: { useJwk?: boolean },
): Promise<IAcmeHttpResponse> {
const maxRetries = 5;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const nonce = await this.getNonce();
const jwsOptions: { nonce: string; kid?: string; jwk?: Record<string, string> } = { nonce };
if (options?.useJwk) {
jwsOptions.jwk = AcmeCrypto.getJwk(this.accountKeyPem);
} else if (this.kid) {
jwsOptions.kid = this.kid;
} else {
jwsOptions.jwk = AcmeCrypto.getJwk(this.accountKeyPem);
}
const jws = AcmeCrypto.createJws(this.accountKeyPem, url, payload, jwsOptions);
const body = JSON.stringify(jws);
const response = await this.httpRequest(url, 'POST', body, {
'Content-Type': 'application/jose+json',
});
// Save nonce from response for reuse
if (response.headers['replay-nonce']) {
this.nonce = response.headers['replay-nonce'];
}
this.log('debug', `ACME request: POST ${url}${response.status}`);
// Retry on bad-nonce
if (
response.status === 400 &&
response.data?.type === 'urn:ietf:params:acme:error:badNonce'
) {
this.log('debug', `Bad nonce on attempt ${attempt + 1}, retrying`);
if (attempt < maxRetries) {
this.nonce = null; // Force fresh nonce
continue;
}
}
// Throw on error responses
if (response.status >= 400) {
const retryAfterRaw = response.headers['retry-after'];
let retryAfter = 0;
if (retryAfterRaw) {
const parsed = parseInt(retryAfterRaw, 10);
if (!isNaN(parsed)) {
retryAfter = parsed;
}
}
const acmeError = new AcmeError({
status: response.status,
type: response.data?.type || '',
detail: response.data?.detail || JSON.stringify(response.data),
subproblems: response.data?.subproblems,
url,
retryAfter,
});
if (acmeError.isRateLimited) {
this.log('warn', `RATE LIMITED: ${url} (HTTP ${response.status}), Retry-After: ${retryAfter}s`, {
type: acmeError.type,
detail: acmeError.detail,
retryAfter,
});
} else {
this.log('warn', `ACME error: ${url} (HTTP ${response.status})`, {
type: acmeError.type,
detail: acmeError.detail,
});
}
throw acmeError;
}
return response;
}
throw new Error('Max bad-nonce retries exceeded');
}
/**
* Raw HTTP request using native node:https
*/
private httpRequest(
url: string,
method: string,
body?: string,
headers?: Record<string, string>,
): Promise<IAcmeHttpResponse> {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const isHttps = urlObj.protocol === 'https:';
const lib = isHttps ? https : http;
const requestHeaders: Record<string, string | number> = {
...headers,
'User-Agent': 'smartacme-acme-client/1.0',
};
if (body) {
requestHeaders['Content-Length'] = Buffer.byteLength(body);
}
const options: https.RequestOptions = {
hostname: urlObj.hostname,
port: urlObj.port || (isHttps ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method,
headers: requestHeaders,
};
const req = lib.request(options, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
const responseBody = Buffer.concat(chunks).toString('utf-8');
// Normalize headers to lowercase single-value
const responseHeaders: Record<string, string> = {};
for (const [key, value] of Object.entries(res.headers)) {
if (typeof value === 'string') {
responseHeaders[key.toLowerCase()] = value;
} else if (Array.isArray(value)) {
responseHeaders[key.toLowerCase()] = value[0];
}
}
// Parse JSON if applicable, otherwise return raw string
let data: any;
const contentType = responseHeaders['content-type'] || '';
if (contentType.includes('json')) {
try {
data = JSON.parse(responseBody);
} catch {
data = responseBody;
}
} else {
data = responseBody;
}
resolve({
status: res.statusCode || 0,
headers: responseHeaders,
data,
});
});
});
req.on('error', reject);
req.setTimeout(30000, () => {
req.destroy(new Error('Request timeout'));
});
if (body) req.write(body);
req.end();
});
}
}

View File

@@ -0,0 +1,125 @@
import { AcmeCrypto } from './acme.classes.crypto.js';
import { AcmeError } from './acme.classes.error.js';
import type { AcmeHttpClient } from './acme.classes.http-client.js';
import type {
IAcmeAuthorization,
IAcmeIdentifier,
IAcmeOrder,
} from './acme.interfaces.js';
/**
* ACME order lifecycle management.
* Handles order creation, authorization retrieval, finalization, and certificate download.
*/
export class AcmeOrderManager {
private httpClient: AcmeHttpClient;
constructor(httpClient: AcmeHttpClient) {
this.httpClient = httpClient;
}
/**
* Create a new ACME order for the given identifiers
*/
async create(opts: { identifiers: IAcmeIdentifier[] }): Promise<IAcmeOrder> {
const dir = await this.httpClient.getDirectory();
const response = await this.httpClient.signedRequest(dir.newOrder, {
identifiers: opts.identifiers,
});
const order = response.data as IAcmeOrder;
// Capture order URL from Location header
order.url = response.headers['location'] || '';
return order;
}
/**
* Retrieve all authorizations for an order (POST-as-GET each authorization URL)
*/
async getAuthorizations(order: IAcmeOrder): Promise<IAcmeAuthorization[]> {
const authorizations: IAcmeAuthorization[] = [];
for (const authzUrl of order.authorizations) {
const response = await this.httpClient.signedRequest(authzUrl, null);
authorizations.push(response.data as IAcmeAuthorization);
}
return authorizations;
}
/**
* Finalize an order by submitting the CSR.
* Waits for the order to reach 'valid' status.
* Mutates the order object with updated status and certificate URL.
*/
async finalize(order: IAcmeOrder, csrPem: string): Promise<void> {
// Convert PEM CSR to base64url DER for ACME
const csrDer = AcmeCrypto.pemToBuffer(csrPem);
const csrB64url = csrDer.toString('base64url');
const response = await this.httpClient.signedRequest(order.finalize, { csr: csrB64url });
// Update order with response data
const updatedOrder = response.data;
order.status = updatedOrder.status;
if (updatedOrder.certificate) {
order.certificate = updatedOrder.certificate;
}
// If not yet valid, poll until it is
if (order.status !== 'valid' && order.url) {
const finalOrder = await this.waitForValidStatus({ url: order.url });
order.status = finalOrder.status;
order.certificate = finalOrder.certificate;
}
}
/**
* Download the certificate chain (PEM) from the order's certificate URL
*/
async getCertificate(order: IAcmeOrder): Promise<string> {
if (!order.certificate) {
throw new Error('Order does not have a certificate URL - finalize first');
}
const response = await this.httpClient.signedRequest(order.certificate, null);
// Certificate chain is returned as PEM text
return typeof response.data === 'string' ? response.data : response.data.toString();
}
/**
* Poll an ACME resource (order or challenge) until it reaches 'valid' or 'ready' status.
* Uses exponential backoff with Retry-After header support.
*/
async waitForValidStatus(
item: { url: string },
opts?: { maxAttempts?: number; initialDelayMs?: number },
): Promise<any> {
const maxAttempts = opts?.maxAttempts ?? 30;
const initialDelay = opts?.initialDelayMs ?? 1000;
for (let i = 0; i < maxAttempts; i++) {
const response = await this.httpClient.signedRequest(item.url, null);
const body = response.data;
if (body.status === 'valid' || body.status === 'ready') {
return body;
}
if (body.status === 'invalid') {
const challengeError = body.challenges?.find((c: any) => c.error)?.error;
throw new AcmeError({
status: 0,
type: challengeError?.type || 'urn:ietf:params:acme:error:rejectedIdentifier',
detail: challengeError?.detail || JSON.stringify(body),
subproblems: challengeError?.subproblems,
url: item.url,
});
}
// Respect Retry-After header, otherwise exponential backoff
const retryAfter = parseInt(response.headers['retry-after'] || '0', 10);
const delay =
retryAfter > 0 ? retryAfter * 1000 : Math.min(initialDelay * Math.pow(2, i), 30000);
await new Promise((resolve) => setTimeout(resolve, delay));
}
throw new Error(`Timeout waiting for valid status after ${maxAttempts} attempts`);
}
}

View File

@@ -0,0 +1,74 @@
/**
* ACME Protocol interfaces per RFC 8555
*/
export interface IAcmeDirectory {
newNonce: string;
newAccount: string;
newOrder: string;
newAuthz?: string;
revokeCert?: string;
keyChange?: string;
meta?: IAcmeDirectoryMeta;
}
export interface IAcmeDirectoryMeta {
termsOfService?: string;
website?: string;
caaIdentities?: string[];
externalAccountRequired?: boolean;
}
export interface IAcmeIdentifier {
type: 'dns';
value: string;
}
export interface IAcmeAccount {
status: string;
contact?: string[];
termsOfServiceAgreed?: boolean;
orders?: string;
}
export interface IAcmeAccountCreateRequest {
termsOfServiceAgreed: boolean;
contact?: string[];
}
export interface IAcmeOrder {
url: string;
status: string;
expires?: string;
identifiers: IAcmeIdentifier[];
authorizations: string[];
finalize: string;
certificate?: string;
}
export interface IAcmeAuthorization {
identifier: IAcmeIdentifier;
status: string;
expires?: string;
challenges: IAcmeChallenge[];
wildcard?: boolean;
}
export interface IAcmeChallenge {
type: string;
url: string;
status: string;
token: string;
validated?: string;
}
export interface IAcmeCsrOptions {
commonName: string;
altNames?: string[];
}
export interface IAcmeHttpResponse {
status: number;
headers: Record<string, string>;
data: any;
}

16
ts/acme/index.ts Normal file
View File

@@ -0,0 +1,16 @@
export { AcmeClient, type IAcmeClientOptions } from './acme.classes.client.js';
export { AcmeCrypto } from './acme.classes.crypto.js';
export { AcmeError } from './acme.classes.error.js';
export { ACME_DIRECTORY_URLS } from './acme.classes.directory.js';
export type {
IAcmeDirectory,
IAcmeDirectoryMeta,
IAcmeIdentifier,
IAcmeAccount,
IAcmeAccountCreateRequest,
IAcmeOrder,
IAcmeAuthorization,
IAcmeChallenge,
IAcmeCsrOptions,
IAcmeHttpResponse,
} from './acme.interfaces.js';

View File

@@ -14,7 +14,6 @@ export class MongoCertManager implements ICertManager {
*/
constructor(mongoDescriptor: plugins.smartdata.IMongoDescriptor) {
this.db = new plugins.smartdata.SmartdataDb(mongoDescriptor);
// Use a single EasyStore document to hold all certs keyed by domainName
this.store = new plugins.smartdata.EasyStore<Record<string, any>>(
'smartacme-certs',
this.db,

View File

@@ -9,21 +9,13 @@ import * as cloudflare from '@apiclient.xyz/cloudflare';
export { cloudflare };
// @apiglobal scope
import * as typedserver from '@api.global/typedserver';
export { typedserver };
// @pushrocks scope
// @push.rocks scope
import * as lik from '@push.rocks/lik';
import * as smartdata from '@push.rocks/smartdata';
import * as smartdelay from '@push.rocks/smartdelay';
import * as smartdnsClient from '@push.rocks/smartdns/client';
import * as smartfile from '@push.rocks/smartfile';
import * as smartlog from '@push.rocks/smartlog';
import * as smartnetwork from '@push.rocks/smartnetwork';
import * as smartpromise from '@push.rocks/smartpromise';
import * as smartrequest from '@push.rocks/smartrequest';
import * as smartunique from '@push.rocks/smartunique';
import * as smartstring from '@push.rocks/smartstring';
import * as smarttime from '@push.rocks/smarttime';
@@ -33,11 +25,8 @@ export {
smartdata,
smartdelay,
smartdnsClient,
smartfile,
smartlog,
smartnetwork,
smartpromise,
smartrequest,
smartunique,
smartstring,
smarttime,
@@ -48,8 +37,8 @@ import * as tsclass from '@tsclass/tsclass';
export { tsclass };
// third party scope
import * as acme from 'acme-client';
// acme protocol (custom implementation)
import * as acme from './acme/index.js';
export { acme };
// local handlers for challenge types

View File

@@ -54,7 +54,7 @@ export class SmartAcme {
private options: ISmartAcmeOptions;
// the acme client
private client: plugins.acme.Client;
private client: plugins.acme.AcmeClient;
private smartdns = new plugins.smartdnsClient.Smartdns({});
public logger: plugins.smartlog.Smartlog;
@@ -77,6 +77,9 @@ export class SmartAcme {
private challengePriority: string[];
// Map for coordinating concurrent certificate requests
private interestMap: plugins.lik.InterestMap<string, SmartacmeCert>;
// bound signal handlers so they can be removed on stop()
private boundSigintHandler: (() => void) | null = null;
private boundSigtermHandler: (() => void) | null = null;
constructor(optionsArg: ISmartAcmeOptions) {
this.options = optionsArg;
@@ -114,7 +117,7 @@ export class SmartAcme {
*/
public async start() {
this.privateKey =
this.options.accountPrivateKey || (await plugins.acme.forge.createPrivateKey()).toString();
this.options.accountPrivateKey || plugins.acme.AcmeCrypto.createRsaPrivateKey();
// Initialize certificate manager
if (!this.options.certManager) {
@@ -127,15 +130,18 @@ export class SmartAcme {
this.certmatcher = new SmartacmeCertMatcher();
// ACME Client
this.client = new plugins.acme.Client({
this.client = new plugins.acme.AcmeClient({
directoryUrl: (() => {
if (this.options.environment === 'production') {
return plugins.acme.directory.letsencrypt.production;
return plugins.acme.ACME_DIRECTORY_URLS.letsencrypt.production;
} else {
return plugins.acme.directory.letsencrypt.staging;
return plugins.acme.ACME_DIRECTORY_URLS.letsencrypt.staging;
}
})(),
accountKey: this.privateKey,
accountKeyPem: this.privateKey,
logger: (level, message, data) => {
this.logger.log(level as any, message, data);
},
});
/* Register account */
@@ -143,20 +149,31 @@ export class SmartAcme {
termsOfServiceAgreed: true,
contact: [`mailto:${this.options.accountEmail}`],
});
// Setup graceful shutdown handlers
process.on('SIGINT', () => this.handleSignal('SIGINT'));
process.on('SIGTERM', () => this.handleSignal('SIGTERM'));
// Setup graceful shutdown handlers (store references for removal in stop())
this.boundSigintHandler = () => this.handleSignal('SIGINT');
this.boundSigtermHandler = () => this.handleSignal('SIGTERM');
process.on('SIGINT', this.boundSigintHandler);
process.on('SIGTERM', this.boundSigtermHandler);
}
/**
* Stops the SmartAcme instance and closes certificate store connections.
*/
public async stop() {
// Remove signal handlers so the process can exit cleanly
if (this.boundSigintHandler) {
process.removeListener('SIGINT', this.boundSigintHandler);
this.boundSigintHandler = null;
}
if (this.boundSigtermHandler) {
process.removeListener('SIGTERM', this.boundSigtermHandler);
this.boundSigtermHandler = null;
}
if (this.certmanager && typeof (this.certmanager as any).close === 'function') {
await (this.certmanager as any).close();
}
}
/** Retry helper with exponential backoff */
/** Retry helper with exponential backoff and AcmeError awareness */
private async retry<T>(operation: () => Promise<T>, operationName: string = 'operation'): Promise<T> {
let attempt = 0;
let delay = this.retryOptions.minTimeoutMs;
@@ -164,6 +181,19 @@ export class SmartAcme {
try {
return await operation();
} catch (err) {
// Check if it's a non-retryable ACME error — throw immediately
if (err instanceof plugins.acme.AcmeError) {
if (!err.isRetryable) {
await this.logger.log('error', `Operation ${operationName} failed with non-retryable error (${err.type}, HTTP ${err.status}) at ${err.url}`, err);
throw err;
}
// For rate-limited errors, use server-specified Retry-After delay
if (err.isRateLimited && err.retryAfter > 0) {
delay = err.retryAfter * 1000;
await this.logger.log('warn', `Operation ${operationName} rate-limited, Retry-After: ${err.retryAfter}s`, err);
}
}
attempt++;
if (attempt > this.retryOptions.retries) {
await this.logger.log('error', `Operation ${operationName} failed after ${attempt} attempts`, err);
@@ -347,11 +377,6 @@ export class SmartAcme {
this.logger.log('info', 'Cooling down for 1 minute before ACME verification');
await plugins.smartdelay.delayFor(60000);
}
// Official ACME verification (ensures challenge is publicly reachable)
await this.retry(
() => this.client.verifyChallenge(authz, selectedChallengeArg),
`${type}.verifyChallenge`,
);
// Notify ACME server to complete the challenge
await this.retry(
() => this.client.completeChallenge(selectedChallengeArg),
@@ -399,7 +424,7 @@ export class SmartAcme {
}
}
const [key, csr] = await plugins.acme.forge.createCsr({
const [key, csr] = await plugins.acme.AcmeCrypto.createCsr({
commonName,
altNames: csrDomains,
});