@push.rocks/smartacme 8.0.0 → 9.0.1

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 +72 -0
  7. package/dist_ts/acme/acme.classes.client.js +77 -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 +42 -0
  15. package/dist_ts/acme/acme.classes.http-client.js +211 -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 +3 -1
  26. package/dist_ts/smartacme.classes.smartacme.js +48 -13
  27. package/npmextra.json +12 -6
  28. package/package.json +23 -24
  29. package/readme.hints.md +27 -3
  30. package/readme.md +245 -267
  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 +106 -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 +249 -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 +49 -16
  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,45 @@
1
+ import type { AcmeHttpClient } from './acme.classes.http-client.js';
2
+ import type { IAcmeAccount, IAcmeAccountCreateRequest } from './acme.interfaces.js';
3
+
4
+ /**
5
+ * ACME account management - registration and key management
6
+ */
7
+ export class AcmeAccount {
8
+ private httpClient: AcmeHttpClient;
9
+ private accountUrl: string | null = null;
10
+
11
+ constructor(httpClient: AcmeHttpClient) {
12
+ this.httpClient = httpClient;
13
+ }
14
+
15
+ /**
16
+ * Register or retrieve an ACME account.
17
+ * Uses JWK (not kid) since account URL is not yet known.
18
+ * Captures account URL from Location header for subsequent requests.
19
+ */
20
+ async create(request: IAcmeAccountCreateRequest): Promise<IAcmeAccount> {
21
+ const dir = await this.httpClient.getDirectory();
22
+ const response = await this.httpClient.signedRequest(dir.newAccount, request, {
23
+ useJwk: true,
24
+ });
25
+
26
+ // Capture account URL from Location header (used as kid for future requests)
27
+ const location = response.headers['location'];
28
+ if (location) {
29
+ this.accountUrl = location;
30
+ this.httpClient.kid = location;
31
+ }
32
+
33
+ return response.data as IAcmeAccount;
34
+ }
35
+
36
+ /**
37
+ * Get the account URL (kid) for use in JWS headers
38
+ */
39
+ getAccountUrl(): string {
40
+ if (!this.accountUrl) {
41
+ throw new Error('Account not yet created - call create() first');
42
+ }
43
+ return this.accountUrl;
44
+ }
45
+ }
@@ -0,0 +1,45 @@
1
+ import * as crypto from 'node:crypto';
2
+ import { AcmeCrypto } from './acme.classes.crypto.js';
3
+ import type { AcmeHttpClient } from './acme.classes.http-client.js';
4
+ import type { IAcmeChallenge } from './acme.interfaces.js';
5
+
6
+ /**
7
+ * ACME challenge operations - key authorization computation and challenge completion
8
+ */
9
+ export class AcmeChallengeManager {
10
+ private httpClient: AcmeHttpClient;
11
+ private accountKeyPem: string;
12
+
13
+ constructor(httpClient: AcmeHttpClient, accountKeyPem: string) {
14
+ this.httpClient = httpClient;
15
+ this.accountKeyPem = accountKeyPem;
16
+ }
17
+
18
+ /**
19
+ * Compute the key authorization for a challenge.
20
+ * For http-01: returns `token.thumbprint`
21
+ * For dns-01: returns `base64url(sha256(token.thumbprint))`
22
+ *
23
+ * This is a synchronous, pure-crypto computation.
24
+ */
25
+ getKeyAuthorization(challenge: IAcmeChallenge): string {
26
+ const jwk = AcmeCrypto.getJwk(this.accountKeyPem);
27
+ const thumbprint = AcmeCrypto.getJwkThumbprint(jwk);
28
+ const keyAuth = `${challenge.token}.${thumbprint}`;
29
+
30
+ if (challenge.type === 'dns-01') {
31
+ // DNS-01 uses base64url(SHA-256(keyAuthorization))
32
+ return crypto.createHash('sha256').update(keyAuth).digest().toString('base64url');
33
+ }
34
+
35
+ // HTTP-01 and others use the raw key authorization
36
+ return keyAuth;
37
+ }
38
+
39
+ /**
40
+ * Notify the ACME server to validate a challenge (POST {} to challenge URL)
41
+ */
42
+ async complete(challenge: IAcmeChallenge): Promise<void> {
43
+ await this.httpClient.signedRequest(challenge.url, {});
44
+ }
45
+ }
@@ -0,0 +1,106 @@
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
+
100
+ /**
101
+ * Destroy HTTP transport to release sockets and allow process exit.
102
+ */
103
+ destroy(): void {
104
+ this.httpClient.destroy();
105
+ }
106
+ }
@@ -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
+ }