@push.rocks/smartacme 7.3.4 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/acme/acme.classes.account.d.ts +20 -0
  3. package/dist_ts/acme/acme.classes.account.js +37 -0
  4. package/dist_ts/acme/acme.classes.challenge.d.ts +22 -0
  5. package/dist_ts/acme/acme.classes.challenge.js +36 -0
  6. package/dist_ts/acme/acme.classes.client.d.ts +68 -0
  7. package/dist_ts/acme/acme.classes.client.js +71 -0
  8. package/dist_ts/acme/acme.classes.crypto.d.ts +50 -0
  9. package/dist_ts/acme/acme.classes.crypto.js +180 -0
  10. package/dist_ts/acme/acme.classes.directory.d.ts +13 -0
  11. package/dist_ts/acme/acme.classes.directory.js +14 -0
  12. package/dist_ts/acme/acme.classes.error.d.ts +44 -0
  13. package/dist_ts/acme/acme.classes.error.js +41 -0
  14. package/dist_ts/acme/acme.classes.http-client.d.ts +36 -0
  15. package/dist_ts/acme/acme.classes.http-client.js +201 -0
  16. package/dist_ts/acme/acme.classes.order.d.ts +40 -0
  17. package/dist_ts/acme/acme.classes.order.js +100 -0
  18. package/dist_ts/acme/acme.interfaces.d.ts +64 -0
  19. package/dist_ts/acme/acme.interfaces.js +5 -0
  20. package/dist_ts/acme/index.d.ts +5 -0
  21. package/dist_ts/acme/index.js +5 -0
  22. package/dist_ts/certmanagers/mongo.js +1 -2
  23. package/dist_ts/plugins.d.ts +2 -7
  24. package/dist_ts/plugins.js +5 -11
  25. package/dist_ts/smartacme.classes.smartacme.d.ts +7 -2
  26. package/dist_ts/smartacme.classes.smartacme.js +80 -20
  27. package/npmextra.json +12 -6
  28. package/package.json +23 -24
  29. package/readme.hints.md +37 -2
  30. package/readme.md +201 -255
  31. package/ts/00_commitinfo_data.ts +1 -1
  32. package/ts/acme/acme.classes.account.ts +45 -0
  33. package/ts/acme/acme.classes.challenge.ts +45 -0
  34. package/ts/acme/acme.classes.client.ts +99 -0
  35. package/ts/acme/acme.classes.crypto.ts +220 -0
  36. package/ts/acme/acme.classes.directory.ts +13 -0
  37. package/ts/acme/acme.classes.error.ts +55 -0
  38. package/ts/acme/acme.classes.http-client.ts +236 -0
  39. package/ts/acme/acme.classes.order.ts +125 -0
  40. package/ts/acme/acme.interfaces.ts +74 -0
  41. package/ts/acme/index.ts +16 -0
  42. package/ts/certmanagers/mongo.ts +0 -1
  43. package/ts/plugins.ts +3 -14
  44. package/ts/smartacme.classes.smartacme.ts +88 -23
  45. package/dist_ts/certmanagers.d.ts +0 -42
  46. package/dist_ts/certmanagers.js +0 -86
  47. package/dist_ts/smartacme.classes.certmanager.d.ts +0 -43
  48. package/dist_ts/smartacme.classes.certmanager.js +0 -92
  49. package/dist_ts/smartacme.plugins.d.ts +0 -21
  50. package/dist_ts/smartacme.plugins.js +0 -28
@@ -0,0 +1,99 @@
1
+ import { AcmeCrypto } from './acme.classes.crypto.js';
2
+ import { ACME_DIRECTORY_URLS } from './acme.classes.directory.js';
3
+ import { AcmeHttpClient, type TAcmeLogger } from './acme.classes.http-client.js';
4
+ import { AcmeAccount } from './acme.classes.account.js';
5
+ import { AcmeOrderManager } from './acme.classes.order.js';
6
+ import { AcmeChallengeManager } from './acme.classes.challenge.js';
7
+ import type {
8
+ IAcmeAccount,
9
+ IAcmeAccountCreateRequest,
10
+ IAcmeAuthorization,
11
+ IAcmeChallenge,
12
+ IAcmeIdentifier,
13
+ IAcmeOrder,
14
+ } from './acme.interfaces.js';
15
+
16
+ export interface IAcmeClientOptions {
17
+ directoryUrl: string;
18
+ accountKeyPem: string;
19
+ logger?: TAcmeLogger;
20
+ }
21
+
22
+ /**
23
+ * Top-level ACME client facade.
24
+ * Composes HTTP transport, account management, order lifecycle, and challenge handling.
25
+ */
26
+ export class AcmeClient {
27
+ private httpClient: AcmeHttpClient;
28
+ private account: AcmeAccount;
29
+ private orderManager: AcmeOrderManager;
30
+ private challengeManager: AcmeChallengeManager;
31
+
32
+ /** Well-known CA directory URLs */
33
+ static directory = ACME_DIRECTORY_URLS;
34
+ /** Crypto utilities */
35
+ static crypto = AcmeCrypto;
36
+
37
+ constructor(options: IAcmeClientOptions) {
38
+ this.httpClient = new AcmeHttpClient(options.directoryUrl, options.accountKeyPem, options.logger);
39
+ this.account = new AcmeAccount(this.httpClient);
40
+ this.orderManager = new AcmeOrderManager(this.httpClient);
41
+ this.challengeManager = new AcmeChallengeManager(this.httpClient, options.accountKeyPem);
42
+ }
43
+
44
+ /**
45
+ * Register or retrieve an ACME account
46
+ */
47
+ async createAccount(request: IAcmeAccountCreateRequest): Promise<IAcmeAccount> {
48
+ return this.account.create(request);
49
+ }
50
+
51
+ /**
52
+ * Create a new certificate order
53
+ */
54
+ async createOrder(opts: { identifiers: IAcmeIdentifier[] }): Promise<IAcmeOrder> {
55
+ return this.orderManager.create(opts);
56
+ }
57
+
58
+ /**
59
+ * Get all authorizations for an order
60
+ */
61
+ async getAuthorizations(order: IAcmeOrder): Promise<IAcmeAuthorization[]> {
62
+ return this.orderManager.getAuthorizations(order);
63
+ }
64
+
65
+ /**
66
+ * Compute the key authorization string for a challenge (sync)
67
+ */
68
+ getChallengeKeyAuthorization(challenge: IAcmeChallenge): string {
69
+ return this.challengeManager.getKeyAuthorization(challenge);
70
+ }
71
+
72
+ /**
73
+ * Notify the ACME server to validate a challenge
74
+ */
75
+ async completeChallenge(challenge: IAcmeChallenge): Promise<void> {
76
+ return this.challengeManager.complete(challenge);
77
+ }
78
+
79
+ /**
80
+ * Poll an ACME resource until it reaches valid/ready status
81
+ */
82
+ async waitForValidStatus(item: { url: string }): Promise<any> {
83
+ return this.orderManager.waitForValidStatus(item);
84
+ }
85
+
86
+ /**
87
+ * Finalize an order by submitting the CSR
88
+ */
89
+ async finalizeOrder(order: IAcmeOrder, csrPem: string): Promise<void> {
90
+ return this.orderManager.finalize(order, csrPem);
91
+ }
92
+
93
+ /**
94
+ * Download the certificate chain (PEM)
95
+ */
96
+ async getCertificate(order: IAcmeOrder): Promise<string> {
97
+ return this.orderManager.getCertificate(order);
98
+ }
99
+ }
@@ -0,0 +1,220 @@
1
+ import * as crypto from 'node:crypto';
2
+ import type { IAcmeCsrOptions } from './acme.interfaces.js';
3
+
4
+ /**
5
+ * All cryptographic operations for the ACME protocol.
6
+ * Uses node:crypto for key gen, JWK, JWS signing.
7
+ * Uses @peculiar/x509 for CSR generation (no native Node.js CSR API).
8
+ */
9
+ export class AcmeCrypto {
10
+ /**
11
+ * Generate an RSA private key in PEM format
12
+ */
13
+ static createRsaPrivateKey(modulusLength = 2048): string {
14
+ const { privateKey } = crypto.generateKeyPairSync('rsa', {
15
+ modulusLength,
16
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
17
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
18
+ });
19
+ return privateKey;
20
+ }
21
+
22
+ /**
23
+ * Export public JWK from PEM private key, keys sorted alphabetically per RFC 7638
24
+ */
25
+ static getJwk(keyPem: string): Record<string, string> {
26
+ const keyObj = crypto.createPublicKey(keyPem);
27
+ const jwk = keyObj.export({ format: 'jwk' }) as Record<string, any>;
28
+ if (jwk.kty === 'RSA') {
29
+ return { e: jwk.e, kty: jwk.kty, n: jwk.n };
30
+ } else if (jwk.kty === 'EC') {
31
+ return { crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y };
32
+ }
33
+ throw new Error(`Unsupported key type: ${jwk.kty}`);
34
+ }
35
+
36
+ /**
37
+ * Compute JWK Thumbprint (SHA-256, base64url) per RFC 7638
38
+ */
39
+ static getJwkThumbprint(jwk: Record<string, string>): string {
40
+ let canonical: string;
41
+ if (jwk.kty === 'RSA') {
42
+ canonical = JSON.stringify({ e: jwk.e, kty: jwk.kty, n: jwk.n });
43
+ } else if (jwk.kty === 'EC') {
44
+ canonical = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
45
+ } else {
46
+ throw new Error(`Unsupported key type: ${jwk.kty}`);
47
+ }
48
+ const hash = crypto.createHash('sha256').update(canonical).digest();
49
+ return hash.toString('base64url');
50
+ }
51
+
52
+ /**
53
+ * Create a flattened JWS for ACME requests (RFC 7515)
54
+ * payload=null means POST-as-GET (empty string payload)
55
+ */
56
+ static createJws(
57
+ keyPem: string,
58
+ url: string,
59
+ payload: any | null,
60
+ options: { nonce: string; kid?: string; jwk?: Record<string, string> },
61
+ ): { protected: string; payload: string; signature: string } {
62
+ const header: Record<string, any> = {
63
+ alg: AcmeCrypto.getAlg(keyPem),
64
+ nonce: options.nonce,
65
+ url,
66
+ };
67
+ if (options.kid) {
68
+ header.kid = options.kid;
69
+ } else if (options.jwk) {
70
+ header.jwk = options.jwk;
71
+ } else {
72
+ header.jwk = AcmeCrypto.getJwk(keyPem);
73
+ }
74
+
75
+ const protectedB64 = Buffer.from(JSON.stringify(header)).toString('base64url');
76
+ const payloadB64 =
77
+ payload !== null ? Buffer.from(JSON.stringify(payload)).toString('base64url') : '';
78
+
79
+ const signingInput = `${protectedB64}.${payloadB64}`;
80
+ const keyObj = crypto.createPrivateKey(keyPem);
81
+ const alg = AcmeCrypto.getAlg(keyPem);
82
+
83
+ let signature: Buffer;
84
+ if (alg.startsWith('RS')) {
85
+ signature = crypto.sign('sha256', Buffer.from(signingInput), keyObj);
86
+ } else if (alg.startsWith('ES')) {
87
+ signature = crypto.sign('sha256', Buffer.from(signingInput), {
88
+ key: keyObj,
89
+ dsaEncoding: 'ieee-p1363',
90
+ });
91
+ } else {
92
+ throw new Error(`Unsupported algorithm: ${alg}`);
93
+ }
94
+
95
+ return {
96
+ protected: protectedB64,
97
+ payload: payloadB64,
98
+ signature: signature.toString('base64url'),
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Create a CSR (PKCS#10) via @peculiar/x509
104
+ * Returns [privateKeyPem, csrPem]
105
+ */
106
+ static async createCsr(
107
+ options: IAcmeCsrOptions,
108
+ existingKeyPem?: string,
109
+ ): Promise<[string, string]> {
110
+ const x509 = await import('@peculiar/x509');
111
+ const { webcrypto } = crypto;
112
+ x509.cryptoProvider.set(webcrypto as any);
113
+
114
+ let keys: CryptoKeyPair;
115
+ let keyPem: string;
116
+
117
+ if (existingKeyPem) {
118
+ keys = await AcmeCrypto.importKeyPairToWebCrypto(existingKeyPem, webcrypto);
119
+ keyPem = existingKeyPem;
120
+ } else {
121
+ keys = (await webcrypto.subtle.generateKey(
122
+ {
123
+ name: 'RSASSA-PKCS1-v1_5',
124
+ modulusLength: 2048,
125
+ publicExponent: new Uint8Array([1, 0, 1]),
126
+ hash: 'SHA-256',
127
+ },
128
+ true,
129
+ ['sign', 'verify'],
130
+ )) as CryptoKeyPair;
131
+
132
+ const pkcs8 = await webcrypto.subtle.exportKey('pkcs8', keys.privateKey);
133
+ const b64 = Buffer.from(pkcs8).toString('base64');
134
+ const lines = b64.match(/.{1,64}/g)!;
135
+ keyPem = `-----BEGIN PRIVATE KEY-----\n${lines.join('\n')}\n-----END PRIVATE KEY-----\n`;
136
+ }
137
+
138
+ // Collect all DNS names for SAN (CN is always included)
139
+ const sanNames: string[] = [options.commonName];
140
+ if (options.altNames) {
141
+ for (const name of options.altNames) {
142
+ if (!sanNames.includes(name)) {
143
+ sanNames.push(name);
144
+ }
145
+ }
146
+ }
147
+
148
+ const csr = await x509.Pkcs10CertificateRequestGenerator.create({
149
+ name: `CN=${options.commonName}`,
150
+ keys,
151
+ signingAlgorithm: { name: 'RSASSA-PKCS1-v1_5' },
152
+ extensions: [
153
+ new x509.SubjectAlternativeNameExtension(
154
+ sanNames.map((name) => ({ type: 'dns' as const, value: name })),
155
+ ),
156
+ ],
157
+ });
158
+
159
+ // Convert to PEM
160
+ const csrPem = csr.toString('pem');
161
+
162
+ return [keyPem, csrPem];
163
+ }
164
+
165
+ /**
166
+ * Convert PEM to raw DER Buffer (strip headers, decode base64)
167
+ */
168
+ static pemToBuffer(pem: string): Buffer {
169
+ const lines = pem
170
+ .split('\n')
171
+ .filter((line) => !line.startsWith('-----') && line.trim().length > 0);
172
+ return Buffer.from(lines.join(''), 'base64');
173
+ }
174
+
175
+ /**
176
+ * Determine JWS algorithm from key type
177
+ */
178
+ private static getAlg(keyPem: string): string {
179
+ const keyObj = crypto.createPrivateKey(keyPem);
180
+ const keyType = keyObj.asymmetricKeyType;
181
+ if (keyType === 'rsa') return 'RS256';
182
+ if (keyType === 'ec') {
183
+ const details = keyObj.asymmetricKeyDetails;
184
+ if (details?.namedCurve === 'prime256v1' || details?.namedCurve === 'P-256') return 'ES256';
185
+ if (details?.namedCurve === 'secp384r1' || details?.namedCurve === 'P-384') return 'ES384';
186
+ return 'ES256';
187
+ }
188
+ throw new Error(`Unsupported key type: ${keyType}`);
189
+ }
190
+
191
+ /**
192
+ * Import a PEM private key into WebCrypto as a CryptoKeyPair
193
+ */
194
+ private static async importKeyPairToWebCrypto(
195
+ keyPem: string,
196
+ wc: typeof crypto.webcrypto,
197
+ ): Promise<CryptoKeyPair> {
198
+ const keyObj = crypto.createPrivateKey(keyPem);
199
+ const pkcs8Der = keyObj.export({ type: 'pkcs8', format: 'der' });
200
+ const privateKey = await wc.subtle.importKey(
201
+ 'pkcs8',
202
+ pkcs8Der,
203
+ { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
204
+ true,
205
+ ['sign'],
206
+ );
207
+
208
+ const pubKeyObj = crypto.createPublicKey(keyPem);
209
+ const spkiDer = pubKeyObj.export({ type: 'spki', format: 'der' });
210
+ const publicKey = await wc.subtle.importKey(
211
+ 'spki',
212
+ spkiDer,
213
+ { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
214
+ true,
215
+ ['verify'],
216
+ );
217
+
218
+ return { privateKey: privateKey as unknown as CryptoKey, publicKey: publicKey as unknown as CryptoKey };
219
+ }
220
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * ACME directory URL constants for well-known CAs
3
+ */
4
+ export const ACME_DIRECTORY_URLS = {
5
+ letsencrypt: {
6
+ production: 'https://acme-v02.api.letsencrypt.org/directory',
7
+ staging: 'https://acme-staging-v02.api.letsencrypt.org/directory',
8
+ },
9
+ buypass: {
10
+ production: 'https://api.buypass.com/acme/directory',
11
+ staging: 'https://api.test4.buypass.no/acme/directory',
12
+ },
13
+ } as const;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Structured ACME protocol error with RFC 8555 fields.
3
+ * Provides type URN, subproblems, Retry-After, and retryability classification.
4
+ */
5
+ export class AcmeError extends Error {
6
+ public readonly status: number;
7
+ public readonly type: string;
8
+ public readonly detail: string;
9
+ public readonly subproblems: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>;
10
+ public readonly url: string;
11
+ public readonly retryAfter: number;
12
+
13
+ constructor(options: {
14
+ message?: string;
15
+ status: number;
16
+ type?: string;
17
+ detail?: string;
18
+ subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>;
19
+ url?: string;
20
+ retryAfter?: number;
21
+ }) {
22
+ const type = options.type || '';
23
+ const detail = options.detail || '';
24
+ const url = options.url || '';
25
+ const msg =
26
+ options.message ||
27
+ `ACME error: ${type || 'unknown'} (HTTP ${options.status}) at ${url || 'unknown'} - ${detail || 'no detail'}`;
28
+ super(msg);
29
+ this.name = 'AcmeError';
30
+ this.status = options.status;
31
+ this.type = type;
32
+ this.detail = detail;
33
+ this.subproblems = options.subproblems || [];
34
+ this.url = url;
35
+ this.retryAfter = options.retryAfter || 0;
36
+ }
37
+
38
+ /**
39
+ * True for HTTP 429 or ACME rateLimited type URN
40
+ */
41
+ get isRateLimited(): boolean {
42
+ return this.status === 429 || this.type === 'urn:ietf:params:acme:error:rateLimited';
43
+ }
44
+
45
+ /**
46
+ * True for transient/retryable errors: 429, 503, 5xx, badNonce.
47
+ * False for definitive client errors: 400 (non-badNonce), 403, 404, 409.
48
+ */
49
+ get isRetryable(): boolean {
50
+ if (this.type === 'urn:ietf:params:acme:error:badNonce') return true;
51
+ if (this.status === 429 || this.status === 503) return true;
52
+ if (this.status >= 500) return true;
53
+ return false;
54
+ }
55
+ }
@@ -0,0 +1,236 @@
1
+ import * as https from 'node:https';
2
+ import * as http from 'node:http';
3
+ import { AcmeCrypto } from './acme.classes.crypto.js';
4
+ import { AcmeError } from './acme.classes.error.js';
5
+ import type { IAcmeDirectory, IAcmeHttpResponse } from './acme.interfaces.js';
6
+
7
+ export type TAcmeLogger = (level: string, message: string, data?: any) => void;
8
+
9
+ /**
10
+ * JWS-signed HTTP transport for ACME protocol.
11
+ * Handles nonce management, bad-nonce retries, and signed requests.
12
+ */
13
+ export class AcmeHttpClient {
14
+ private directoryUrl: string;
15
+ private accountKeyPem: string;
16
+ private directory: IAcmeDirectory | null = null;
17
+ private nonce: string | null = null;
18
+ public kid: string | null = null;
19
+ private logger?: TAcmeLogger;
20
+
21
+ constructor(directoryUrl: string, accountKeyPem: string, logger?: TAcmeLogger) {
22
+ this.directoryUrl = directoryUrl;
23
+ this.accountKeyPem = accountKeyPem;
24
+ this.logger = logger;
25
+ }
26
+
27
+ private log(level: string, message: string, data?: any): void {
28
+ if (this.logger) {
29
+ this.logger(level, message, data);
30
+ }
31
+ }
32
+
33
+ /**
34
+ * GET the ACME directory (cached after first call)
35
+ */
36
+ async getDirectory(): Promise<IAcmeDirectory> {
37
+ if (this.directory) return this.directory;
38
+ const response = await this.httpRequest(this.directoryUrl, 'GET');
39
+ if (response.status !== 200) {
40
+ throw new AcmeError({
41
+ status: response.status,
42
+ type: response.data?.type || '',
43
+ detail: `Failed to fetch ACME directory`,
44
+ url: this.directoryUrl,
45
+ });
46
+ }
47
+ this.directory = response.data as IAcmeDirectory;
48
+ return this.directory;
49
+ }
50
+
51
+ /**
52
+ * Fetch a fresh nonce via HEAD to newNonce
53
+ */
54
+ async getNonce(): Promise<string> {
55
+ if (this.nonce) {
56
+ const n = this.nonce;
57
+ this.nonce = null;
58
+ return n;
59
+ }
60
+ const dir = await this.getDirectory();
61
+ const response = await this.httpRequest(dir.newNonce, 'HEAD');
62
+ const nonce = response.headers['replay-nonce'];
63
+ if (!nonce) {
64
+ throw new Error('No replay-nonce header in newNonce response');
65
+ }
66
+ return nonce;
67
+ }
68
+
69
+ /**
70
+ * Send a JWS-signed POST request to an ACME endpoint.
71
+ * Handles nonce rotation and bad-nonce retries (up to 5).
72
+ * payload=null means POST-as-GET.
73
+ */
74
+ async signedRequest(
75
+ url: string,
76
+ payload: any | null,
77
+ options?: { useJwk?: boolean },
78
+ ): Promise<IAcmeHttpResponse> {
79
+ const maxRetries = 5;
80
+
81
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
82
+ const nonce = await this.getNonce();
83
+
84
+ const jwsOptions: { nonce: string; kid?: string; jwk?: Record<string, string> } = { nonce };
85
+ if (options?.useJwk) {
86
+ jwsOptions.jwk = AcmeCrypto.getJwk(this.accountKeyPem);
87
+ } else if (this.kid) {
88
+ jwsOptions.kid = this.kid;
89
+ } else {
90
+ jwsOptions.jwk = AcmeCrypto.getJwk(this.accountKeyPem);
91
+ }
92
+
93
+ const jws = AcmeCrypto.createJws(this.accountKeyPem, url, payload, jwsOptions);
94
+ const body = JSON.stringify(jws);
95
+
96
+ const response = await this.httpRequest(url, 'POST', body, {
97
+ 'Content-Type': 'application/jose+json',
98
+ });
99
+
100
+ // Save nonce from response for reuse
101
+ if (response.headers['replay-nonce']) {
102
+ this.nonce = response.headers['replay-nonce'];
103
+ }
104
+
105
+ this.log('debug', `ACME request: POST ${url} → ${response.status}`);
106
+
107
+ // Retry on bad-nonce
108
+ if (
109
+ response.status === 400 &&
110
+ response.data?.type === 'urn:ietf:params:acme:error:badNonce'
111
+ ) {
112
+ this.log('debug', `Bad nonce on attempt ${attempt + 1}, retrying`);
113
+ if (attempt < maxRetries) {
114
+ this.nonce = null; // Force fresh nonce
115
+ continue;
116
+ }
117
+ }
118
+
119
+ // Throw on error responses
120
+ if (response.status >= 400) {
121
+ const retryAfterRaw = response.headers['retry-after'];
122
+ let retryAfter = 0;
123
+ if (retryAfterRaw) {
124
+ const parsed = parseInt(retryAfterRaw, 10);
125
+ if (!isNaN(parsed)) {
126
+ retryAfter = parsed;
127
+ }
128
+ }
129
+
130
+ const acmeError = new AcmeError({
131
+ status: response.status,
132
+ type: response.data?.type || '',
133
+ detail: response.data?.detail || JSON.stringify(response.data),
134
+ subproblems: response.data?.subproblems,
135
+ url,
136
+ retryAfter,
137
+ });
138
+
139
+ if (acmeError.isRateLimited) {
140
+ this.log('warn', `RATE LIMITED: ${url} (HTTP ${response.status}), Retry-After: ${retryAfter}s`, {
141
+ type: acmeError.type,
142
+ detail: acmeError.detail,
143
+ retryAfter,
144
+ });
145
+ } else {
146
+ this.log('warn', `ACME error: ${url} (HTTP ${response.status})`, {
147
+ type: acmeError.type,
148
+ detail: acmeError.detail,
149
+ });
150
+ }
151
+
152
+ throw acmeError;
153
+ }
154
+
155
+ return response;
156
+ }
157
+
158
+ throw new Error('Max bad-nonce retries exceeded');
159
+ }
160
+
161
+ /**
162
+ * Raw HTTP request using native node:https
163
+ */
164
+ private httpRequest(
165
+ url: string,
166
+ method: string,
167
+ body?: string,
168
+ headers?: Record<string, string>,
169
+ ): Promise<IAcmeHttpResponse> {
170
+ return new Promise((resolve, reject) => {
171
+ const urlObj = new URL(url);
172
+ const isHttps = urlObj.protocol === 'https:';
173
+ const lib = isHttps ? https : http;
174
+
175
+ const requestHeaders: Record<string, string | number> = {
176
+ ...headers,
177
+ 'User-Agent': 'smartacme-acme-client/1.0',
178
+ };
179
+ if (body) {
180
+ requestHeaders['Content-Length'] = Buffer.byteLength(body);
181
+ }
182
+
183
+ const options: https.RequestOptions = {
184
+ hostname: urlObj.hostname,
185
+ port: urlObj.port || (isHttps ? 443 : 80),
186
+ path: urlObj.pathname + urlObj.search,
187
+ method,
188
+ headers: requestHeaders,
189
+ };
190
+
191
+ const req = lib.request(options, (res) => {
192
+ const chunks: Buffer[] = [];
193
+ res.on('data', (chunk: Buffer) => chunks.push(chunk));
194
+ res.on('end', () => {
195
+ const responseBody = Buffer.concat(chunks).toString('utf-8');
196
+
197
+ // Normalize headers to lowercase single-value
198
+ const responseHeaders: Record<string, string> = {};
199
+ for (const [key, value] of Object.entries(res.headers)) {
200
+ if (typeof value === 'string') {
201
+ responseHeaders[key.toLowerCase()] = value;
202
+ } else if (Array.isArray(value)) {
203
+ responseHeaders[key.toLowerCase()] = value[0];
204
+ }
205
+ }
206
+
207
+ // Parse JSON if applicable, otherwise return raw string
208
+ let data: any;
209
+ const contentType = responseHeaders['content-type'] || '';
210
+ if (contentType.includes('json')) {
211
+ try {
212
+ data = JSON.parse(responseBody);
213
+ } catch {
214
+ data = responseBody;
215
+ }
216
+ } else {
217
+ data = responseBody;
218
+ }
219
+
220
+ resolve({
221
+ status: res.statusCode || 0,
222
+ headers: responseHeaders,
223
+ data,
224
+ });
225
+ });
226
+ });
227
+
228
+ req.on('error', reject);
229
+ req.setTimeout(30000, () => {
230
+ req.destroy(new Error('Request timeout'));
231
+ });
232
+ if (body) req.write(body);
233
+ req.end();
234
+ });
235
+ }
236
+ }