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

@@ -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';