@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.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/acme/acme.classes.account.d.ts +20 -0
- package/dist_ts/acme/acme.classes.account.js +37 -0
- package/dist_ts/acme/acme.classes.challenge.d.ts +22 -0
- package/dist_ts/acme/acme.classes.challenge.js +36 -0
- package/dist_ts/acme/acme.classes.client.d.ts +68 -0
- package/dist_ts/acme/acme.classes.client.js +71 -0
- package/dist_ts/acme/acme.classes.crypto.d.ts +50 -0
- package/dist_ts/acme/acme.classes.crypto.js +180 -0
- package/dist_ts/acme/acme.classes.directory.d.ts +13 -0
- package/dist_ts/acme/acme.classes.directory.js +14 -0
- package/dist_ts/acme/acme.classes.error.d.ts +44 -0
- package/dist_ts/acme/acme.classes.error.js +41 -0
- package/dist_ts/acme/acme.classes.http-client.d.ts +36 -0
- package/dist_ts/acme/acme.classes.http-client.js +201 -0
- package/dist_ts/acme/acme.classes.order.d.ts +40 -0
- package/dist_ts/acme/acme.classes.order.js +100 -0
- package/dist_ts/acme/acme.interfaces.d.ts +64 -0
- package/dist_ts/acme/acme.interfaces.js +5 -0
- package/dist_ts/acme/index.d.ts +5 -0
- package/dist_ts/acme/index.js +5 -0
- package/dist_ts/certmanagers/mongo.js +1 -2
- package/dist_ts/plugins.d.ts +2 -7
- package/dist_ts/plugins.js +5 -11
- package/dist_ts/smartacme.classes.smartacme.d.ts +7 -2
- package/dist_ts/smartacme.classes.smartacme.js +80 -20
- package/npmextra.json +12 -6
- package/package.json +23 -24
- package/readme.hints.md +37 -2
- package/readme.md +201 -255
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/acme/acme.classes.account.ts +45 -0
- package/ts/acme/acme.classes.challenge.ts +45 -0
- package/ts/acme/acme.classes.client.ts +99 -0
- package/ts/acme/acme.classes.crypto.ts +220 -0
- package/ts/acme/acme.classes.directory.ts +13 -0
- package/ts/acme/acme.classes.error.ts +55 -0
- package/ts/acme/acme.classes.http-client.ts +236 -0
- package/ts/acme/acme.classes.order.ts +125 -0
- package/ts/acme/acme.interfaces.ts +74 -0
- package/ts/acme/index.ts +16 -0
- package/ts/certmanagers/mongo.ts +0 -1
- package/ts/plugins.ts +3 -14
- package/ts/smartacme.classes.smartacme.ts +88 -23
- package/dist_ts/certmanagers.d.ts +0 -42
- package/dist_ts/certmanagers.js +0 -86
- package/dist_ts/smartacme.classes.certmanager.d.ts +0 -43
- package/dist_ts/smartacme.classes.certmanager.js +0 -92
- package/dist_ts/smartacme.plugins.d.ts +0 -21
- 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
|
+
}
|