@certd/acme-client 1.22.4 → 1.24.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/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "Simple and unopinionated ACME client",
4
4
  "private": false,
5
5
  "author": "nmorsman",
6
- "version": "1.22.4",
6
+ "version": "1.24.0",
7
7
  "main": "src/index.js",
8
8
  "types": "types/index.d.ts",
9
9
  "license": "MIT",
@@ -16,24 +16,24 @@
16
16
  "types"
17
17
  ],
18
18
  "dependencies": {
19
- "@peculiar/x509": "^1.10.0",
19
+ "@peculiar/x509": "^1.11.0",
20
20
  "asn1js": "^3.0.5",
21
21
  "axios": "^1.7.2",
22
- "debug": "^4.1.1",
22
+ "debug": "^4.3.5",
23
23
  "https-proxy-agent": "^7.0.4",
24
24
  "node-forge": "^1.3.1"
25
25
  },
26
26
  "devDependencies": {
27
- "@types/node": "^20.12.12",
27
+ "@types/node": "^20.14.10",
28
28
  "chai": "^4.4.1",
29
29
  "chai-as-promised": "^7.1.2",
30
30
  "eslint": "^8.57.0",
31
31
  "eslint-config-airbnb-base": "^15.0.0",
32
32
  "eslint-plugin-import": "^2.29.1",
33
33
  "jsdoc-to-markdown": "^8.0.1",
34
- "mocha": "^10.4.0",
34
+ "mocha": "^10.6.0",
35
35
  "nock": "^13.5.4",
36
- "tsd": "^0.31.0",
36
+ "tsd": "^0.31.1",
37
37
  "typescript": "^4.8.4",
38
38
  "uuid": "^8.3.2"
39
39
  },
@@ -59,5 +59,5 @@
59
59
  "bugs": {
60
60
  "url": "https://github.com/publishlab/node-acme-client/issues"
61
61
  },
62
- "gitHead": "37a9e6aae0c69f854b4270334f57952aae430482"
62
+ "gitHead": "f17b08ddab8245108dda6e86d59d43ebbd68b9ba"
63
63
  }
package/src/api.js CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  const util = require('./util');
6
+ const { log } = require('./logger');
6
7
 
7
8
  /**
8
9
  * AcmeApi
@@ -17,6 +18,21 @@ class AcmeApi {
17
18
  this.accountUrl = accountUrl;
18
19
  }
19
20
 
21
+ getLocationFromHeader(resp) {
22
+ let locationUrl = resp.headers.location;
23
+ const mapping = this.http.urlMapping;
24
+ if (mapping.mappings) {
25
+ // eslint-disable-next-line guard-for-in,no-restricted-syntax
26
+ for (const key in mapping.mappings) {
27
+ const url = mapping.mappings[key];
28
+ if (locationUrl.indexOf(url) > -1) {
29
+ locationUrl = locationUrl.replace(url, key);
30
+ }
31
+ }
32
+ }
33
+ return locationUrl;
34
+ }
35
+
20
36
  /**
21
37
  * Get account URL
22
38
  *
@@ -103,7 +119,7 @@ class AcmeApi {
103
119
 
104
120
  /* Set account URL */
105
121
  if (resp.headers.location) {
106
- this.accountUrl = resp.headers.location;
122
+ this.accountUrl = this.getLocationFromHeader(resp);
107
123
  }
108
124
 
109
125
  return resp;
package/src/auto.js CHANGED
@@ -137,9 +137,13 @@ module.exports = async (client, userOpts) => {
137
137
  }
138
138
  else {
139
139
  log(`[auto] [${d}] Running challenge verification`);
140
- await client.verifyChallenge(authz, challenge);
140
+ try {
141
+ await client.verifyChallenge(authz, challenge);
142
+ }
143
+ catch (e) {
144
+ log(`[auto] [${d}] challenge verification threw error: ${e.message}`);
145
+ }
141
146
  }
142
-
143
147
  /* Complete challenge and wait for valid status */
144
148
  log(`[auto] [${d}] Completing challenge with ACME provider and waiting for valid status`);
145
149
  await client.completeChallenge(challenge);
@@ -170,11 +174,41 @@ module.exports = async (client, userOpts) => {
170
174
  throw e;
171
175
  }
172
176
  };
177
+ const domainSets = [];
173
178
 
174
- const challengePromises = authorizations.map((authz) => async () => {
175
- await challengeFunc(authz);
179
+ authorizations.forEach((authz) => {
180
+ const d = authz.identifier.value;
181
+ let setd = false;
182
+ // eslint-disable-next-line no-restricted-syntax
183
+ for (const group of domainSets) {
184
+ if (!group[d]) {
185
+ group[d] = authz;
186
+ setd = true;
187
+ }
188
+ }
189
+ if (!setd) {
190
+ const group = {};
191
+ group[d] = authz;
192
+ domainSets.push(group);
193
+ }
176
194
  });
177
195
 
196
+ const allChallengePromises = [];
197
+ // eslint-disable-next-line no-restricted-syntax
198
+ for (const domainSet of domainSets) {
199
+ const challengePromises = [];
200
+ // eslint-disable-next-line guard-for-in,no-restricted-syntax
201
+ for (const domain in domainSet) {
202
+ const authz = domainSet[domain];
203
+ challengePromises.push(async () => {
204
+ log(`[auto] [${domain}] Starting challenge`);
205
+ await challengeFunc(authz);
206
+ });
207
+ }
208
+ allChallengePromises.push(challengePromises);
209
+ }
210
+
211
+ log(`[auto] challengeGroups:${allChallengePromises.length}`);
178
212
  function runAllPromise(tasks) {
179
213
  let promise = Promise.resolve();
180
214
  tasks.forEach((task) => {
@@ -195,9 +229,18 @@ module.exports = async (client, userOpts) => {
195
229
  }
196
230
 
197
231
  try {
198
- log('开始challenge');
199
- await runPromisePa(challengePromises);
200
-
232
+ log(`开始challenge,共${allChallengePromises.length}组`);
233
+ let i = 0;
234
+ // eslint-disable-next-line no-restricted-syntax
235
+ for (const challengePromises of allChallengePromises) {
236
+ i += 1;
237
+ log(`开始第${i}组`);
238
+ if (opts.signal && opts.signal.aborted) {
239
+ throw new Error('用户取消');
240
+ }
241
+ // eslint-disable-next-line no-await-in-loop
242
+ await runPromisePa(challengePromises);
243
+ }
201
244
  log('challenge结束');
202
245
 
203
246
  // log('[auto] Waiting for challenge valid status');
package/src/axios.js CHANGED
@@ -3,10 +3,14 @@
3
3
  */
4
4
 
5
5
  const axios = require('axios');
6
+ const { parseRetryAfterHeader } = require('./util');
7
+ const { log } = require('./logger');
6
8
  const pkg = require('./../package.json');
7
9
 
10
+ const { AxiosError } = axios;
11
+
8
12
  /**
9
- * Instance
13
+ * Defaults
10
14
  */
11
15
 
12
16
  const instance = axios.create();
@@ -19,6 +23,9 @@ instance.defaults.acmeSettings = {
19
23
  httpChallengePort: 80,
20
24
  httpsChallengePort: 443,
21
25
  tlsAlpnChallengePort: 443,
26
+
27
+ retryMaxAttempts: 5,
28
+ retryDefaultDelay: 5,
22
29
  };
23
30
  // instance.defaults.proxy = {
24
31
  // host: '192.168.34.139',
@@ -33,6 +40,85 @@ instance.defaults.acmeSettings = {
33
40
 
34
41
  instance.defaults.adapter = 'http';
35
42
 
43
+ /**
44
+ * Retry requests on server errors or when rate limited
45
+ *
46
+ * https://datatracker.ietf.org/doc/html/rfc8555#section-6.6
47
+ */
48
+
49
+ function isRetryableError(error) {
50
+ return (error.code !== 'ECONNABORTED')
51
+ && (error.code !== 'ERR_NOCK_NO_MATCH')
52
+ && (!error.response
53
+ || (error.response.status === 429)
54
+ || ((error.response.status >= 500) && (error.response.status <= 599)));
55
+ }
56
+
57
+ /* https://github.com/axios/axios/blob/main/lib/core/settle.js */
58
+ function validateStatus(response) {
59
+ const validator = response.config.retryValidateStatus;
60
+
61
+ if (!response.status || !validator || validator(response.status)) {
62
+ return response;
63
+ }
64
+
65
+ throw new AxiosError(
66
+ `Request failed with status code ${response.status}`,
67
+ (Math.floor(response.status / 100) === 4) ? AxiosError.ERR_BAD_REQUEST : AxiosError.ERR_BAD_RESPONSE,
68
+ response.config,
69
+ response.request,
70
+ response,
71
+ );
72
+ }
73
+
74
+ /* Pass all responses through the error interceptor */
75
+ instance.interceptors.request.use((config) => {
76
+ if (!('retryValidateStatus' in config)) {
77
+ config.retryValidateStatus = config.validateStatus;
78
+ }
79
+
80
+ config.validateStatus = () => false;
81
+ return config;
82
+ });
83
+
84
+ /* Handle request retries if applicable */
85
+ instance.interceptors.response.use(null, async (error) => {
86
+ const { config, response } = error;
87
+
88
+ if (!config) {
89
+ return Promise.reject(error);
90
+ }
91
+
92
+ /* Pick up errors we want to retry */
93
+ if (isRetryableError(error)) {
94
+ const { retryMaxAttempts, retryDefaultDelay } = instance.defaults.acmeSettings;
95
+ config.retryAttempt = ('retryAttempt' in config) ? (config.retryAttempt + 1) : 1;
96
+
97
+ if (config.retryAttempt <= retryMaxAttempts) {
98
+ const code = response ? `HTTP ${response.status}` : error.code;
99
+ log(`Caught ${code}, retry attempt ${config.retryAttempt}/${retryMaxAttempts} to URL ${config.url}`);
100
+
101
+ /* Attempt to parse Retry-After header, fallback to default delay */
102
+ let retryAfter = response ? parseRetryAfterHeader(response.headers['retry-after']) : 0;
103
+
104
+ if (retryAfter > 0) {
105
+ log(`Found retry-after response header with value: ${response.headers['retry-after']}, waiting ${retryAfter} seconds`);
106
+ }
107
+ else {
108
+ retryAfter = (retryDefaultDelay * config.retryAttempt);
109
+ log(`Unable to locate or parse retry-after response header, waiting ${retryAfter} seconds`);
110
+ }
111
+
112
+ /* Wait and retry the request */
113
+ await new Promise((resolve) => { setTimeout(resolve, (retryAfter * 1000)); });
114
+ return instance(config);
115
+ }
116
+ }
117
+
118
+ /* Validate and return response */
119
+ return validateStatus(response);
120
+ });
121
+
36
122
  /**
37
123
  * Export instance
38
124
  */
package/src/client.js CHANGED
@@ -300,7 +300,8 @@ class AcmeClient {
300
300
  }
301
301
 
302
302
  /* Add URL to response */
303
- resp.data.url = resp.headers.location;
303
+ resp.data.url = this.api.getLocationFromHeader(resp);
304
+
304
305
  return resp.data;
305
306
  }
306
307
 
@@ -490,6 +491,9 @@ class AcmeClient {
490
491
  const keyAuthorization = await this.getChallengeKeyAuthorization(challenge);
491
492
 
492
493
  const verifyFn = async () => {
494
+ if (this.opts.signal && this.opts.signal.aborted) {
495
+ throw new Error('用户取消');
496
+ }
493
497
  await verify[challenge.type](authz, challenge, keyAuthorization);
494
498
  };
495
499
 
@@ -513,6 +517,9 @@ class AcmeClient {
513
517
  */
514
518
 
515
519
  async completeChallenge(challenge) {
520
+ if (this.opts.signal && this.opts.signal.aborted) {
521
+ throw new Error('用户取消');
522
+ }
516
523
  const resp = await this.api.completeChallenge(challenge.url, {});
517
524
  return resp.data;
518
525
  }
@@ -550,6 +557,10 @@ class AcmeClient {
550
557
  }
551
558
 
552
559
  const verifyFn = async (abort) => {
560
+ if (this.opts.signal && this.opts.signal.aborted) {
561
+ throw new Error('用户取消');
562
+ }
563
+
553
564
  const resp = await this.api.apiRequest(item.url, null, [200]);
554
565
 
555
566
  /* Verify status */
@@ -10,6 +10,7 @@
10
10
  const net = require('net');
11
11
  const { promisify } = require('util');
12
12
  const forge = require('node-forge');
13
+ const { createPrivateEcdsaKey, getPublicKey } = require('./index');
13
14
 
14
15
  const generateKeyPair = promisify(forge.pki.rsa.generateKeyPair);
15
16
 
@@ -378,13 +379,17 @@ function formatCsrAltNames(altNames) {
378
379
  * }, certificateKey);
379
380
  */
380
381
 
381
- exports.createCsr = async (data, key = null) => {
382
- if (!key) {
383
- key = await createPrivateKey(data.keySize);
382
+ exports.createCsr = async (data, keyType = null) => {
383
+ let key = null;
384
+ if (keyType === 'ec') {
385
+ key = await createPrivateEcdsaKey();
384
386
  }
385
- else if (!Buffer.isBuffer(key)) {
386
- key = Buffer.from(key);
387
+ else {
388
+ key = await createPrivateKey(data.keySize);
387
389
  }
390
+ // else if (!Buffer.isBuffer(key)) {
391
+ // key = Buffer.from(key);
392
+ // }
388
393
 
389
394
  if (typeof data.altNames === 'undefined') {
390
395
  data.altNames = [];
@@ -396,6 +401,8 @@ exports.createCsr = async (data, key = null) => {
396
401
  const privateKey = forge.pki.privateKeyFromPem(key);
397
402
  const publicKey = forge.pki.rsa.setPublicKey(privateKey.n, privateKey.e);
398
403
  csr.publicKey = publicKey;
404
+ // const privateKey = key;
405
+ // csr.publicKey = getPublicKey(key);
399
406
 
400
407
  /* Ensure subject common name is present in SAN - https://cabforum.org/wp-content/uploads/BRv1.2.3.pdf */
401
408
  if (data.commonName && !data.altNames.includes(data.commonName)) {
package/src/http.js CHANGED
@@ -55,7 +55,7 @@ class HttpClient {
55
55
  */
56
56
 
57
57
  async request(url, method, opts = {}) {
58
- if (this.urlMapping && this.urlMapping.enabled === true && this.urlMapping.mappings) {
58
+ if (this.urlMapping && this.urlMapping.mappings) {
59
59
  // eslint-disable-next-line no-restricted-syntax
60
60
  for (const key in this.urlMapping.mappings) {
61
61
  if (url.includes(key)) {
@@ -93,9 +93,11 @@ class HttpClient {
93
93
  */
94
94
 
95
95
  async getDirectory() {
96
- const age = (Math.floor(Date.now() / 1000) - this.directoryTimestamp);
96
+ const now = Math.floor(Date.now() / 1000);
97
+ const age = (now - this.directoryTimestamp);
97
98
 
98
99
  if (!this.directoryCache || (age > this.directoryMaxAge)) {
100
+ log(`Refreshing ACME directory, age: ${age}`);
99
101
  const resp = await this.request(this.directoryUrl, 'get');
100
102
 
101
103
  if (resp.status >= 400) {
@@ -107,6 +109,7 @@ class HttpClient {
107
109
  }
108
110
 
109
111
  this.directoryCache = resp.data;
112
+ this.directoryTimestamp = now;
110
113
  }
111
114
 
112
115
  return this.directoryCache;
@@ -131,7 +134,7 @@ class HttpClient {
131
134
  *
132
135
  * https://datatracker.ietf.org/doc/html/rfc8555#section-7.2
133
136
  *
134
- * @returns {Promise<string>} nonce
137
+ * @returns {Promise<string>} Nonce
135
138
  */
136
139
 
137
140
  async getNonce() {
package/src/util.js CHANGED
@@ -84,9 +84,12 @@ function retry(fn, { attempts = 5, min = 5000, max = 30000 } = {}) {
84
84
  }
85
85
 
86
86
  /**
87
- * Parse URLs from link header
87
+ * Parse URLs from Link header
88
88
  *
89
- * @param {string} header Link header contents
89
+ * https://datatracker.ietf.org/doc/html/rfc8555#section-7.4.2
90
+ * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link
91
+ *
92
+ * @param {string} header Header contents
90
93
  * @param {string} rel Link relation, default: `alternate`
91
94
  * @returns {string[]} Array of URLs
92
95
  */
@@ -102,6 +105,37 @@ function parseLinkHeader(header, rel = 'alternate') {
102
105
  return results.filter((r) => r);
103
106
  }
104
107
 
108
+ /**
109
+ * Parse date or duration from Retry-After header
110
+ *
111
+ * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
112
+ *
113
+ * @param {string} header Header contents
114
+ * @returns {number} Retry duration in seconds
115
+ */
116
+
117
+ function parseRetryAfterHeader(header) {
118
+ const sec = parseInt(header, 10);
119
+ const date = new Date(header);
120
+
121
+ /* Seconds into the future */
122
+ if (Number.isSafeInteger(sec) && (sec > 0)) {
123
+ return sec;
124
+ }
125
+
126
+ /* Future date string */
127
+ if (date instanceof Date && !Number.isNaN(date)) {
128
+ const now = new Date();
129
+ const diff = Math.ceil((date.getTime() - now.getTime()) / 1000);
130
+
131
+ if (diff > 0) {
132
+ return diff;
133
+ }
134
+ }
135
+
136
+ return 0;
137
+ }
138
+
105
139
  /**
106
140
  * Find certificate chain with preferred issuer common name
107
141
  * - If issuer is found in multiple chains, the closest to root wins
@@ -161,14 +195,16 @@ function findCertificateChainForIssuer(chains, issuer) {
161
195
  function formatResponseError(resp) {
162
196
  let result;
163
197
 
164
- if (resp.data.error) {
165
- result = resp.data.error.detail || resp.data.error;
166
- }
167
- else {
168
- result = resp.data.detail || JSON.stringify(resp.data);
198
+ if (resp.data) {
199
+ if (resp.data.error) {
200
+ result = resp.data.error.detail || resp.data.error;
201
+ }
202
+ else {
203
+ result = resp.data.detail || JSON.stringify(resp.data);
204
+ }
169
205
  }
170
206
 
171
- return result.replace(/\n/g, '');
207
+ return (result || '').replace(/\n/g, '');
172
208
  }
173
209
 
174
210
  /**
@@ -296,6 +332,7 @@ async function retrieveTlsAlpnCertificate(host, port, timeout = 30000) {
296
332
  module.exports = {
297
333
  retry,
298
334
  parseLinkHeader,
335
+ parseRetryAfterHeader,
299
336
  findCertificateChainForIssuer,
300
337
  formatResponseError,
301
338
  getAuthoritativeDnsResolver,
package/types/index.d.ts CHANGED
@@ -45,6 +45,7 @@ export interface ClientOptions {
45
45
  backoffMin?: number;
46
46
  backoffMax?: number;
47
47
  urlMapping?: UrlMapping;
48
+ signal?: AbortSignal;
48
49
  }
49
50
 
50
51
  export interface ClientExternalAccountBindingOptions {
@@ -61,6 +62,7 @@ export interface ClientAutoOptions {
61
62
  skipChallengeVerification?: boolean;
62
63
  challengePriority?: string[];
63
64
  preferredChain?: string;
65
+ signal?: AbortSignal;
64
66
  }
65
67
 
66
68
  export class Client {