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:
@@ -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.'
|
||||
}
|
||||
|
||||
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';
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user