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:
45
ts/acme/acme.classes.account.ts
Normal file
45
ts/acme/acme.classes.account.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
45
ts/acme/acme.classes.challenge.ts
Normal file
45
ts/acme/acme.classes.challenge.ts
Normal 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, {});
|
||||
}
|
||||
}
|
||||
99
ts/acme/acme.classes.client.ts
Normal file
99
ts/acme/acme.classes.client.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
220
ts/acme/acme.classes.crypto.ts
Normal file
220
ts/acme/acme.classes.crypto.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
13
ts/acme/acme.classes.directory.ts
Normal file
13
ts/acme/acme.classes.directory.ts
Normal 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;
|
||||
55
ts/acme/acme.classes.error.ts
Normal file
55
ts/acme/acme.classes.error.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
236
ts/acme/acme.classes.http-client.ts
Normal file
236
ts/acme/acme.classes.http-client.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
125
ts/acme/acme.classes.order.ts
Normal file
125
ts/acme/acme.classes.order.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
74
ts/acme/acme.interfaces.ts
Normal file
74
ts/acme/acme.interfaces.ts
Normal 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
16
ts/acme/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user