@certd/acme-client 0.2.0 → 0.3.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.
- package/LICENSE +1 -1
- package/README.md +88 -25
- package/package.json +24 -29
- package/src/api.js +15 -8
- package/src/auto.js +102 -112
- package/src/client.js +67 -48
- package/src/crypto/forge.js +9 -7
- package/src/crypto/index.js +526 -0
- package/src/http.js +126 -49
- package/src/index.js +15 -0
- package/src/logger.js +30 -0
- package/src/util.js +148 -63
- package/src/verify.js +58 -27
- package/types/index.d.ts +52 -3
- package/types/test.ts +2 -2
- package/CHANGELOG.md +0 -152
- package/src/util.log.js +0 -8
package/src/client.js
CHANGED
|
@@ -4,28 +4,39 @@
|
|
|
4
4
|
* @namespace Client
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
const debug = logger.info;
|
|
11
|
-
const Promise = require('bluebird');
|
|
7
|
+
const { createHash } = require('crypto');
|
|
8
|
+
const { getPemBodyAsB64u } = require('./crypto');
|
|
9
|
+
const { log } = require('./logger');
|
|
12
10
|
const HttpClient = require('./http');
|
|
13
11
|
const AcmeApi = require('./api');
|
|
14
12
|
const verify = require('./verify');
|
|
15
13
|
const util = require('./util');
|
|
16
14
|
const auto = require('./auto');
|
|
17
|
-
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* ACME states
|
|
19
|
+
*
|
|
20
|
+
* @private
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const validStates = ['ready', 'valid'];
|
|
24
|
+
const pendingStates = ['pending', 'processing'];
|
|
25
|
+
const invalidStates = ['invalid'];
|
|
18
26
|
|
|
19
27
|
|
|
20
28
|
/**
|
|
21
29
|
* Default options
|
|
30
|
+
*
|
|
31
|
+
* @private
|
|
22
32
|
*/
|
|
23
33
|
|
|
24
34
|
const defaultOpts = {
|
|
25
35
|
directoryUrl: undefined,
|
|
26
36
|
accountKey: undefined,
|
|
27
37
|
accountUrl: null,
|
|
28
|
-
|
|
38
|
+
externalAccountBinding: {},
|
|
39
|
+
backoffAttempts: 10,
|
|
29
40
|
backoffMin: 5000,
|
|
30
41
|
backoffMax: 30000
|
|
31
42
|
};
|
|
@@ -39,7 +50,10 @@ const defaultOpts = {
|
|
|
39
50
|
* @param {string} opts.directoryUrl ACME directory URL
|
|
40
51
|
* @param {buffer|string} opts.accountKey PEM encoded account private key
|
|
41
52
|
* @param {string} [opts.accountUrl] Account URL, default: `null`
|
|
42
|
-
* @param {
|
|
53
|
+
* @param {object} [opts.externalAccountBinding]
|
|
54
|
+
* @param {string} [opts.externalAccountBinding.kid] External account binding KID
|
|
55
|
+
* @param {string} [opts.externalAccountBinding.hmacKey] External account binding HMAC key
|
|
56
|
+
* @param {number} [opts.backoffAttempts] Maximum number of backoff attempts, default: `10`
|
|
43
57
|
* @param {number} [opts.backoffMin] Minimum backoff attempt delay in milliseconds, default: `5000`
|
|
44
58
|
* @param {number} [opts.backoffMax] Maximum backoff attempt delay in milliseconds, default: `30000`
|
|
45
59
|
*
|
|
@@ -57,11 +71,23 @@ const defaultOpts = {
|
|
|
57
71
|
* directoryUrl: acme.directory.letsencrypt.staging,
|
|
58
72
|
* accountKey: 'Private key goes here',
|
|
59
73
|
* accountUrl: 'Optional account URL goes here',
|
|
60
|
-
* backoffAttempts:
|
|
74
|
+
* backoffAttempts: 10,
|
|
61
75
|
* backoffMin: 5000,
|
|
62
76
|
* backoffMax: 30000
|
|
63
77
|
* });
|
|
64
78
|
* ```
|
|
79
|
+
*
|
|
80
|
+
* @example Create ACME client with external account binding
|
|
81
|
+
* ```js
|
|
82
|
+
* const client = new acme.Client({
|
|
83
|
+
* directoryUrl: 'https://acme-provider.example.com/directory-url',
|
|
84
|
+
* accountKey: 'Private key goes here',
|
|
85
|
+
* externalAccountBinding: {
|
|
86
|
+
* kid: 'YOUR-EAB-KID',
|
|
87
|
+
* hmacKey: 'YOUR-EAB-HMAC-KEY'
|
|
88
|
+
* }
|
|
89
|
+
* });
|
|
90
|
+
* ```
|
|
65
91
|
*/
|
|
66
92
|
|
|
67
93
|
class AcmeClient {
|
|
@@ -78,7 +104,7 @@ class AcmeClient {
|
|
|
78
104
|
max: this.opts.backoffMax
|
|
79
105
|
};
|
|
80
106
|
|
|
81
|
-
this.http = new HttpClient(this.opts.directoryUrl, this.opts.accountKey);
|
|
107
|
+
this.http = new HttpClient(this.opts.directoryUrl, this.opts.accountKey, this.opts.externalAccountBinding);
|
|
82
108
|
this.api = new AcmeApi(this.http, this.opts.accountUrl);
|
|
83
109
|
}
|
|
84
110
|
|
|
@@ -154,17 +180,17 @@ class AcmeClient {
|
|
|
154
180
|
this.getAccountUrl();
|
|
155
181
|
|
|
156
182
|
/* Account URL exists */
|
|
157
|
-
|
|
183
|
+
log('Account URL exists, returning updateAccount()');
|
|
158
184
|
return this.updateAccount(data);
|
|
159
185
|
}
|
|
160
186
|
catch (e) {
|
|
161
187
|
const resp = await this.api.createAccount(data);
|
|
162
|
-
|
|
188
|
+
|
|
163
189
|
/* HTTP 200: Account exists */
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
190
|
+
if (resp.status === 200) {
|
|
191
|
+
log('Account already exists (HTTP 200), returning updateAccount()');
|
|
192
|
+
return this.updateAccount(data);
|
|
193
|
+
}
|
|
168
194
|
|
|
169
195
|
return resp.data;
|
|
170
196
|
}
|
|
@@ -192,7 +218,7 @@ class AcmeClient {
|
|
|
192
218
|
this.api.getAccountUrl();
|
|
193
219
|
}
|
|
194
220
|
catch (e) {
|
|
195
|
-
|
|
221
|
+
log('No account URL found, returning createAccount()');
|
|
196
222
|
return this.createAccount(data);
|
|
197
223
|
}
|
|
198
224
|
|
|
@@ -238,16 +264,13 @@ class AcmeClient {
|
|
|
238
264
|
const newHttpClient = new HttpClient(this.opts.directoryUrl, newAccountKey);
|
|
239
265
|
const newApiClient = new AcmeApi(newHttpClient, accountUrl);
|
|
240
266
|
|
|
241
|
-
/* Get
|
|
267
|
+
/* Get old JWK */
|
|
242
268
|
data.account = accountUrl;
|
|
243
|
-
data.oldKey =
|
|
244
|
-
|
|
245
|
-
/* TODO: Backward-compatibility with draft-ietf-acme-12, remove this in a later release */
|
|
246
|
-
data.newKey = await newHttpClient.getJwk();
|
|
269
|
+
data.oldKey = this.http.getJwk();
|
|
247
270
|
|
|
248
271
|
/* Get signed request body from new client */
|
|
249
272
|
const url = await newHttpClient.getResourceUrl('keyChange');
|
|
250
|
-
const body =
|
|
273
|
+
const body = newHttpClient.createSignedBody(url, data);
|
|
251
274
|
|
|
252
275
|
/* Change key using old client */
|
|
253
276
|
const resp = await this.api.updateAccountKey(body);
|
|
@@ -345,9 +368,7 @@ class AcmeClient {
|
|
|
345
368
|
csr = Buffer.from(csr);
|
|
346
369
|
}
|
|
347
370
|
|
|
348
|
-
const
|
|
349
|
-
const data = { csr: util.b64escape(body) };
|
|
350
|
-
|
|
371
|
+
const data = { csr: getPemBodyAsB64u(csr) };
|
|
351
372
|
const resp = await this.api.finalizeOrder(order.finalize, data);
|
|
352
373
|
|
|
353
374
|
/* Add URL to response */
|
|
@@ -376,13 +397,13 @@ class AcmeClient {
|
|
|
376
397
|
*/
|
|
377
398
|
|
|
378
399
|
async getAuthorizations(order) {
|
|
379
|
-
return Promise.
|
|
400
|
+
return Promise.all((order.authorizations || []).map(async (url) => {
|
|
380
401
|
const resp = await this.api.getAuthorization(url);
|
|
381
402
|
|
|
382
403
|
/* Add URL to response */
|
|
383
404
|
resp.data.url = url;
|
|
384
405
|
return resp.data;
|
|
385
|
-
});
|
|
406
|
+
}));
|
|
386
407
|
}
|
|
387
408
|
|
|
388
409
|
|
|
@@ -436,9 +457,9 @@ class AcmeClient {
|
|
|
436
457
|
*/
|
|
437
458
|
|
|
438
459
|
async getChallengeKeyAuthorization(challenge) {
|
|
439
|
-
const jwk =
|
|
440
|
-
const keysum =
|
|
441
|
-
const thumbprint =
|
|
460
|
+
const jwk = this.http.getJwk();
|
|
461
|
+
const keysum = createHash('sha256').update(JSON.stringify(jwk));
|
|
462
|
+
const thumbprint = keysum.digest('base64url');
|
|
442
463
|
const result = `${challenge.token}.${thumbprint}`;
|
|
443
464
|
|
|
444
465
|
/**
|
|
@@ -455,8 +476,8 @@ class AcmeClient {
|
|
|
455
476
|
*/
|
|
456
477
|
|
|
457
478
|
if ((challenge.type === 'dns-01') || (challenge.type === 'tls-alpn-01')) {
|
|
458
|
-
const shasum =
|
|
459
|
-
return
|
|
479
|
+
const shasum = createHash('sha256').update(result);
|
|
480
|
+
return shasum.digest('base64url');
|
|
460
481
|
}
|
|
461
482
|
|
|
462
483
|
throw new Error(`Unable to produce key authorization, unknown challenge type: ${challenge.type}`);
|
|
@@ -493,7 +514,7 @@ class AcmeClient {
|
|
|
493
514
|
await verify[challenge.type](authz, challenge, keyAuthorization);
|
|
494
515
|
};
|
|
495
516
|
|
|
496
|
-
|
|
517
|
+
log('Waiting for ACME challenge verification', this.backoffOpts);
|
|
497
518
|
return util.retry(verifyFn, this.backoffOpts);
|
|
498
519
|
}
|
|
499
520
|
|
|
@@ -555,23 +576,23 @@ class AcmeClient {
|
|
|
555
576
|
const resp = await this.api.apiRequest(item.url, null, [200]);
|
|
556
577
|
|
|
557
578
|
/* Verify status */
|
|
558
|
-
|
|
579
|
+
log(`Item has status: ${resp.data.status}`);
|
|
559
580
|
|
|
560
|
-
if (resp.data.status
|
|
581
|
+
if (invalidStates.includes(resp.data.status)) {
|
|
561
582
|
abort();
|
|
562
|
-
throw new Error(
|
|
583
|
+
throw new Error(util.formatResponseError(resp));
|
|
563
584
|
}
|
|
564
|
-
else if (resp.data.status
|
|
565
|
-
throw new Error('Operation is pending');
|
|
585
|
+
else if (pendingStates.includes(resp.data.status)) {
|
|
586
|
+
throw new Error('Operation is pending or processing');
|
|
566
587
|
}
|
|
567
|
-
else if (resp.data.status
|
|
588
|
+
else if (validStates.includes(resp.data.status)) {
|
|
568
589
|
return resp.data;
|
|
569
590
|
}
|
|
570
591
|
|
|
571
592
|
throw new Error(`Unexpected item status: ${resp.data.status}`);
|
|
572
593
|
};
|
|
573
594
|
|
|
574
|
-
|
|
595
|
+
log(`Waiting for valid status from: ${item.url}`, this.backoffOpts);
|
|
575
596
|
return util.retry(verifyFn, this.backoffOpts);
|
|
576
597
|
}
|
|
577
598
|
|
|
@@ -599,7 +620,7 @@ class AcmeClient {
|
|
|
599
620
|
*/
|
|
600
621
|
|
|
601
622
|
async getCertificate(order, preferredChain = null) {
|
|
602
|
-
if (order.status
|
|
623
|
+
if (!validStates.includes(order.status)) {
|
|
603
624
|
order = await this.waitForValidStatus(order);
|
|
604
625
|
}
|
|
605
626
|
|
|
@@ -612,7 +633,7 @@ class AcmeClient {
|
|
|
612
633
|
/* Handle alternate certificate chains */
|
|
613
634
|
if (preferredChain && resp.headers.link) {
|
|
614
635
|
const alternateLinks = util.parseLinkHeader(resp.headers.link);
|
|
615
|
-
const alternates = await Promise.map(
|
|
636
|
+
const alternates = await Promise.all(alternateLinks.map(async (link) => this.api.apiRequest(link, null, [200])));
|
|
616
637
|
const certificates = [resp].concat(alternates).map((c) => c.data);
|
|
617
638
|
|
|
618
639
|
return util.findCertificateChainForIssuer(certificates, preferredChain);
|
|
@@ -648,9 +669,7 @@ class AcmeClient {
|
|
|
648
669
|
*/
|
|
649
670
|
|
|
650
671
|
async revokeCertificate(cert, data = {}) {
|
|
651
|
-
|
|
652
|
-
data.certificate = util.b64escape(body);
|
|
653
|
-
|
|
672
|
+
data.certificate = getPemBodyAsB64u(cert);
|
|
654
673
|
const resp = await this.api.revokeCert(data);
|
|
655
674
|
return resp.data;
|
|
656
675
|
}
|
|
@@ -672,7 +691,7 @@ class AcmeClient {
|
|
|
672
691
|
*
|
|
673
692
|
* @example Order a certificate using auto mode
|
|
674
693
|
* ```js
|
|
675
|
-
* const [certificateKey, certificateRequest] = await acme.
|
|
694
|
+
* const [certificateKey, certificateRequest] = await acme.crypto.createCsr({
|
|
676
695
|
* commonName: 'test.example.com'
|
|
677
696
|
* });
|
|
678
697
|
*
|
|
@@ -691,7 +710,7 @@ class AcmeClient {
|
|
|
691
710
|
*
|
|
692
711
|
* @example Order a certificate using auto mode with preferred chain
|
|
693
712
|
* ```js
|
|
694
|
-
* const [certificateKey, certificateRequest] = await acme.
|
|
713
|
+
* const [certificateKey, certificateRequest] = await acme.crypto.createCsr({
|
|
695
714
|
* commonName: 'test.example.com'
|
|
696
715
|
* });
|
|
697
716
|
*
|
package/src/crypto/forge.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* node-forge crypto
|
|
2
|
+
* Legacy node-forge crypto interface
|
|
3
|
+
*
|
|
4
|
+
* DEPRECATION WARNING: This crypto interface is deprecated and will be removed from acme-client in a future
|
|
5
|
+
* major release. Please migrate to the new `acme.crypto` interface at your earliest convenience.
|
|
3
6
|
*
|
|
4
7
|
* @namespace forge
|
|
5
8
|
*/
|
|
6
9
|
|
|
7
10
|
const net = require('net');
|
|
8
|
-
const
|
|
11
|
+
const { promisify } = require('util');
|
|
9
12
|
const forge = require('node-forge');
|
|
10
13
|
|
|
11
|
-
const generateKeyPair =
|
|
14
|
+
const generateKeyPair = promisify(forge.pki.rsa.generateKeyPair);
|
|
12
15
|
|
|
13
16
|
|
|
14
17
|
/**
|
|
@@ -123,7 +126,6 @@ async function createPrivateKey(size = 2048) {
|
|
|
123
126
|
|
|
124
127
|
// convert a PKCS#8 ASN.1 PrivateKeyInfo to PEM
|
|
125
128
|
var pemKey = forge.pki.privateKeyInfoToPem(privateKeyInfo);
|
|
126
|
-
console.log('privatekey ', pemKey)
|
|
127
129
|
return Buffer.from(pemKey);
|
|
128
130
|
}
|
|
129
131
|
|
|
@@ -152,7 +154,7 @@ exports.createPublicKey = async function (key) {
|
|
|
152
154
|
|
|
153
155
|
|
|
154
156
|
/**
|
|
155
|
-
* Parse body of PEM encoded object
|
|
157
|
+
* Parse body of PEM encoded object from buffer or string
|
|
156
158
|
* If multiple objects are chained, the first body will be returned
|
|
157
159
|
*
|
|
158
160
|
* @param {buffer|string} str PEM encoded buffer or string
|
|
@@ -445,8 +447,8 @@ exports.createCsr = async function (data, key = null) {
|
|
|
445
447
|
}]);
|
|
446
448
|
}
|
|
447
449
|
|
|
448
|
-
/* Sign CSR */
|
|
449
|
-
csr.sign(privateKey);
|
|
450
|
+
/* Sign CSR using SHA-256 */
|
|
451
|
+
csr.sign(privateKey, forge.md.sha256.create());
|
|
450
452
|
|
|
451
453
|
/* Done */
|
|
452
454
|
const pemCsr = forge.pki.certificationRequestToPem(csr);
|