@certd/acme-client 0.1.6 → 0.2.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/CHANGELOG.md +152 -152
- package/LICENSE +21 -21
- package/README.md +199 -199
- package/package.json +5 -4
- package/src/api.js +243 -243
- package/src/auto.js +203 -199
- package/src/axios.js +40 -40
- package/src/client.js +716 -716
- package/src/crypto/forge.js +454 -445
- package/src/http.js +241 -241
- package/src/index.js +31 -31
- package/src/util.js +173 -172
- package/src/util.log.js +8 -8
- package/src/verify.js +96 -96
- package/types/index.d.ts +141 -141
- package/types/rfc8555.d.ts +127 -127
- package/types/test.ts +70 -70
- package/types/tsconfig.json +11 -11
- package/types/tslint.json +6 -6
package/src/client.js
CHANGED
|
@@ -1,716 +1,716 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ACME client
|
|
3
|
-
*
|
|
4
|
-
* @namespace Client
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
const crypto = require('crypto');
|
|
8
|
-
const logger = require('./util.log.js');
|
|
9
|
-
|
|
10
|
-
const debug = logger.info;
|
|
11
|
-
const Promise = require('bluebird');
|
|
12
|
-
const HttpClient = require('./http');
|
|
13
|
-
const AcmeApi = require('./api');
|
|
14
|
-
const verify = require('./verify');
|
|
15
|
-
const util = require('./util');
|
|
16
|
-
const auto = require('./auto');
|
|
17
|
-
const forge = require('./crypto/forge');
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Default options
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
const defaultOpts = {
|
|
25
|
-
directoryUrl: undefined,
|
|
26
|
-
accountKey: undefined,
|
|
27
|
-
accountUrl: null,
|
|
28
|
-
backoffAttempts: 5,
|
|
29
|
-
backoffMin: 5000,
|
|
30
|
-
backoffMax: 30000
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* AcmeClient
|
|
36
|
-
*
|
|
37
|
-
* @class
|
|
38
|
-
* @param {object} opts
|
|
39
|
-
* @param {string} opts.directoryUrl ACME directory URL
|
|
40
|
-
* @param {buffer|string} opts.accountKey PEM encoded account private key
|
|
41
|
-
* @param {string} [opts.accountUrl] Account URL, default: `null`
|
|
42
|
-
* @param {number} [opts.backoffAttempts] Maximum number of backoff attempts, default: `5`
|
|
43
|
-
* @param {number} [opts.backoffMin] Minimum backoff attempt delay in milliseconds, default: `5000`
|
|
44
|
-
* @param {number} [opts.backoffMax] Maximum backoff attempt delay in milliseconds, default: `30000`
|
|
45
|
-
*
|
|
46
|
-
* @example Create ACME client instance
|
|
47
|
-
* ```js
|
|
48
|
-
* const client = new acme.Client({
|
|
49
|
-
* directoryUrl: acme.directory.letsencrypt.staging,
|
|
50
|
-
* accountKey: 'Private key goes here'
|
|
51
|
-
* });
|
|
52
|
-
* ```
|
|
53
|
-
*
|
|
54
|
-
* @example Create ACME client instance
|
|
55
|
-
* ```js
|
|
56
|
-
* const client = new acme.Client({
|
|
57
|
-
* directoryUrl: acme.directory.letsencrypt.staging,
|
|
58
|
-
* accountKey: 'Private key goes here',
|
|
59
|
-
* accountUrl: 'Optional account URL goes here',
|
|
60
|
-
* backoffAttempts: 5,
|
|
61
|
-
* backoffMin: 5000,
|
|
62
|
-
* backoffMax: 30000
|
|
63
|
-
* });
|
|
64
|
-
* ```
|
|
65
|
-
*/
|
|
66
|
-
|
|
67
|
-
class AcmeClient {
|
|
68
|
-
constructor(opts) {
|
|
69
|
-
if (!Buffer.isBuffer(opts.accountKey)) {
|
|
70
|
-
opts.accountKey = Buffer.from(opts.accountKey);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
this.opts = Object.assign({}, defaultOpts, opts);
|
|
74
|
-
|
|
75
|
-
this.backoffOpts = {
|
|
76
|
-
attempts: this.opts.backoffAttempts,
|
|
77
|
-
min: this.opts.backoffMin,
|
|
78
|
-
max: this.opts.backoffMax
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
this.http = new HttpClient(this.opts.directoryUrl, this.opts.accountKey);
|
|
82
|
-
this.api = new AcmeApi(this.http, this.opts.accountUrl);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Get Terms of Service URL if available
|
|
88
|
-
*
|
|
89
|
-
* @returns {Promise<string|null>} ToS URL
|
|
90
|
-
*
|
|
91
|
-
* @example Get Terms of Service URL
|
|
92
|
-
* ```js
|
|
93
|
-
* const termsOfService = client.getTermsOfServiceUrl();
|
|
94
|
-
*
|
|
95
|
-
* if (!termsOfService) {
|
|
96
|
-
* // CA did not provide Terms of Service
|
|
97
|
-
* }
|
|
98
|
-
* ```
|
|
99
|
-
*/
|
|
100
|
-
|
|
101
|
-
getTermsOfServiceUrl() {
|
|
102
|
-
return this.api.getTermsOfServiceUrl();
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Get current account URL
|
|
108
|
-
*
|
|
109
|
-
* @returns {string} Account URL
|
|
110
|
-
* @throws {Error} No account URL found
|
|
111
|
-
*
|
|
112
|
-
* @example Get current account URL
|
|
113
|
-
* ```js
|
|
114
|
-
* try {
|
|
115
|
-
* const accountUrl = client.getAccountUrl();
|
|
116
|
-
* }
|
|
117
|
-
* catch (e) {
|
|
118
|
-
* // No account URL exists, need to create account first
|
|
119
|
-
* }
|
|
120
|
-
* ```
|
|
121
|
-
*/
|
|
122
|
-
|
|
123
|
-
getAccountUrl() {
|
|
124
|
-
return this.api.getAccountUrl();
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Create a new account
|
|
130
|
-
*
|
|
131
|
-
* https://tools.ietf.org/html/rfc8555#section-7.3
|
|
132
|
-
*
|
|
133
|
-
* @param {object} [data] Request data
|
|
134
|
-
* @returns {Promise<object>} Account
|
|
135
|
-
*
|
|
136
|
-
* @example Create a new account
|
|
137
|
-
* ```js
|
|
138
|
-
* const account = await client.createAccount({
|
|
139
|
-
* termsOfServiceAgreed: true
|
|
140
|
-
* });
|
|
141
|
-
* ```
|
|
142
|
-
*
|
|
143
|
-
* @example Create a new account with contact info
|
|
144
|
-
* ```js
|
|
145
|
-
* const account = await client.createAccount({
|
|
146
|
-
* termsOfServiceAgreed: true,
|
|
147
|
-
* contact: ['mailto:test@example.com']
|
|
148
|
-
* });
|
|
149
|
-
* ```
|
|
150
|
-
*/
|
|
151
|
-
|
|
152
|
-
async createAccount(data = {}) {
|
|
153
|
-
try {
|
|
154
|
-
this.getAccountUrl();
|
|
155
|
-
|
|
156
|
-
/* Account URL exists */
|
|
157
|
-
logger.info('Account URL exists, returning updateAccount()');
|
|
158
|
-
return this.updateAccount(data);
|
|
159
|
-
}
|
|
160
|
-
catch (e) {
|
|
161
|
-
const resp = await this.api.createAccount(data);
|
|
162
|
-
// TODO 先注释,可加快速度
|
|
163
|
-
/* HTTP 200: Account exists */
|
|
164
|
-
// if (resp.status === 200) {
|
|
165
|
-
// logger.info('Account already exists (HTTP 200), returning updateAccount()');
|
|
166
|
-
// return this.updateAccount(data);
|
|
167
|
-
// }
|
|
168
|
-
|
|
169
|
-
return resp.data;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Update existing account
|
|
176
|
-
*
|
|
177
|
-
* https://tools.ietf.org/html/rfc8555#section-7.3.2
|
|
178
|
-
*
|
|
179
|
-
* @param {object} [data] Request data
|
|
180
|
-
* @returns {Promise<object>} Account
|
|
181
|
-
*
|
|
182
|
-
* @example Update existing account
|
|
183
|
-
* ```js
|
|
184
|
-
* const account = await client.updateAccount({
|
|
185
|
-
* contact: ['mailto:foo@example.com']
|
|
186
|
-
* });
|
|
187
|
-
* ```
|
|
188
|
-
*/
|
|
189
|
-
|
|
190
|
-
async updateAccount(data = {}) {
|
|
191
|
-
try {
|
|
192
|
-
this.api.getAccountUrl();
|
|
193
|
-
}
|
|
194
|
-
catch (e) {
|
|
195
|
-
logger.info('No account URL found, returning createAccount()');
|
|
196
|
-
return this.createAccount(data);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/* Remove data only applicable to createAccount() */
|
|
200
|
-
if ('onlyReturnExisting' in data) {
|
|
201
|
-
delete data.onlyReturnExisting;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/* POST-as-GET */
|
|
205
|
-
if (Object.keys(data).length === 0) {
|
|
206
|
-
data = null;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const resp = await this.api.updateAccount(data);
|
|
210
|
-
return resp.data;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Update account private key
|
|
216
|
-
*
|
|
217
|
-
* https://tools.ietf.org/html/rfc8555#section-7.3.5
|
|
218
|
-
*
|
|
219
|
-
* @param {buffer|string} newAccountKey New PEM encoded private key
|
|
220
|
-
* @param {object} [data] Additional request data
|
|
221
|
-
* @returns {Promise<object>} Account
|
|
222
|
-
*
|
|
223
|
-
* @example Update account private key
|
|
224
|
-
* ```js
|
|
225
|
-
* const newAccountKey = 'New private key goes here';
|
|
226
|
-
* const result = await client.updateAccountKey(newAccountKey);
|
|
227
|
-
* ```
|
|
228
|
-
*/
|
|
229
|
-
|
|
230
|
-
async updateAccountKey(newAccountKey, data = {}) {
|
|
231
|
-
if (!Buffer.isBuffer(newAccountKey)) {
|
|
232
|
-
newAccountKey = Buffer.from(newAccountKey);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const accountUrl = this.api.getAccountUrl();
|
|
236
|
-
|
|
237
|
-
/* Create new HTTP and API clients using new key */
|
|
238
|
-
const newHttpClient = new HttpClient(this.opts.directoryUrl, newAccountKey);
|
|
239
|
-
const newApiClient = new AcmeApi(newHttpClient, accountUrl);
|
|
240
|
-
|
|
241
|
-
/* Get new JWK */
|
|
242
|
-
data.account = accountUrl;
|
|
243
|
-
data.oldKey = await this.http.getJwk();
|
|
244
|
-
|
|
245
|
-
/* TODO: Backward-compatibility with draft-ietf-acme-12, remove this in a later release */
|
|
246
|
-
data.newKey = await newHttpClient.getJwk();
|
|
247
|
-
|
|
248
|
-
/* Get signed request body from new client */
|
|
249
|
-
const url = await newHttpClient.getResourceUrl('keyChange');
|
|
250
|
-
const body = await newHttpClient.createSignedBody(url, data);
|
|
251
|
-
|
|
252
|
-
/* Change key using old client */
|
|
253
|
-
const resp = await this.api.updateAccountKey(body);
|
|
254
|
-
|
|
255
|
-
/* Replace existing HTTP and API client */
|
|
256
|
-
this.http = newHttpClient;
|
|
257
|
-
this.api = newApiClient;
|
|
258
|
-
|
|
259
|
-
return resp.data;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* Create a new order
|
|
265
|
-
*
|
|
266
|
-
* https://tools.ietf.org/html/rfc8555#section-7.4
|
|
267
|
-
*
|
|
268
|
-
* @param {object} data Request data
|
|
269
|
-
* @returns {Promise<object>} Order
|
|
270
|
-
*
|
|
271
|
-
* @example Create a new order
|
|
272
|
-
* ```js
|
|
273
|
-
* const order = await client.createOrder({
|
|
274
|
-
* identifiers: [
|
|
275
|
-
* { type: 'dns', value: 'example.com' },
|
|
276
|
-
* { type: 'dns', value: 'test.example.com' }
|
|
277
|
-
* ]
|
|
278
|
-
* });
|
|
279
|
-
* ```
|
|
280
|
-
*/
|
|
281
|
-
|
|
282
|
-
async createOrder(data) {
|
|
283
|
-
const resp = await this.api.createOrder(data);
|
|
284
|
-
|
|
285
|
-
if (!resp.headers.location) {
|
|
286
|
-
throw new Error('Creating a new order did not return an order link');
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/* Add URL to response */
|
|
290
|
-
resp.data.url = resp.headers.location;
|
|
291
|
-
return resp.data;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
/**
|
|
296
|
-
* Refresh order object from CA
|
|
297
|
-
*
|
|
298
|
-
* https://tools.ietf.org/html/rfc8555#section-7.4
|
|
299
|
-
*
|
|
300
|
-
* @param {object} order Order object
|
|
301
|
-
* @returns {Promise<object>} Order
|
|
302
|
-
*
|
|
303
|
-
* @example
|
|
304
|
-
* ```js
|
|
305
|
-
* const order = { ... }; // Previously created order object
|
|
306
|
-
* const result = await client.getOrder(order);
|
|
307
|
-
* ```
|
|
308
|
-
*/
|
|
309
|
-
|
|
310
|
-
async getOrder(order) {
|
|
311
|
-
if (!order.url) {
|
|
312
|
-
throw new Error('Unable to get order, URL not found');
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
const resp = await this.api.getOrder(order.url);
|
|
316
|
-
|
|
317
|
-
/* Add URL to response */
|
|
318
|
-
resp.data.url = order.url;
|
|
319
|
-
return resp.data;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* Finalize order
|
|
324
|
-
*
|
|
325
|
-
* https://tools.ietf.org/html/rfc8555#section-7.4
|
|
326
|
-
*
|
|
327
|
-
* @param {object} order Order object
|
|
328
|
-
* @param {buffer|string} csr PEM encoded Certificate Signing Request
|
|
329
|
-
* @returns {Promise<object>} Order
|
|
330
|
-
*
|
|
331
|
-
* @example Finalize order
|
|
332
|
-
* ```js
|
|
333
|
-
* const order = { ... }; // Previously created order object
|
|
334
|
-
* const csr = { ... }; // Previously created Certificate Signing Request
|
|
335
|
-
* const result = await client.finalizeOrder(order, csr);
|
|
336
|
-
* ```
|
|
337
|
-
*/
|
|
338
|
-
|
|
339
|
-
async finalizeOrder(order, csr) {
|
|
340
|
-
if (!order.finalize) {
|
|
341
|
-
throw new Error('Unable to finalize order, URL not found');
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
if (!Buffer.isBuffer(csr)) {
|
|
345
|
-
csr = Buffer.from(csr);
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
const body = forge.getPemBody(csr);
|
|
349
|
-
const data = { csr: util.b64escape(body) };
|
|
350
|
-
|
|
351
|
-
const resp = await this.api.finalizeOrder(order.finalize, data);
|
|
352
|
-
|
|
353
|
-
/* Add URL to response */
|
|
354
|
-
resp.data.url = order.url;
|
|
355
|
-
return resp.data;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* Get identifier authorizations from order
|
|
361
|
-
*
|
|
362
|
-
* https://tools.ietf.org/html/rfc8555#section-7.5
|
|
363
|
-
*
|
|
364
|
-
* @param {object} order Order
|
|
365
|
-
* @returns {Promise<object[]>} Authorizations
|
|
366
|
-
*
|
|
367
|
-
* @example Get identifier authorizations
|
|
368
|
-
* ```js
|
|
369
|
-
* const order = { ... }; // Previously created order object
|
|
370
|
-
* const authorizations = await client.getAuthorizations(order);
|
|
371
|
-
*
|
|
372
|
-
* authorizations.forEach((authz) => {
|
|
373
|
-
* const { challenges } = authz;
|
|
374
|
-
* });
|
|
375
|
-
* ```
|
|
376
|
-
*/
|
|
377
|
-
|
|
378
|
-
async getAuthorizations(order) {
|
|
379
|
-
return Promise.map((order.authorizations || []), async (url) => {
|
|
380
|
-
const resp = await this.api.getAuthorization(url);
|
|
381
|
-
|
|
382
|
-
/* Add URL to response */
|
|
383
|
-
resp.data.url = url;
|
|
384
|
-
return resp.data;
|
|
385
|
-
});
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
/**
|
|
390
|
-
* Deactivate identifier authorization
|
|
391
|
-
*
|
|
392
|
-
* https://tools.ietf.org/html/rfc8555#section-7.5.2
|
|
393
|
-
*
|
|
394
|
-
* @param {object} authz Identifier authorization
|
|
395
|
-
* @returns {Promise<object>} Authorization
|
|
396
|
-
*
|
|
397
|
-
* @example Deactivate identifier authorization
|
|
398
|
-
* ```js
|
|
399
|
-
* const authz = { ... }; // Identifier authorization resolved from previously created order
|
|
400
|
-
* const result = await client.deactivateAuthorization(authz);
|
|
401
|
-
* ```
|
|
402
|
-
*/
|
|
403
|
-
|
|
404
|
-
async deactivateAuthorization(authz) {
|
|
405
|
-
if (!authz.url) {
|
|
406
|
-
throw new Error('Unable to deactivate identifier authorization, URL not found');
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
const data = {
|
|
410
|
-
status: 'deactivated'
|
|
411
|
-
};
|
|
412
|
-
|
|
413
|
-
const resp = await this.api.updateAuthorization(authz.url, data);
|
|
414
|
-
|
|
415
|
-
/* Add URL to response */
|
|
416
|
-
resp.data.url = authz.url;
|
|
417
|
-
return resp.data;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
/**
|
|
422
|
-
* Get key authorization for ACME challenge
|
|
423
|
-
*
|
|
424
|
-
* https://tools.ietf.org/html/rfc8555#section-8.1
|
|
425
|
-
*
|
|
426
|
-
* @param {object} challenge Challenge object returned by API
|
|
427
|
-
* @returns {Promise<string>} Key authorization
|
|
428
|
-
*
|
|
429
|
-
* @example Get challenge key authorization
|
|
430
|
-
* ```js
|
|
431
|
-
* const challenge = { ... }; // Challenge from previously resolved identifier authorization
|
|
432
|
-
* const key = await client.getChallengeKeyAuthorization(challenge);
|
|
433
|
-
*
|
|
434
|
-
* // Write key somewhere to satisfy challenge
|
|
435
|
-
* ```
|
|
436
|
-
*/
|
|
437
|
-
|
|
438
|
-
async getChallengeKeyAuthorization(challenge) {
|
|
439
|
-
const jwk = await this.http.getJwk();
|
|
440
|
-
const keysum = crypto.createHash('sha256').update(JSON.stringify(jwk));
|
|
441
|
-
const thumbprint = util.b64escape(keysum.digest('base64'));
|
|
442
|
-
const result = `${challenge.token}.${thumbprint}`;
|
|
443
|
-
|
|
444
|
-
/**
|
|
445
|
-
* https://tools.ietf.org/html/rfc8555#section-8.3
|
|
446
|
-
*/
|
|
447
|
-
|
|
448
|
-
if (challenge.type === 'http-01') {
|
|
449
|
-
return result;
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
/**
|
|
453
|
-
* https://tools.ietf.org/html/rfc8555#section-8.4
|
|
454
|
-
* https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-01
|
|
455
|
-
*/
|
|
456
|
-
|
|
457
|
-
if ((challenge.type === 'dns-01') || (challenge.type === 'tls-alpn-01')) {
|
|
458
|
-
const shasum = crypto.createHash('sha256').update(result);
|
|
459
|
-
return util.b64escape(shasum.digest('base64'));
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
throw new Error(`Unable to produce key authorization, unknown challenge type: ${challenge.type}`);
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
/**
|
|
467
|
-
* Verify that ACME challenge is satisfied
|
|
468
|
-
*
|
|
469
|
-
* @param {object} authz Identifier authorization
|
|
470
|
-
* @param {object} challenge Authorization challenge
|
|
471
|
-
* @returns {Promise}
|
|
472
|
-
*
|
|
473
|
-
* @example Verify satisfied ACME challenge
|
|
474
|
-
* ```js
|
|
475
|
-
* const authz = { ... }; // Identifier authorization
|
|
476
|
-
* const challenge = { ... }; // Satisfied challenge
|
|
477
|
-
* await client.verifyChallenge(authz, challenge);
|
|
478
|
-
* ```
|
|
479
|
-
*/
|
|
480
|
-
|
|
481
|
-
async verifyChallenge(authz, challenge) {
|
|
482
|
-
if (!authz.url || !challenge.url) {
|
|
483
|
-
throw new Error('Unable to verify ACME challenge, URL not found');
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
if (typeof verify[challenge.type] === 'undefined') {
|
|
487
|
-
throw new Error(`Unable to verify ACME challenge, unknown type: ${challenge.type}`);
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
const keyAuthorization = await this.getChallengeKeyAuthorization(challenge);
|
|
491
|
-
|
|
492
|
-
const verifyFn = async () => {
|
|
493
|
-
await verify[challenge.type](authz, challenge, keyAuthorization);
|
|
494
|
-
};
|
|
495
|
-
|
|
496
|
-
logger.info('Waiting for ACME challenge verification', this.backoffOpts);
|
|
497
|
-
return util.retry(verifyFn, this.backoffOpts);
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
/**
|
|
502
|
-
* Notify CA that challenge has been completed
|
|
503
|
-
*
|
|
504
|
-
* https://tools.ietf.org/html/rfc8555#section-7.5.1
|
|
505
|
-
*
|
|
506
|
-
* @param {object} challenge Challenge object returned by API
|
|
507
|
-
* @returns {Promise<object>} Challenge
|
|
508
|
-
*
|
|
509
|
-
* @example Notify CA that challenge has been completed
|
|
510
|
-
* ```js
|
|
511
|
-
* const challenge = { ... }; // Satisfied challenge
|
|
512
|
-
* const result = await client.completeChallenge(challenge);
|
|
513
|
-
* ```
|
|
514
|
-
*/
|
|
515
|
-
|
|
516
|
-
async completeChallenge(challenge) {
|
|
517
|
-
const resp = await this.api.completeChallenge(challenge.url, {});
|
|
518
|
-
return resp.data;
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
/**
|
|
523
|
-
* Wait for ACME provider to verify status on a order, authorization or challenge
|
|
524
|
-
*
|
|
525
|
-
* https://tools.ietf.org/html/rfc8555#section-7.5.1
|
|
526
|
-
*
|
|
527
|
-
* @param {object} item An order, authorization or challenge object
|
|
528
|
-
* @returns {Promise<object>} Valid order, authorization or challenge
|
|
529
|
-
*
|
|
530
|
-
* @example Wait for valid challenge status
|
|
531
|
-
* ```js
|
|
532
|
-
* const challenge = { ... };
|
|
533
|
-
* await client.waitForValidStatus(challenge);
|
|
534
|
-
* ```
|
|
535
|
-
*
|
|
536
|
-
* @example Wait for valid authoriation status
|
|
537
|
-
* ```js
|
|
538
|
-
* const authz = { ... };
|
|
539
|
-
* await client.waitForValidStatus(authz);
|
|
540
|
-
* ```
|
|
541
|
-
*
|
|
542
|
-
* @example Wait for valid order status
|
|
543
|
-
* ```js
|
|
544
|
-
* const order = { ... };
|
|
545
|
-
* await client.waitForValidStatus(order);
|
|
546
|
-
* ```
|
|
547
|
-
*/
|
|
548
|
-
|
|
549
|
-
async waitForValidStatus(item) {
|
|
550
|
-
if (!item.url) {
|
|
551
|
-
throw new Error('Unable to verify status of item, URL not found');
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
const verifyFn = async (abort) => {
|
|
555
|
-
const resp = await this.api.apiRequest(item.url, null, [200]);
|
|
556
|
-
|
|
557
|
-
/* Verify status */
|
|
558
|
-
logger.info(`Item has status: ${resp.data.status}`);
|
|
559
|
-
|
|
560
|
-
if (resp.data.status === 'invalid') {
|
|
561
|
-
abort();
|
|
562
|
-
throw new Error(util.formatResponseError(resp));
|
|
563
|
-
}
|
|
564
|
-
else if (resp.data.status === 'pending') {
|
|
565
|
-
throw new Error('Operation is pending');
|
|
566
|
-
}
|
|
567
|
-
else if (resp.data.status === 'valid') {
|
|
568
|
-
return resp.data;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
throw new Error(`Unexpected item status: ${resp.data.status}`);
|
|
572
|
-
};
|
|
573
|
-
|
|
574
|
-
logger.info(`Waiting for valid status from: ${item.url}`, this.backoffOpts);
|
|
575
|
-
return util.retry(verifyFn, this.backoffOpts);
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
/**
|
|
580
|
-
* Get certificate from ACME order
|
|
581
|
-
*
|
|
582
|
-
* https://tools.ietf.org/html/rfc8555#section-7.4.2
|
|
583
|
-
*
|
|
584
|
-
* @param {object} order Order object
|
|
585
|
-
* @param {string} [preferredChain] Indicate which certificate chain is preferred if a CA offers multiple, by exact issuer common name, default: `null`
|
|
586
|
-
* @returns {Promise<string>} Certificate
|
|
587
|
-
*
|
|
588
|
-
* @example Get certificate
|
|
589
|
-
* ```js
|
|
590
|
-
* const order = { ... }; // Previously created order
|
|
591
|
-
* const certificate = await client.getCertificate(order);
|
|
592
|
-
* ```
|
|
593
|
-
*
|
|
594
|
-
* @example Get certificate with preferred chain
|
|
595
|
-
* ```js
|
|
596
|
-
* const order = { ... }; // Previously created order
|
|
597
|
-
* const certificate = await client.getCertificate(order, 'DST Root CA X3');
|
|
598
|
-
* ```
|
|
599
|
-
*/
|
|
600
|
-
|
|
601
|
-
async getCertificate(order, preferredChain = null) {
|
|
602
|
-
if (order.status !== 'valid') {
|
|
603
|
-
order = await this.waitForValidStatus(order);
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
if (!order.certificate) {
|
|
607
|
-
throw new Error('Unable to download certificate, URL not found');
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
const resp = await this.api.apiRequest(order.certificate, null, [200]);
|
|
611
|
-
|
|
612
|
-
/* Handle alternate certificate chains */
|
|
613
|
-
if (preferredChain && resp.headers.link) {
|
|
614
|
-
const alternateLinks = util.parseLinkHeader(resp.headers.link);
|
|
615
|
-
const alternates = await Promise.map(alternateLinks, async (link) => this.api.apiRequest(link, null, [200]));
|
|
616
|
-
const certificates = [resp].concat(alternates).map((c) => c.data);
|
|
617
|
-
|
|
618
|
-
return util.findCertificateChainForIssuer(certificates, preferredChain);
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
/* Return default certificate chain */
|
|
622
|
-
return resp.data;
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
/**
|
|
627
|
-
* Revoke certificate
|
|
628
|
-
*
|
|
629
|
-
* https://tools.ietf.org/html/rfc8555#section-7.6
|
|
630
|
-
*
|
|
631
|
-
* @param {buffer|string} cert PEM encoded certificate
|
|
632
|
-
* @param {object} [data] Additional request data
|
|
633
|
-
* @returns {Promise}
|
|
634
|
-
*
|
|
635
|
-
* @example Revoke certificate
|
|
636
|
-
* ```js
|
|
637
|
-
* const certificate = { ... }; // Previously created certificate
|
|
638
|
-
* const result = await client.revokeCertificate(certificate);
|
|
639
|
-
* ```
|
|
640
|
-
*
|
|
641
|
-
* @example Revoke certificate with reason
|
|
642
|
-
* ```js
|
|
643
|
-
* const certificate = { ... }; // Previously created certificate
|
|
644
|
-
* const result = await client.revokeCertificate(certificate, {
|
|
645
|
-
* reason: 4
|
|
646
|
-
* });
|
|
647
|
-
* ```
|
|
648
|
-
*/
|
|
649
|
-
|
|
650
|
-
async revokeCertificate(cert, data = {}) {
|
|
651
|
-
const body = forge.getPemBody(cert);
|
|
652
|
-
data.certificate = util.b64escape(body);
|
|
653
|
-
|
|
654
|
-
const resp = await this.api.revokeCert(data);
|
|
655
|
-
return resp.data;
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
/**
|
|
660
|
-
* Auto mode
|
|
661
|
-
*
|
|
662
|
-
* @param {object} opts
|
|
663
|
-
* @param {buffer|string} opts.csr Certificate Signing Request
|
|
664
|
-
* @param {function} opts.challengeCreateFn Function returning Promise triggered before completing ACME challenge
|
|
665
|
-
* @param {function} opts.challengeRemoveFn Function returning Promise triggered after completing ACME challenge
|
|
666
|
-
* @param {string} [opts.email] Account email address
|
|
667
|
-
* @param {boolean} [opts.termsOfServiceAgreed] Agree to Terms of Service, default: `false`
|
|
668
|
-
* @param {boolean} [opts.skipChallengeVerification] Skip internal challenge verification before notifying ACME provider, default: `false`
|
|
669
|
-
* @param {string[]} [opts.challengePriority] Array defining challenge type priority, default: `['http-01', 'dns-01']`
|
|
670
|
-
* @param {string} [opts.preferredChain] Indicate which certificate chain is preferred if a CA offers multiple, by exact issuer common name, default: `null`
|
|
671
|
-
* @returns {Promise<string>} Certificate
|
|
672
|
-
*
|
|
673
|
-
* @example Order a certificate using auto mode
|
|
674
|
-
* ```js
|
|
675
|
-
* const [certificateKey, certificateRequest] = await acme.forge.createCsr({
|
|
676
|
-
* commonName: 'test.example.com'
|
|
677
|
-
* });
|
|
678
|
-
*
|
|
679
|
-
* const certificate = await client.auto({
|
|
680
|
-
* csr: certificateRequest,
|
|
681
|
-
* email: 'test@example.com',
|
|
682
|
-
* termsOfServiceAgreed: true,
|
|
683
|
-
* challengeCreateFn: async (authz, challenge, keyAuthorization) => {
|
|
684
|
-
* // Satisfy challenge here
|
|
685
|
-
* },
|
|
686
|
-
* challengeRemoveFn: async (authz, challenge, keyAuthorization) => {
|
|
687
|
-
* // Clean up challenge here
|
|
688
|
-
* }
|
|
689
|
-
* });
|
|
690
|
-
* ```
|
|
691
|
-
*
|
|
692
|
-
* @example Order a certificate using auto mode with preferred chain
|
|
693
|
-
* ```js
|
|
694
|
-
* const [certificateKey, certificateRequest] = await acme.forge.createCsr({
|
|
695
|
-
* commonName: 'test.example.com'
|
|
696
|
-
* });
|
|
697
|
-
*
|
|
698
|
-
* const certificate = await client.auto({
|
|
699
|
-
* csr: certificateRequest,
|
|
700
|
-
* email: 'test@example.com',
|
|
701
|
-
* termsOfServiceAgreed: true,
|
|
702
|
-
* preferredChain: 'DST Root CA X3',
|
|
703
|
-
* challengeCreateFn: async () => {},
|
|
704
|
-
* challengeRemoveFn: async () => {}
|
|
705
|
-
* });
|
|
706
|
-
* ```
|
|
707
|
-
*/
|
|
708
|
-
|
|
709
|
-
auto(opts) {
|
|
710
|
-
return auto(this, opts);
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
/* Export client */
|
|
716
|
-
module.exports = AcmeClient;
|
|
1
|
+
/**
|
|
2
|
+
* ACME client
|
|
3
|
+
*
|
|
4
|
+
* @namespace Client
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const crypto = require('crypto');
|
|
8
|
+
const logger = require('./util.log.js');
|
|
9
|
+
|
|
10
|
+
const debug = logger.info;
|
|
11
|
+
const Promise = require('bluebird');
|
|
12
|
+
const HttpClient = require('./http');
|
|
13
|
+
const AcmeApi = require('./api');
|
|
14
|
+
const verify = require('./verify');
|
|
15
|
+
const util = require('./util');
|
|
16
|
+
const auto = require('./auto');
|
|
17
|
+
const forge = require('./crypto/forge');
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Default options
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const defaultOpts = {
|
|
25
|
+
directoryUrl: undefined,
|
|
26
|
+
accountKey: undefined,
|
|
27
|
+
accountUrl: null,
|
|
28
|
+
backoffAttempts: 5,
|
|
29
|
+
backoffMin: 5000,
|
|
30
|
+
backoffMax: 30000
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* AcmeClient
|
|
36
|
+
*
|
|
37
|
+
* @class
|
|
38
|
+
* @param {object} opts
|
|
39
|
+
* @param {string} opts.directoryUrl ACME directory URL
|
|
40
|
+
* @param {buffer|string} opts.accountKey PEM encoded account private key
|
|
41
|
+
* @param {string} [opts.accountUrl] Account URL, default: `null`
|
|
42
|
+
* @param {number} [opts.backoffAttempts] Maximum number of backoff attempts, default: `5`
|
|
43
|
+
* @param {number} [opts.backoffMin] Minimum backoff attempt delay in milliseconds, default: `5000`
|
|
44
|
+
* @param {number} [opts.backoffMax] Maximum backoff attempt delay in milliseconds, default: `30000`
|
|
45
|
+
*
|
|
46
|
+
* @example Create ACME client instance
|
|
47
|
+
* ```js
|
|
48
|
+
* const client = new acme.Client({
|
|
49
|
+
* directoryUrl: acme.directory.letsencrypt.staging,
|
|
50
|
+
* accountKey: 'Private key goes here'
|
|
51
|
+
* });
|
|
52
|
+
* ```
|
|
53
|
+
*
|
|
54
|
+
* @example Create ACME client instance
|
|
55
|
+
* ```js
|
|
56
|
+
* const client = new acme.Client({
|
|
57
|
+
* directoryUrl: acme.directory.letsencrypt.staging,
|
|
58
|
+
* accountKey: 'Private key goes here',
|
|
59
|
+
* accountUrl: 'Optional account URL goes here',
|
|
60
|
+
* backoffAttempts: 5,
|
|
61
|
+
* backoffMin: 5000,
|
|
62
|
+
* backoffMax: 30000
|
|
63
|
+
* });
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
class AcmeClient {
|
|
68
|
+
constructor(opts) {
|
|
69
|
+
if (!Buffer.isBuffer(opts.accountKey)) {
|
|
70
|
+
opts.accountKey = Buffer.from(opts.accountKey);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
this.opts = Object.assign({}, defaultOpts, opts);
|
|
74
|
+
|
|
75
|
+
this.backoffOpts = {
|
|
76
|
+
attempts: this.opts.backoffAttempts,
|
|
77
|
+
min: this.opts.backoffMin,
|
|
78
|
+
max: this.opts.backoffMax
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
this.http = new HttpClient(this.opts.directoryUrl, this.opts.accountKey);
|
|
82
|
+
this.api = new AcmeApi(this.http, this.opts.accountUrl);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get Terms of Service URL if available
|
|
88
|
+
*
|
|
89
|
+
* @returns {Promise<string|null>} ToS URL
|
|
90
|
+
*
|
|
91
|
+
* @example Get Terms of Service URL
|
|
92
|
+
* ```js
|
|
93
|
+
* const termsOfService = client.getTermsOfServiceUrl();
|
|
94
|
+
*
|
|
95
|
+
* if (!termsOfService) {
|
|
96
|
+
* // CA did not provide Terms of Service
|
|
97
|
+
* }
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
|
|
101
|
+
getTermsOfServiceUrl() {
|
|
102
|
+
return this.api.getTermsOfServiceUrl();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Get current account URL
|
|
108
|
+
*
|
|
109
|
+
* @returns {string} Account URL
|
|
110
|
+
* @throws {Error} No account URL found
|
|
111
|
+
*
|
|
112
|
+
* @example Get current account URL
|
|
113
|
+
* ```js
|
|
114
|
+
* try {
|
|
115
|
+
* const accountUrl = client.getAccountUrl();
|
|
116
|
+
* }
|
|
117
|
+
* catch (e) {
|
|
118
|
+
* // No account URL exists, need to create account first
|
|
119
|
+
* }
|
|
120
|
+
* ```
|
|
121
|
+
*/
|
|
122
|
+
|
|
123
|
+
getAccountUrl() {
|
|
124
|
+
return this.api.getAccountUrl();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Create a new account
|
|
130
|
+
*
|
|
131
|
+
* https://tools.ietf.org/html/rfc8555#section-7.3
|
|
132
|
+
*
|
|
133
|
+
* @param {object} [data] Request data
|
|
134
|
+
* @returns {Promise<object>} Account
|
|
135
|
+
*
|
|
136
|
+
* @example Create a new account
|
|
137
|
+
* ```js
|
|
138
|
+
* const account = await client.createAccount({
|
|
139
|
+
* termsOfServiceAgreed: true
|
|
140
|
+
* });
|
|
141
|
+
* ```
|
|
142
|
+
*
|
|
143
|
+
* @example Create a new account with contact info
|
|
144
|
+
* ```js
|
|
145
|
+
* const account = await client.createAccount({
|
|
146
|
+
* termsOfServiceAgreed: true,
|
|
147
|
+
* contact: ['mailto:test@example.com']
|
|
148
|
+
* });
|
|
149
|
+
* ```
|
|
150
|
+
*/
|
|
151
|
+
|
|
152
|
+
async createAccount(data = {}) {
|
|
153
|
+
try {
|
|
154
|
+
this.getAccountUrl();
|
|
155
|
+
|
|
156
|
+
/* Account URL exists */
|
|
157
|
+
logger.info('Account URL exists, returning updateAccount()');
|
|
158
|
+
return this.updateAccount(data);
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
const resp = await this.api.createAccount(data);
|
|
162
|
+
// TODO 先注释,可加快速度
|
|
163
|
+
/* HTTP 200: Account exists */
|
|
164
|
+
// if (resp.status === 200) {
|
|
165
|
+
// logger.info('Account already exists (HTTP 200), returning updateAccount()');
|
|
166
|
+
// return this.updateAccount(data);
|
|
167
|
+
// }
|
|
168
|
+
|
|
169
|
+
return resp.data;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Update existing account
|
|
176
|
+
*
|
|
177
|
+
* https://tools.ietf.org/html/rfc8555#section-7.3.2
|
|
178
|
+
*
|
|
179
|
+
* @param {object} [data] Request data
|
|
180
|
+
* @returns {Promise<object>} Account
|
|
181
|
+
*
|
|
182
|
+
* @example Update existing account
|
|
183
|
+
* ```js
|
|
184
|
+
* const account = await client.updateAccount({
|
|
185
|
+
* contact: ['mailto:foo@example.com']
|
|
186
|
+
* });
|
|
187
|
+
* ```
|
|
188
|
+
*/
|
|
189
|
+
|
|
190
|
+
async updateAccount(data = {}) {
|
|
191
|
+
try {
|
|
192
|
+
this.api.getAccountUrl();
|
|
193
|
+
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
logger.info('No account URL found, returning createAccount()');
|
|
196
|
+
return this.createAccount(data);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/* Remove data only applicable to createAccount() */
|
|
200
|
+
if ('onlyReturnExisting' in data) {
|
|
201
|
+
delete data.onlyReturnExisting;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/* POST-as-GET */
|
|
205
|
+
if (Object.keys(data).length === 0) {
|
|
206
|
+
data = null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const resp = await this.api.updateAccount(data);
|
|
210
|
+
return resp.data;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Update account private key
|
|
216
|
+
*
|
|
217
|
+
* https://tools.ietf.org/html/rfc8555#section-7.3.5
|
|
218
|
+
*
|
|
219
|
+
* @param {buffer|string} newAccountKey New PEM encoded private key
|
|
220
|
+
* @param {object} [data] Additional request data
|
|
221
|
+
* @returns {Promise<object>} Account
|
|
222
|
+
*
|
|
223
|
+
* @example Update account private key
|
|
224
|
+
* ```js
|
|
225
|
+
* const newAccountKey = 'New private key goes here';
|
|
226
|
+
* const result = await client.updateAccountKey(newAccountKey);
|
|
227
|
+
* ```
|
|
228
|
+
*/
|
|
229
|
+
|
|
230
|
+
async updateAccountKey(newAccountKey, data = {}) {
|
|
231
|
+
if (!Buffer.isBuffer(newAccountKey)) {
|
|
232
|
+
newAccountKey = Buffer.from(newAccountKey);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const accountUrl = this.api.getAccountUrl();
|
|
236
|
+
|
|
237
|
+
/* Create new HTTP and API clients using new key */
|
|
238
|
+
const newHttpClient = new HttpClient(this.opts.directoryUrl, newAccountKey);
|
|
239
|
+
const newApiClient = new AcmeApi(newHttpClient, accountUrl);
|
|
240
|
+
|
|
241
|
+
/* Get new JWK */
|
|
242
|
+
data.account = accountUrl;
|
|
243
|
+
data.oldKey = await this.http.getJwk();
|
|
244
|
+
|
|
245
|
+
/* TODO: Backward-compatibility with draft-ietf-acme-12, remove this in a later release */
|
|
246
|
+
data.newKey = await newHttpClient.getJwk();
|
|
247
|
+
|
|
248
|
+
/* Get signed request body from new client */
|
|
249
|
+
const url = await newHttpClient.getResourceUrl('keyChange');
|
|
250
|
+
const body = await newHttpClient.createSignedBody(url, data);
|
|
251
|
+
|
|
252
|
+
/* Change key using old client */
|
|
253
|
+
const resp = await this.api.updateAccountKey(body);
|
|
254
|
+
|
|
255
|
+
/* Replace existing HTTP and API client */
|
|
256
|
+
this.http = newHttpClient;
|
|
257
|
+
this.api = newApiClient;
|
|
258
|
+
|
|
259
|
+
return resp.data;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Create a new order
|
|
265
|
+
*
|
|
266
|
+
* https://tools.ietf.org/html/rfc8555#section-7.4
|
|
267
|
+
*
|
|
268
|
+
* @param {object} data Request data
|
|
269
|
+
* @returns {Promise<object>} Order
|
|
270
|
+
*
|
|
271
|
+
* @example Create a new order
|
|
272
|
+
* ```js
|
|
273
|
+
* const order = await client.createOrder({
|
|
274
|
+
* identifiers: [
|
|
275
|
+
* { type: 'dns', value: 'example.com' },
|
|
276
|
+
* { type: 'dns', value: 'test.example.com' }
|
|
277
|
+
* ]
|
|
278
|
+
* });
|
|
279
|
+
* ```
|
|
280
|
+
*/
|
|
281
|
+
|
|
282
|
+
async createOrder(data) {
|
|
283
|
+
const resp = await this.api.createOrder(data);
|
|
284
|
+
|
|
285
|
+
if (!resp.headers.location) {
|
|
286
|
+
throw new Error('Creating a new order did not return an order link');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/* Add URL to response */
|
|
290
|
+
resp.data.url = resp.headers.location;
|
|
291
|
+
return resp.data;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Refresh order object from CA
|
|
297
|
+
*
|
|
298
|
+
* https://tools.ietf.org/html/rfc8555#section-7.4
|
|
299
|
+
*
|
|
300
|
+
* @param {object} order Order object
|
|
301
|
+
* @returns {Promise<object>} Order
|
|
302
|
+
*
|
|
303
|
+
* @example
|
|
304
|
+
* ```js
|
|
305
|
+
* const order = { ... }; // Previously created order object
|
|
306
|
+
* const result = await client.getOrder(order);
|
|
307
|
+
* ```
|
|
308
|
+
*/
|
|
309
|
+
|
|
310
|
+
async getOrder(order) {
|
|
311
|
+
if (!order.url) {
|
|
312
|
+
throw new Error('Unable to get order, URL not found');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const resp = await this.api.getOrder(order.url);
|
|
316
|
+
|
|
317
|
+
/* Add URL to response */
|
|
318
|
+
resp.data.url = order.url;
|
|
319
|
+
return resp.data;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Finalize order
|
|
324
|
+
*
|
|
325
|
+
* https://tools.ietf.org/html/rfc8555#section-7.4
|
|
326
|
+
*
|
|
327
|
+
* @param {object} order Order object
|
|
328
|
+
* @param {buffer|string} csr PEM encoded Certificate Signing Request
|
|
329
|
+
* @returns {Promise<object>} Order
|
|
330
|
+
*
|
|
331
|
+
* @example Finalize order
|
|
332
|
+
* ```js
|
|
333
|
+
* const order = { ... }; // Previously created order object
|
|
334
|
+
* const csr = { ... }; // Previously created Certificate Signing Request
|
|
335
|
+
* const result = await client.finalizeOrder(order, csr);
|
|
336
|
+
* ```
|
|
337
|
+
*/
|
|
338
|
+
|
|
339
|
+
async finalizeOrder(order, csr) {
|
|
340
|
+
if (!order.finalize) {
|
|
341
|
+
throw new Error('Unable to finalize order, URL not found');
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (!Buffer.isBuffer(csr)) {
|
|
345
|
+
csr = Buffer.from(csr);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const body = forge.getPemBody(csr);
|
|
349
|
+
const data = { csr: util.b64escape(body) };
|
|
350
|
+
|
|
351
|
+
const resp = await this.api.finalizeOrder(order.finalize, data);
|
|
352
|
+
|
|
353
|
+
/* Add URL to response */
|
|
354
|
+
resp.data.url = order.url;
|
|
355
|
+
return resp.data;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Get identifier authorizations from order
|
|
361
|
+
*
|
|
362
|
+
* https://tools.ietf.org/html/rfc8555#section-7.5
|
|
363
|
+
*
|
|
364
|
+
* @param {object} order Order
|
|
365
|
+
* @returns {Promise<object[]>} Authorizations
|
|
366
|
+
*
|
|
367
|
+
* @example Get identifier authorizations
|
|
368
|
+
* ```js
|
|
369
|
+
* const order = { ... }; // Previously created order object
|
|
370
|
+
* const authorizations = await client.getAuthorizations(order);
|
|
371
|
+
*
|
|
372
|
+
* authorizations.forEach((authz) => {
|
|
373
|
+
* const { challenges } = authz;
|
|
374
|
+
* });
|
|
375
|
+
* ```
|
|
376
|
+
*/
|
|
377
|
+
|
|
378
|
+
async getAuthorizations(order) {
|
|
379
|
+
return Promise.map((order.authorizations || []), async (url) => {
|
|
380
|
+
const resp = await this.api.getAuthorization(url);
|
|
381
|
+
|
|
382
|
+
/* Add URL to response */
|
|
383
|
+
resp.data.url = url;
|
|
384
|
+
return resp.data;
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Deactivate identifier authorization
|
|
391
|
+
*
|
|
392
|
+
* https://tools.ietf.org/html/rfc8555#section-7.5.2
|
|
393
|
+
*
|
|
394
|
+
* @param {object} authz Identifier authorization
|
|
395
|
+
* @returns {Promise<object>} Authorization
|
|
396
|
+
*
|
|
397
|
+
* @example Deactivate identifier authorization
|
|
398
|
+
* ```js
|
|
399
|
+
* const authz = { ... }; // Identifier authorization resolved from previously created order
|
|
400
|
+
* const result = await client.deactivateAuthorization(authz);
|
|
401
|
+
* ```
|
|
402
|
+
*/
|
|
403
|
+
|
|
404
|
+
async deactivateAuthorization(authz) {
|
|
405
|
+
if (!authz.url) {
|
|
406
|
+
throw new Error('Unable to deactivate identifier authorization, URL not found');
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const data = {
|
|
410
|
+
status: 'deactivated'
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const resp = await this.api.updateAuthorization(authz.url, data);
|
|
414
|
+
|
|
415
|
+
/* Add URL to response */
|
|
416
|
+
resp.data.url = authz.url;
|
|
417
|
+
return resp.data;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Get key authorization for ACME challenge
|
|
423
|
+
*
|
|
424
|
+
* https://tools.ietf.org/html/rfc8555#section-8.1
|
|
425
|
+
*
|
|
426
|
+
* @param {object} challenge Challenge object returned by API
|
|
427
|
+
* @returns {Promise<string>} Key authorization
|
|
428
|
+
*
|
|
429
|
+
* @example Get challenge key authorization
|
|
430
|
+
* ```js
|
|
431
|
+
* const challenge = { ... }; // Challenge from previously resolved identifier authorization
|
|
432
|
+
* const key = await client.getChallengeKeyAuthorization(challenge);
|
|
433
|
+
*
|
|
434
|
+
* // Write key somewhere to satisfy challenge
|
|
435
|
+
* ```
|
|
436
|
+
*/
|
|
437
|
+
|
|
438
|
+
async getChallengeKeyAuthorization(challenge) {
|
|
439
|
+
const jwk = await this.http.getJwk();
|
|
440
|
+
const keysum = crypto.createHash('sha256').update(JSON.stringify(jwk));
|
|
441
|
+
const thumbprint = util.b64escape(keysum.digest('base64'));
|
|
442
|
+
const result = `${challenge.token}.${thumbprint}`;
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* https://tools.ietf.org/html/rfc8555#section-8.3
|
|
446
|
+
*/
|
|
447
|
+
|
|
448
|
+
if (challenge.type === 'http-01') {
|
|
449
|
+
return result;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* https://tools.ietf.org/html/rfc8555#section-8.4
|
|
454
|
+
* https://tools.ietf.org/html/draft-ietf-acme-tls-alpn-01
|
|
455
|
+
*/
|
|
456
|
+
|
|
457
|
+
if ((challenge.type === 'dns-01') || (challenge.type === 'tls-alpn-01')) {
|
|
458
|
+
const shasum = crypto.createHash('sha256').update(result);
|
|
459
|
+
return util.b64escape(shasum.digest('base64'));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
throw new Error(`Unable to produce key authorization, unknown challenge type: ${challenge.type}`);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Verify that ACME challenge is satisfied
|
|
468
|
+
*
|
|
469
|
+
* @param {object} authz Identifier authorization
|
|
470
|
+
* @param {object} challenge Authorization challenge
|
|
471
|
+
* @returns {Promise}
|
|
472
|
+
*
|
|
473
|
+
* @example Verify satisfied ACME challenge
|
|
474
|
+
* ```js
|
|
475
|
+
* const authz = { ... }; // Identifier authorization
|
|
476
|
+
* const challenge = { ... }; // Satisfied challenge
|
|
477
|
+
* await client.verifyChallenge(authz, challenge);
|
|
478
|
+
* ```
|
|
479
|
+
*/
|
|
480
|
+
|
|
481
|
+
async verifyChallenge(authz, challenge) {
|
|
482
|
+
if (!authz.url || !challenge.url) {
|
|
483
|
+
throw new Error('Unable to verify ACME challenge, URL not found');
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (typeof verify[challenge.type] === 'undefined') {
|
|
487
|
+
throw new Error(`Unable to verify ACME challenge, unknown type: ${challenge.type}`);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const keyAuthorization = await this.getChallengeKeyAuthorization(challenge);
|
|
491
|
+
|
|
492
|
+
const verifyFn = async () => {
|
|
493
|
+
await verify[challenge.type](authz, challenge, keyAuthorization);
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
logger.info('Waiting for ACME challenge verification', this.backoffOpts);
|
|
497
|
+
return util.retry(verifyFn, this.backoffOpts);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Notify CA that challenge has been completed
|
|
503
|
+
*
|
|
504
|
+
* https://tools.ietf.org/html/rfc8555#section-7.5.1
|
|
505
|
+
*
|
|
506
|
+
* @param {object} challenge Challenge object returned by API
|
|
507
|
+
* @returns {Promise<object>} Challenge
|
|
508
|
+
*
|
|
509
|
+
* @example Notify CA that challenge has been completed
|
|
510
|
+
* ```js
|
|
511
|
+
* const challenge = { ... }; // Satisfied challenge
|
|
512
|
+
* const result = await client.completeChallenge(challenge);
|
|
513
|
+
* ```
|
|
514
|
+
*/
|
|
515
|
+
|
|
516
|
+
async completeChallenge(challenge) {
|
|
517
|
+
const resp = await this.api.completeChallenge(challenge.url, {});
|
|
518
|
+
return resp.data;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Wait for ACME provider to verify status on a order, authorization or challenge
|
|
524
|
+
*
|
|
525
|
+
* https://tools.ietf.org/html/rfc8555#section-7.5.1
|
|
526
|
+
*
|
|
527
|
+
* @param {object} item An order, authorization or challenge object
|
|
528
|
+
* @returns {Promise<object>} Valid order, authorization or challenge
|
|
529
|
+
*
|
|
530
|
+
* @example Wait for valid challenge status
|
|
531
|
+
* ```js
|
|
532
|
+
* const challenge = { ... };
|
|
533
|
+
* await client.waitForValidStatus(challenge);
|
|
534
|
+
* ```
|
|
535
|
+
*
|
|
536
|
+
* @example Wait for valid authoriation status
|
|
537
|
+
* ```js
|
|
538
|
+
* const authz = { ... };
|
|
539
|
+
* await client.waitForValidStatus(authz);
|
|
540
|
+
* ```
|
|
541
|
+
*
|
|
542
|
+
* @example Wait for valid order status
|
|
543
|
+
* ```js
|
|
544
|
+
* const order = { ... };
|
|
545
|
+
* await client.waitForValidStatus(order);
|
|
546
|
+
* ```
|
|
547
|
+
*/
|
|
548
|
+
|
|
549
|
+
async waitForValidStatus(item) {
|
|
550
|
+
if (!item.url) {
|
|
551
|
+
throw new Error('Unable to verify status of item, URL not found');
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const verifyFn = async (abort) => {
|
|
555
|
+
const resp = await this.api.apiRequest(item.url, null, [200]);
|
|
556
|
+
|
|
557
|
+
/* Verify status */
|
|
558
|
+
logger.info(`Item has status: ${resp.data.status}`);
|
|
559
|
+
|
|
560
|
+
if (resp.data.status === 'invalid') {
|
|
561
|
+
abort();
|
|
562
|
+
throw new Error(`Operation is invalid:${util.formatResponseError(resp)}`);
|
|
563
|
+
}
|
|
564
|
+
else if (resp.data.status === 'pending') {
|
|
565
|
+
throw new Error('Operation is pending');
|
|
566
|
+
}
|
|
567
|
+
else if (resp.data.status === 'valid') {
|
|
568
|
+
return resp.data;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
throw new Error(`Unexpected item status: ${resp.data.status}`);
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
logger.info(`Waiting for valid status from: ${item.url}`, this.backoffOpts);
|
|
575
|
+
return util.retry(verifyFn, this.backoffOpts);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Get certificate from ACME order
|
|
581
|
+
*
|
|
582
|
+
* https://tools.ietf.org/html/rfc8555#section-7.4.2
|
|
583
|
+
*
|
|
584
|
+
* @param {object} order Order object
|
|
585
|
+
* @param {string} [preferredChain] Indicate which certificate chain is preferred if a CA offers multiple, by exact issuer common name, default: `null`
|
|
586
|
+
* @returns {Promise<string>} Certificate
|
|
587
|
+
*
|
|
588
|
+
* @example Get certificate
|
|
589
|
+
* ```js
|
|
590
|
+
* const order = { ... }; // Previously created order
|
|
591
|
+
* const certificate = await client.getCertificate(order);
|
|
592
|
+
* ```
|
|
593
|
+
*
|
|
594
|
+
* @example Get certificate with preferred chain
|
|
595
|
+
* ```js
|
|
596
|
+
* const order = { ... }; // Previously created order
|
|
597
|
+
* const certificate = await client.getCertificate(order, 'DST Root CA X3');
|
|
598
|
+
* ```
|
|
599
|
+
*/
|
|
600
|
+
|
|
601
|
+
async getCertificate(order, preferredChain = null) {
|
|
602
|
+
if (order.status !== 'valid') {
|
|
603
|
+
order = await this.waitForValidStatus(order);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (!order.certificate) {
|
|
607
|
+
throw new Error('Unable to download certificate, URL not found');
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const resp = await this.api.apiRequest(order.certificate, null, [200]);
|
|
611
|
+
|
|
612
|
+
/* Handle alternate certificate chains */
|
|
613
|
+
if (preferredChain && resp.headers.link) {
|
|
614
|
+
const alternateLinks = util.parseLinkHeader(resp.headers.link);
|
|
615
|
+
const alternates = await Promise.map(alternateLinks, async (link) => this.api.apiRequest(link, null, [200]));
|
|
616
|
+
const certificates = [resp].concat(alternates).map((c) => c.data);
|
|
617
|
+
|
|
618
|
+
return util.findCertificateChainForIssuer(certificates, preferredChain);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/* Return default certificate chain */
|
|
622
|
+
return resp.data;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Revoke certificate
|
|
628
|
+
*
|
|
629
|
+
* https://tools.ietf.org/html/rfc8555#section-7.6
|
|
630
|
+
*
|
|
631
|
+
* @param {buffer|string} cert PEM encoded certificate
|
|
632
|
+
* @param {object} [data] Additional request data
|
|
633
|
+
* @returns {Promise}
|
|
634
|
+
*
|
|
635
|
+
* @example Revoke certificate
|
|
636
|
+
* ```js
|
|
637
|
+
* const certificate = { ... }; // Previously created certificate
|
|
638
|
+
* const result = await client.revokeCertificate(certificate);
|
|
639
|
+
* ```
|
|
640
|
+
*
|
|
641
|
+
* @example Revoke certificate with reason
|
|
642
|
+
* ```js
|
|
643
|
+
* const certificate = { ... }; // Previously created certificate
|
|
644
|
+
* const result = await client.revokeCertificate(certificate, {
|
|
645
|
+
* reason: 4
|
|
646
|
+
* });
|
|
647
|
+
* ```
|
|
648
|
+
*/
|
|
649
|
+
|
|
650
|
+
async revokeCertificate(cert, data = {}) {
|
|
651
|
+
const body = forge.getPemBody(cert);
|
|
652
|
+
data.certificate = util.b64escape(body);
|
|
653
|
+
|
|
654
|
+
const resp = await this.api.revokeCert(data);
|
|
655
|
+
return resp.data;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Auto mode
|
|
661
|
+
*
|
|
662
|
+
* @param {object} opts
|
|
663
|
+
* @param {buffer|string} opts.csr Certificate Signing Request
|
|
664
|
+
* @param {function} opts.challengeCreateFn Function returning Promise triggered before completing ACME challenge
|
|
665
|
+
* @param {function} opts.challengeRemoveFn Function returning Promise triggered after completing ACME challenge
|
|
666
|
+
* @param {string} [opts.email] Account email address
|
|
667
|
+
* @param {boolean} [opts.termsOfServiceAgreed] Agree to Terms of Service, default: `false`
|
|
668
|
+
* @param {boolean} [opts.skipChallengeVerification] Skip internal challenge verification before notifying ACME provider, default: `false`
|
|
669
|
+
* @param {string[]} [opts.challengePriority] Array defining challenge type priority, default: `['http-01', 'dns-01']`
|
|
670
|
+
* @param {string} [opts.preferredChain] Indicate which certificate chain is preferred if a CA offers multiple, by exact issuer common name, default: `null`
|
|
671
|
+
* @returns {Promise<string>} Certificate
|
|
672
|
+
*
|
|
673
|
+
* @example Order a certificate using auto mode
|
|
674
|
+
* ```js
|
|
675
|
+
* const [certificateKey, certificateRequest] = await acme.forge.createCsr({
|
|
676
|
+
* commonName: 'test.example.com'
|
|
677
|
+
* });
|
|
678
|
+
*
|
|
679
|
+
* const certificate = await client.auto({
|
|
680
|
+
* csr: certificateRequest,
|
|
681
|
+
* email: 'test@example.com',
|
|
682
|
+
* termsOfServiceAgreed: true,
|
|
683
|
+
* challengeCreateFn: async (authz, challenge, keyAuthorization) => {
|
|
684
|
+
* // Satisfy challenge here
|
|
685
|
+
* },
|
|
686
|
+
* challengeRemoveFn: async (authz, challenge, keyAuthorization) => {
|
|
687
|
+
* // Clean up challenge here
|
|
688
|
+
* }
|
|
689
|
+
* });
|
|
690
|
+
* ```
|
|
691
|
+
*
|
|
692
|
+
* @example Order a certificate using auto mode with preferred chain
|
|
693
|
+
* ```js
|
|
694
|
+
* const [certificateKey, certificateRequest] = await acme.forge.createCsr({
|
|
695
|
+
* commonName: 'test.example.com'
|
|
696
|
+
* });
|
|
697
|
+
*
|
|
698
|
+
* const certificate = await client.auto({
|
|
699
|
+
* csr: certificateRequest,
|
|
700
|
+
* email: 'test@example.com',
|
|
701
|
+
* termsOfServiceAgreed: true,
|
|
702
|
+
* preferredChain: 'DST Root CA X3',
|
|
703
|
+
* challengeCreateFn: async () => {},
|
|
704
|
+
* challengeRemoveFn: async () => {}
|
|
705
|
+
* });
|
|
706
|
+
* ```
|
|
707
|
+
*/
|
|
708
|
+
|
|
709
|
+
auto(opts) {
|
|
710
|
+
return auto(this, opts);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
/* Export client */
|
|
716
|
+
module.exports = AcmeClient;
|