@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/src/http.js CHANGED
@@ -2,13 +2,10 @@
2
2
  * ACME HTTP client
3
3
  */
4
4
 
5
- const crypto = require('crypto');
6
- const logger = require('./util.log.js');
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
- logger.info(`HTTP request: ${method} ${url}`);
56
+ log(`HTTP request: ${method} ${url}`);
56
57
  const resp = await axios.request(opts);
57
58
 
58
- logger.info(`RESP ${resp.status} ${method} ${url}`);
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 {Promise<object>} {e, kty, n}
92
+ * @returns {object} JSON Web Key
83
93
  */
84
94
 
85
- async getJwk() {
86
- if (this.jwk) {
87
- return this.jwk;
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(`Could not resolve URL for API resource: "${resource}"`);
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
- * Create signed HTTP request body
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 {string} [nonce] Request nonce
165
- * @param {string} [kid] Request KID
166
- * @returns {Promise<object>} Signed HTTP request body
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
- async createSignedBody(url, payload = null, nonce = null, kid = null) {
170
- /* JWS header */
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
- logger.info(`Using nonce: ${nonce}`);
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 = await this.getJwk();
186
+ header.jwk = this.getJwk();
187
187
  }
188
188
 
189
- /* Request payload */
190
- const result = {
191
- payload: payload ? util.b64encode(JSON.stringify(payload)) : '',
192
- protected: util.b64encode(JSON.stringify(header))
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 = crypto.createSign('RSA-SHA256').update(`${result.protected}.${result.payload}`, 'utf8');
197
- result.signature = util.b64escape(signer.sign(this.accountKey, 'base64'));
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 {string} [kid] Request KID
211
- * @param {string} [nonce] Request anti-replay nonce
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 = await this.createSignedBody(url, payload, nonce, kid);
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
- const newNonce = resp.headers['replay-nonce'] || null;
304
+ nonce = resp.headers['replay-nonce'] || null;
228
305
  attempts += 1;
229
306
 
230
- logger.info(`Caught invalid nonce error, retrying (${attempts}/${this.maxBadNonceRetries}) signed request to: ${url}`);
231
- return this.signedRequest(url, payload, kid, newNonce, attempts);
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 Promise = require('bluebird');
6
- const Backoff = require('backo2');
7
- const logger = require('./util.log.js');
5
+ const dns = require('dns').promises;
6
+ const { readCertificateInfo, splitPemChain } = require('./crypto');
7
+ const { log } = require('./logger');
8
8
 
9
- const debug = logger.info;
10
- const forge = require('./crypto/forge');
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
- logger.info(`Promise rejected attempt #${backoff.attempts}, retrying in ${duration}ms: ${e.message}`);
65
+ log(`Promise rejected attempt #${backoff.attempts}, retrying in ${duration}ms: ${e.message}`);
37
66
 
38
- await Promise.delay(duration);
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 can not be located, the first certificate will be returned
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 {Promise<string>} PEM encoded certificate chain
117
+ * @returns {string} PEM encoded certificate chain
115
118
  */
116
119
 
117
- async function findCertificateChainForIssuer(chains, issuer) {
118
- try {
119
- return await Promise.any(chains.map(async (chain) => {
120
- /* Look up all issuers */
121
- const certs = forge.splitPemChain(chain);
122
- const infoCollection = await Promise.map(certs, forge.readCertificateInfo);
123
- const issuerCollection = infoCollection.map((i) => i.issuer.commonName);
124
-
125
- /* Found match, return it */
126
- if (issuerCollection.includes(issuer)) {
127
- logger.info(`Found matching certificate for preferred issuer="${issuer}", issuers=${JSON.stringify(issuerCollection)}`);
128
- return chain;
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
- /* No match, throw error */
132
- logger.info(`Unable to match certificate for preferred issuer="${issuer}", issuers=${JSON.stringify(issuerCollection)}`);
133
- throw new Error('Certificate issuer mismatch');
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
- /* Export utils */
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
  };