@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/http.js
CHANGED
|
@@ -2,13 +2,10 @@
|
|
|
2
2
|
* ACME HTTP client
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
const debug = logger.info;
|
|
5
|
+
const { createHmac, createSign, constants: { RSA_PKCS1_PADDING } } = require('crypto');
|
|
6
|
+
const { getJwk } = require('./crypto');
|
|
7
|
+
const { log } = require('./logger');
|
|
9
8
|
const axios = require('./axios');
|
|
10
|
-
const util = require('./util');
|
|
11
|
-
const forge = require('./crypto/forge');
|
|
12
9
|
|
|
13
10
|
|
|
14
11
|
/**
|
|
@@ -17,12 +14,16 @@ const forge = require('./crypto/forge');
|
|
|
17
14
|
* @class
|
|
18
15
|
* @param {string} directoryUrl ACME directory URL
|
|
19
16
|
* @param {buffer} accountKey PEM encoded account private key
|
|
17
|
+
* @param {object} [opts.externalAccountBinding]
|
|
18
|
+
* @param {string} [opts.externalAccountBinding.kid] External account binding KID
|
|
19
|
+
* @param {string} [opts.externalAccountBinding.hmacKey] External account binding HMAC key
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
class HttpClient {
|
|
23
|
-
constructor(directoryUrl, accountKey) {
|
|
23
|
+
constructor(directoryUrl, accountKey, externalAccountBinding = {}) {
|
|
24
24
|
this.directoryUrl = directoryUrl;
|
|
25
25
|
this.accountKey = accountKey;
|
|
26
|
+
this.externalAccountBinding = externalAccountBinding;
|
|
26
27
|
|
|
27
28
|
this.maxBadNonceRetries = 5;
|
|
28
29
|
this.directory = null;
|
|
@@ -52,10 +53,10 @@ class HttpClient {
|
|
|
52
53
|
opts.headers['Content-Type'] = 'application/jose+json';
|
|
53
54
|
|
|
54
55
|
/* Request */
|
|
55
|
-
|
|
56
|
+
log(`HTTP request: ${method} ${url}`);
|
|
56
57
|
const resp = await axios.request(opts);
|
|
57
58
|
|
|
58
|
-
|
|
59
|
+
log(`RESP ${resp.status} ${method} ${url}`);
|
|
59
60
|
return resp;
|
|
60
61
|
}
|
|
61
62
|
|
|
@@ -71,6 +72,15 @@ class HttpClient {
|
|
|
71
72
|
async getDirectory() {
|
|
72
73
|
if (!this.directory) {
|
|
73
74
|
const resp = await this.request(this.directoryUrl, 'get');
|
|
75
|
+
|
|
76
|
+
if (resp.status >= 400) {
|
|
77
|
+
throw new Error(`Attempting to read ACME directory returned error ${resp.status}: ${this.directoryUrl}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!resp.data) {
|
|
81
|
+
throw new Error('Attempting to read ACME directory returned no data');
|
|
82
|
+
}
|
|
83
|
+
|
|
74
84
|
this.directory = resp.data;
|
|
75
85
|
}
|
|
76
86
|
}
|
|
@@ -79,23 +89,14 @@ class HttpClient {
|
|
|
79
89
|
/**
|
|
80
90
|
* Get JSON Web Key
|
|
81
91
|
*
|
|
82
|
-
* @returns {
|
|
92
|
+
* @returns {object} JSON Web Key
|
|
83
93
|
*/
|
|
84
94
|
|
|
85
|
-
|
|
86
|
-
if (this.jwk) {
|
|
87
|
-
|
|
95
|
+
getJwk() {
|
|
96
|
+
if (!this.jwk) {
|
|
97
|
+
this.jwk = getJwk(this.accountKey);
|
|
88
98
|
}
|
|
89
99
|
|
|
90
|
-
const exponent = await forge.getPublicExponent(this.accountKey);
|
|
91
|
-
const modulus = await forge.getModulus(this.accountKey);
|
|
92
|
-
|
|
93
|
-
this.jwk = {
|
|
94
|
-
e: util.b64encode(exponent),
|
|
95
|
-
kty: 'RSA',
|
|
96
|
-
n: util.b64encode(modulus)
|
|
97
|
-
};
|
|
98
|
-
|
|
99
100
|
return this.jwk;
|
|
100
101
|
}
|
|
101
102
|
|
|
@@ -131,7 +132,7 @@ class HttpClient {
|
|
|
131
132
|
await this.getDirectory();
|
|
132
133
|
|
|
133
134
|
if (!this.directory[resource]) {
|
|
134
|
-
throw new Error(`
|
|
135
|
+
throw new Error(`Unable to locate API resource URL in ACME directory: "${resource}"`);
|
|
135
136
|
}
|
|
136
137
|
|
|
137
138
|
return this.directory[resource];
|
|
@@ -157,24 +158,23 @@ class HttpClient {
|
|
|
157
158
|
|
|
158
159
|
|
|
159
160
|
/**
|
|
160
|
-
*
|
|
161
|
+
* Prepare HTTP request body for signature
|
|
161
162
|
*
|
|
163
|
+
* @param {string} alg JWS algorithm
|
|
162
164
|
* @param {string} url Request URL
|
|
163
|
-
* @param {object} payload Request payload
|
|
164
|
-
* @param {
|
|
165
|
-
* @param {string} [
|
|
166
|
-
* @
|
|
165
|
+
* @param {object} [payload] Request payload
|
|
166
|
+
* @param {object} [opts]
|
|
167
|
+
* @param {string} [opts.nonce] JWS anti-replay nonce
|
|
168
|
+
* @param {string} [opts.kid] JWS KID
|
|
169
|
+
* @returns {object} Signed HTTP request body
|
|
167
170
|
*/
|
|
168
171
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const header = {
|
|
172
|
-
url,
|
|
173
|
-
alg: 'RS256'
|
|
174
|
-
};
|
|
172
|
+
prepareSignedBody(alg, url, payload = null, { nonce = null, kid = null } = {}) {
|
|
173
|
+
const header = { alg, url };
|
|
175
174
|
|
|
175
|
+
/* Nonce */
|
|
176
176
|
if (nonce) {
|
|
177
|
-
|
|
177
|
+
log(`Using nonce: ${nonce}`);
|
|
178
178
|
header.nonce = nonce;
|
|
179
179
|
}
|
|
180
180
|
|
|
@@ -183,18 +183,82 @@ class HttpClient {
|
|
|
183
183
|
header.kid = kid;
|
|
184
184
|
}
|
|
185
185
|
else {
|
|
186
|
-
header.jwk =
|
|
186
|
+
header.jwk = this.getJwk();
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
-
/*
|
|
190
|
-
|
|
191
|
-
payload: payload ?
|
|
192
|
-
protected:
|
|
189
|
+
/* Body */
|
|
190
|
+
return {
|
|
191
|
+
payload: payload ? Buffer.from(JSON.stringify(payload)).toString('base64url') : '',
|
|
192
|
+
protected: Buffer.from(JSON.stringify(header)).toString('base64url')
|
|
193
193
|
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Create JWS HTTP request body using HMAC
|
|
199
|
+
*
|
|
200
|
+
* @param {string} hmacKey HMAC key
|
|
201
|
+
* @param {string} url Request URL
|
|
202
|
+
* @param {object} [payload] Request payload
|
|
203
|
+
* @param {object} [opts]
|
|
204
|
+
* @param {string} [opts.nonce] JWS anti-replay nonce
|
|
205
|
+
* @param {string} [opts.kid] JWS KID
|
|
206
|
+
* @returns {object} Signed HMAC request body
|
|
207
|
+
*/
|
|
208
|
+
|
|
209
|
+
createSignedHmacBody(hmacKey, url, payload = null, { nonce = null, kid = null } = {}) {
|
|
210
|
+
const result = this.prepareSignedBody('HS256', url, payload, { nonce, kid });
|
|
194
211
|
|
|
195
212
|
/* Signature */
|
|
196
|
-
const signer =
|
|
197
|
-
result.signature =
|
|
213
|
+
const signer = createHmac('SHA256', Buffer.from(hmacKey, 'base64')).update(`${result.protected}.${result.payload}`, 'utf8');
|
|
214
|
+
result.signature = signer.digest().toString('base64url');
|
|
215
|
+
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Create JWS HTTP request body using RSA or ECC
|
|
222
|
+
*
|
|
223
|
+
* https://datatracker.ietf.org/doc/html/rfc7515
|
|
224
|
+
*
|
|
225
|
+
* @param {string} url Request URL
|
|
226
|
+
* @param {object} [payload] Request payload
|
|
227
|
+
* @param {object} [opts]
|
|
228
|
+
* @param {string} [opts.nonce] JWS nonce
|
|
229
|
+
* @param {string} [opts.kid] JWS KID
|
|
230
|
+
* @returns {object} JWS request body
|
|
231
|
+
*/
|
|
232
|
+
|
|
233
|
+
createSignedBody(url, payload = null, { nonce = null, kid = null } = {}) {
|
|
234
|
+
const jwk = this.getJwk();
|
|
235
|
+
let headerAlg = 'RS256';
|
|
236
|
+
let signerAlg = 'SHA256';
|
|
237
|
+
|
|
238
|
+
/* https://datatracker.ietf.org/doc/html/rfc7518#section-3.1 */
|
|
239
|
+
if (jwk.crv && (jwk.kty === 'EC')) {
|
|
240
|
+
headerAlg = 'ES256';
|
|
241
|
+
|
|
242
|
+
if (jwk.crv === 'P-384') {
|
|
243
|
+
headerAlg = 'ES384';
|
|
244
|
+
signerAlg = 'SHA384';
|
|
245
|
+
}
|
|
246
|
+
else if (jwk.crv === 'P-521') {
|
|
247
|
+
headerAlg = 'ES512';
|
|
248
|
+
signerAlg = 'SHA512';
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/* Prepare body and signer */
|
|
253
|
+
const result = this.prepareSignedBody(headerAlg, url, payload, { nonce, kid });
|
|
254
|
+
const signer = createSign(signerAlg).update(`${result.protected}.${result.payload}`, 'utf8');
|
|
255
|
+
|
|
256
|
+
/* Signature - https://stackoverflow.com/questions/39554165 */
|
|
257
|
+
result.signature = signer.sign({
|
|
258
|
+
key: this.accountKey,
|
|
259
|
+
padding: RSA_PKCS1_PADDING,
|
|
260
|
+
dsaEncoding: 'ieee-p1363'
|
|
261
|
+
}, 'base64url');
|
|
198
262
|
|
|
199
263
|
return result;
|
|
200
264
|
}
|
|
@@ -207,28 +271,41 @@ class HttpClient {
|
|
|
207
271
|
*
|
|
208
272
|
* @param {string} url Request URL
|
|
209
273
|
* @param {object} payload Request payload
|
|
210
|
-
* @param {
|
|
211
|
-
* @param {string} [
|
|
274
|
+
* @param {object} [opts]
|
|
275
|
+
* @param {string} [opts.kid] JWS KID
|
|
276
|
+
* @param {string} [opts.nonce] JWS anti-replay nonce
|
|
277
|
+
* @param {boolean} [opts.includeExternalAccountBinding] Include EAB in request
|
|
212
278
|
* @param {number} [attempts] Request attempt counter
|
|
213
279
|
* @returns {Promise<object>} HTTP response
|
|
214
280
|
*/
|
|
215
281
|
|
|
216
|
-
async signedRequest(url, payload, kid = null, nonce = null, attempts = 0) {
|
|
282
|
+
async signedRequest(url, payload, { kid = null, nonce = null, includeExternalAccountBinding = false } = {}, attempts = 0) {
|
|
217
283
|
if (!nonce) {
|
|
218
284
|
nonce = await this.getNonce();
|
|
219
285
|
}
|
|
220
286
|
|
|
287
|
+
/* External account binding */
|
|
288
|
+
if (includeExternalAccountBinding && this.externalAccountBinding) {
|
|
289
|
+
if (this.externalAccountBinding.kid && this.externalAccountBinding.hmacKey) {
|
|
290
|
+
const jwk = this.getJwk();
|
|
291
|
+
const eabKid = this.externalAccountBinding.kid;
|
|
292
|
+
const eabHmacKey = this.externalAccountBinding.hmacKey;
|
|
293
|
+
|
|
294
|
+
payload.externalAccountBinding = this.createSignedHmacBody(eabHmacKey, url, jwk, { kid: eabKid });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
221
298
|
/* Sign body and send request */
|
|
222
|
-
const data =
|
|
299
|
+
const data = this.createSignedBody(url, payload, { nonce, kid });
|
|
223
300
|
const resp = await this.request(url, 'post', { data });
|
|
224
301
|
|
|
225
302
|
/* Retry on bad nonce - https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-6.4 */
|
|
226
303
|
if (resp.data && resp.data.type && (resp.status === 400) && (resp.data.type === 'urn:ietf:params:acme:error:badNonce') && (attempts < this.maxBadNonceRetries)) {
|
|
227
|
-
|
|
304
|
+
nonce = resp.headers['replay-nonce'] || null;
|
|
228
305
|
attempts += 1;
|
|
229
306
|
|
|
230
|
-
|
|
231
|
-
return this.signedRequest(url, payload, kid,
|
|
307
|
+
log(`Caught invalid nonce error, retrying (${attempts}/${this.maxBadNonceRetries}) signed request to: ${url}`);
|
|
308
|
+
return this.signedRequest(url, payload, { kid, nonce, includeExternalAccountBinding }, attempts);
|
|
232
309
|
}
|
|
233
310
|
|
|
234
311
|
/* Return response */
|
package/src/index.js
CHANGED
|
@@ -10,9 +10,16 @@ exports.Client = require('./client');
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
exports.directory = {
|
|
13
|
+
buypass: {
|
|
14
|
+
staging: 'https://api.test4.buypass.no/acme/directory',
|
|
15
|
+
production: 'https://api.buypass.com/acme/directory'
|
|
16
|
+
},
|
|
13
17
|
letsencrypt: {
|
|
14
18
|
staging: 'https://acme-staging-v02.api.letsencrypt.org/directory',
|
|
15
19
|
production: 'https://acme-v02.api.letsencrypt.org/directory'
|
|
20
|
+
},
|
|
21
|
+
zerossl: {
|
|
22
|
+
production: 'https://acme.zerossl.com/v2/DV90'
|
|
16
23
|
}
|
|
17
24
|
};
|
|
18
25
|
|
|
@@ -21,6 +28,7 @@ exports.directory = {
|
|
|
21
28
|
* Crypto
|
|
22
29
|
*/
|
|
23
30
|
|
|
31
|
+
exports.crypto = require('./crypto');
|
|
24
32
|
exports.forge = require('./crypto/forge');
|
|
25
33
|
|
|
26
34
|
|
|
@@ -29,3 +37,10 @@ exports.forge = require('./crypto/forge');
|
|
|
29
37
|
*/
|
|
30
38
|
|
|
31
39
|
exports.axios = require('./axios');
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Logger
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
exports.setLogger = require('./logger').setLogger;
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACME logger
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const debug = require('debug')('acme-client');
|
|
6
|
+
|
|
7
|
+
let logger = () => {};
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Set logger function
|
|
12
|
+
*
|
|
13
|
+
* @param {function} fn Logger function
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
exports.setLogger = (fn) => {
|
|
17
|
+
logger = fn;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Log message
|
|
23
|
+
*
|
|
24
|
+
* @param {string} Message
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
exports.log = (msg) => {
|
|
28
|
+
debug(msg);
|
|
29
|
+
logger(msg);
|
|
30
|
+
};
|
package/src/util.js
CHANGED
|
@@ -2,12 +2,42 @@
|
|
|
2
2
|
* Utility methods
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const
|
|
5
|
+
const dns = require('dns').promises;
|
|
6
|
+
const { readCertificateInfo, splitPemChain } = require('./crypto');
|
|
7
|
+
const { log } = require('./logger');
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Exponential backoff
|
|
12
|
+
*
|
|
13
|
+
* https://github.com/mokesmokes/backo
|
|
14
|
+
*
|
|
15
|
+
* @class
|
|
16
|
+
* @param {object} [opts]
|
|
17
|
+
* @param {number} [opts.min] Minimum backoff duration in ms
|
|
18
|
+
* @param {number} [opts.max] Maximum backoff duration in ms
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
class Backoff {
|
|
22
|
+
constructor({ min = 100, max = 10000 } = {}) {
|
|
23
|
+
this.min = min;
|
|
24
|
+
this.max = max;
|
|
25
|
+
this.attempts = 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get backoff duration
|
|
31
|
+
*
|
|
32
|
+
* @returns {number} Backoff duration in ms
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
duration() {
|
|
36
|
+
const ms = this.min * (2 ** this.attempts);
|
|
37
|
+
this.attempts += 1;
|
|
38
|
+
return Math.min(ms, this.max);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
11
41
|
|
|
12
42
|
|
|
13
43
|
/**
|
|
@@ -28,14 +58,13 @@ async function retryPromise(fn, attempts, backoff) {
|
|
|
28
58
|
}
|
|
29
59
|
catch (e) {
|
|
30
60
|
if (aborted || ((backoff.attempts + 1) >= attempts)) {
|
|
31
|
-
logger.error(e);
|
|
32
61
|
throw e;
|
|
33
62
|
}
|
|
34
63
|
|
|
35
64
|
const duration = backoff.duration();
|
|
36
|
-
|
|
65
|
+
log(`Promise rejected attempt #${backoff.attempts}, retrying in ${duration}ms: ${e.message}`);
|
|
37
66
|
|
|
38
|
-
await Promise
|
|
67
|
+
await new Promise((resolve) => { setTimeout(resolve, duration); });
|
|
39
68
|
return retryPromise(fn, attempts, backoff);
|
|
40
69
|
}
|
|
41
70
|
}
|
|
@@ -58,33 +87,6 @@ function retry(fn, { attempts = 5, min = 5000, max = 30000 } = {}) {
|
|
|
58
87
|
}
|
|
59
88
|
|
|
60
89
|
|
|
61
|
-
/**
|
|
62
|
-
* Escape base64 encoded string
|
|
63
|
-
*
|
|
64
|
-
* @param {string} str Base64 encoded string
|
|
65
|
-
* @returns {string} Escaped string
|
|
66
|
-
*/
|
|
67
|
-
|
|
68
|
-
function b64escape(str) {
|
|
69
|
-
return str.replace(/\+/g, '-')
|
|
70
|
-
.replace(/\//g, '_')
|
|
71
|
-
.replace(/=/g, '');
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Base64 encode and escape buffer or string
|
|
77
|
-
*
|
|
78
|
-
* @param {buffer|string} str Buffer or string to be encoded
|
|
79
|
-
* @returns {string} Escaped base64 encoded string
|
|
80
|
-
*/
|
|
81
|
-
|
|
82
|
-
function b64encode(str) {
|
|
83
|
-
const buf = Buffer.isBuffer(str) ? str : Buffer.from(str);
|
|
84
|
-
return b64escape(buf.toString('base64'));
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
|
|
88
90
|
/**
|
|
89
91
|
* Parse URLs from link header
|
|
90
92
|
*
|
|
@@ -106,38 +108,52 @@ function parseLinkHeader(header, rel = 'alternate') {
|
|
|
106
108
|
|
|
107
109
|
|
|
108
110
|
/**
|
|
109
|
-
* Find certificate chain with preferred issuer
|
|
110
|
-
* If issuer
|
|
111
|
+
* Find certificate chain with preferred issuer common name
|
|
112
|
+
* - If issuer is found in multiple chains, the closest to root wins
|
|
113
|
+
* - If issuer can not be located, the first chain will be returned
|
|
111
114
|
*
|
|
112
115
|
* @param {array} certificates Array of PEM encoded certificate chains
|
|
113
116
|
* @param {string} issuer Preferred certificate issuer
|
|
114
|
-
* @returns {
|
|
117
|
+
* @returns {string} PEM encoded certificate chain
|
|
115
118
|
*/
|
|
116
119
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
120
|
+
function findCertificateChainForIssuer(chains, issuer) {
|
|
121
|
+
log(`Attempting to find match for issuer="${issuer}" in ${chains.length} certificate chains`);
|
|
122
|
+
let bestMatch = null;
|
|
123
|
+
let bestDistance = null;
|
|
124
|
+
|
|
125
|
+
chains.forEach((chain) => {
|
|
126
|
+
/* Look up all issuers */
|
|
127
|
+
const certs = splitPemChain(chain);
|
|
128
|
+
const infoCollection = certs.map((c) => readCertificateInfo(c));
|
|
129
|
+
const issuerCollection = infoCollection.map((i) => i.issuer.commonName);
|
|
130
|
+
|
|
131
|
+
/* Found issuer match, get distance from root - lower is better */
|
|
132
|
+
if (issuerCollection.includes(issuer)) {
|
|
133
|
+
const distance = (issuerCollection.length - issuerCollection.indexOf(issuer));
|
|
134
|
+
log(`Found matching chain for preferred issuer="${issuer}" distance=${distance} issuers=${JSON.stringify(issuerCollection)}`);
|
|
135
|
+
|
|
136
|
+
/* Chain wins, use it */
|
|
137
|
+
if (!bestDistance || (distance < bestDistance)) {
|
|
138
|
+
log(`Issuer is closer to root than previous match, using it (${distance} < ${bestDistance || 'undefined'})`);
|
|
139
|
+
bestMatch = chain;
|
|
140
|
+
bestDistance = distance;
|
|
129
141
|
}
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
/* No match */
|
|
145
|
+
log(`Unable to match certificate for preferred issuer="${issuer}", issuers=${JSON.stringify(issuerCollection)}`);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
130
148
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}));
|
|
135
|
-
}
|
|
136
|
-
catch (e) {
|
|
137
|
-
/* No certificates matched, return default */
|
|
138
|
-
logger.info(`Found no match in ${chains.length} certificate chains for preferred issuer="${issuer}", returning default certificate chain`);
|
|
139
|
-
return chains[0];
|
|
149
|
+
/* Return found match */
|
|
150
|
+
if (bestMatch) {
|
|
151
|
+
return bestMatch;
|
|
140
152
|
}
|
|
153
|
+
|
|
154
|
+
/* No chains matched, return default */
|
|
155
|
+
log(`Found no match in ${chains.length} certificate chains for preferred issuer="${issuer}", returning default certificate chain`);
|
|
156
|
+
return chains[0];
|
|
141
157
|
}
|
|
142
158
|
|
|
143
159
|
|
|
@@ -162,12 +178,81 @@ function formatResponseError(resp) {
|
|
|
162
178
|
}
|
|
163
179
|
|
|
164
180
|
|
|
165
|
-
|
|
181
|
+
/**
|
|
182
|
+
* Resolve root domain name by looking for SOA record
|
|
183
|
+
*
|
|
184
|
+
* @param {string} recordName DNS record name
|
|
185
|
+
* @returns {Promise<string>} Root domain name
|
|
186
|
+
*/
|
|
187
|
+
|
|
188
|
+
async function resolveDomainBySoaRecord(recordName) {
|
|
189
|
+
try {
|
|
190
|
+
await dns.resolveSoa(recordName);
|
|
191
|
+
log(`Found SOA record, considering domain to be: ${recordName}`);
|
|
192
|
+
return recordName;
|
|
193
|
+
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
log(`Unable to locate SOA record for name: ${recordName}`);
|
|
196
|
+
const parentRecordName = recordName.split('.').slice(1).join('.');
|
|
197
|
+
|
|
198
|
+
if (!parentRecordName.includes('.')) {
|
|
199
|
+
throw new Error('Unable to resolve domain by SOA record');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return resolveDomainBySoaRecord(parentRecordName);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Get DNS resolver using domains authoritative NS records
|
|
209
|
+
*
|
|
210
|
+
* @param {string} recordName DNS record name
|
|
211
|
+
* @returns {Promise<dns.Resolver>} DNS resolver
|
|
212
|
+
*/
|
|
213
|
+
|
|
214
|
+
async function getAuthoritativeDnsResolver(recordName) {
|
|
215
|
+
log(`Locating authoritative NS records for name: ${recordName}`);
|
|
216
|
+
const resolver = new dns.Resolver();
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
/* Resolve root domain by SOA */
|
|
220
|
+
const domain = await resolveDomainBySoaRecord(recordName);
|
|
221
|
+
|
|
222
|
+
/* Resolve authoritative NS addresses */
|
|
223
|
+
log(`Looking up authoritative NS records for domain: ${domain}`);
|
|
224
|
+
const nsRecords = await dns.resolveNs(domain);
|
|
225
|
+
const nsAddrArray = await Promise.all(nsRecords.map(async (r) => dns.resolve4(r)));
|
|
226
|
+
const nsAddresses = [].concat(...nsAddrArray).filter((a) => a);
|
|
227
|
+
|
|
228
|
+
if (!nsAddresses.length) {
|
|
229
|
+
throw new Error(`Unable to locate any valid authoritative NS addresses for domain: ${domain}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* Authoritative NS success */
|
|
233
|
+
log(`Found ${nsAddresses.length} authoritative NS addresses for domain: ${domain}`);
|
|
234
|
+
resolver.setServers(nsAddresses);
|
|
235
|
+
}
|
|
236
|
+
catch (e) {
|
|
237
|
+
log(`Authoritative NS lookup error: ${e.message}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/* Return resolver */
|
|
241
|
+
const addresses = resolver.getServers();
|
|
242
|
+
log(`DNS resolver addresses: ${addresses.join(', ')}`);
|
|
243
|
+
|
|
244
|
+
return resolver;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Export utils
|
|
250
|
+
*/
|
|
251
|
+
|
|
166
252
|
module.exports = {
|
|
167
253
|
retry,
|
|
168
|
-
b64escape,
|
|
169
|
-
b64encode,
|
|
170
254
|
parseLinkHeader,
|
|
171
255
|
findCertificateChainForIssuer,
|
|
172
|
-
formatResponseError
|
|
256
|
+
formatResponseError,
|
|
257
|
+
getAuthoritativeDnsResolver
|
|
173
258
|
};
|