Browse Source

v1.5.0: perform full test challenge first

tags/v1.5.0
AJ ONeal 9 months ago
parent
commit
83137766bc
3 changed files with 256 additions and 233 deletions
  1. 2
    0
      README.md
  2. 253
    232
      node.js
  3. 1
    1
      package.json

+ 2
- 0
README.md View File

@@ -193,6 +193,8 @@ ACME.challengePrefixes['dns-01']              // '_acme-challenge'
193 193
 
194 194
 # Changelog
195 195
 
196
+* v1.5
197
+  * perform full test challenge first (even before nonce)
196 198
 * v1.3
197 199
   * Use node RSA keygen by default
198 200
   * No non-optional external deps!

+ 253
- 232
node.js View File

@@ -16,6 +16,9 @@ ACME.splitPemChain = function splitPemChain(str) {
16 16
   });
17 17
 };
18 18
 
19
+
20
+// http-01: GET https://example.org/.well-known/acme-challenge/{{token}} => {{keyAuth}}
21
+// dns-01: TXT _acme-challenge.example.org. => "{{urlSafeBase64(sha256(keyAuth))}}"
19 22
 ACME.challengePrefixes = {
20 23
   'http-01': '/.well-known/acme-challenge'
21 24
 , 'dns-01': '_acme-challenge'
@@ -255,6 +258,37 @@ ACME._wait = function wait(ms) {
255 258
     setTimeout(resolve, (ms || 1100));
256 259
   });
257 260
 };
261
+
262
+ACME._testChallenges = function (me, options) {
263
+  if (me.skipChallengeTest) {
264
+    return Promise.resolve();
265
+  }
266
+
267
+  return Promise.all(options.domains.map(function (identifierValue) {
268
+    // TODO we really only need one to pass, not all to pass
269
+    return Promise.all(options.challengeTypes.map(function (chType) {
270
+      var chToken = require('crypto').randomBytes(16).toString('hex');
271
+      var thumbprint = me.RSA.thumbprint(options.accountKeypair);
272
+      var keyAuthorization = chToken + '.' + thumbprint;
273
+      var auth = {
274
+        identifier: { type: "dns", value: identifierValue }
275
+      , hostname: identifierValue
276
+      , type: chType
277
+      , token: chToken
278
+      , thumbprint: thumbprint
279
+      , keyAuthorization: keyAuthorization
280
+      , dnsAuthorization: me.RSA.utils.toWebsafeBase64(
281
+          require('crypto').createHash('sha256').update(keyAuthorization).digest('base64')
282
+        )
283
+      };
284
+
285
+      return ACME._setChallenge(me, options, auth).then(function () {
286
+        return ACME.challengeTests[chType](me, auth);
287
+      });
288
+    }));
289
+  }));
290
+};
291
+
258 292
 // https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-7.5.1
259 293
 ACME._postChallenge = function (me, options, identifier, ch) {
260 294
   var RETRY_INTERVAL = me.retryInterval || 1000;
@@ -279,172 +313,157 @@ ACME._postChallenge = function (me, options, identifier, ch) {
279 313
     )
280 314
   };
281 315
 
282
-  return new Promise(function (resolve, reject) {
283
-    /*
284
-     POST /acme/authz/1234 HTTP/1.1
285
-     Host: example.com
286
-     Content-Type: application/jose+json
287
-
288
-     {
289
-       "protected": base64url({
290
-         "alg": "ES256",
291
-         "kid": "https://example.com/acme/acct/1",
292
-         "nonce": "xWCM9lGbIyCgue8di6ueWQ",
293
-         "url": "https://example.com/acme/authz/1234"
294
-       }),
295
-       "payload": base64url({
296
-         "status": "deactivated"
297
-       }),
298
-       "signature": "srX9Ji7Le9bjszhu...WTFdtujObzMtZcx4"
299
-     }
300
-     */
301
-    function deactivate() {
302
-      var jws = me.RSA.signJws(
303
-        options.accountKeypair
304
-      , undefined
305
-      , { nonce: me._nonce, alg: 'RS256', url: ch.url, kid: me._kid }
306
-      , Buffer.from(JSON.stringify({ "status": "deactivated" }))
307
-      );
308
-      me._nonce = null;
309
-      return me._request({
310
-        method: 'POST'
311
-      , url: ch.url
312
-      , headers: { 'Content-Type': 'application/jose+json' }
313
-      , json: jws
314
-      }).then(function (resp) {
315
-        if (me.debug) { console.debug('[acme-v2.js] deactivate:'); }
316
-        if (me.debug) { console.debug(resp.headers); }
317
-        if (me.debug) { console.debug(resp.body); }
318
-        if (me.debug) { console.debug(); }
319
-
320
-        me._nonce = resp.toJSON().headers['replay-nonce'];
321
-        if (me.debug) { console.debug('deactivate challenge: resp.body:'); }
322
-        if (me.debug) { console.debug(resp.body); }
323
-        return ACME._wait(DEAUTH_INTERVAL);
324
-      });
325
-    }
326
-
327
-    function pollStatus() {
328
-      if (count >= MAX_POLL) {
329
-        return Promise.reject(new Error("[acme-v2] stuck in bad pending/processing state"));
330
-      }
331
-
332
-      count += 1;
333
-
334
-      if (me.debug) { console.debug('\n[DEBUG] statusChallenge\n'); }
335
-      return me._request({ method: 'GET', url: ch.url, json: true }).then(function (resp) {
316
+  /*
317
+   POST /acme/authz/1234 HTTP/1.1
318
+   Host: example.com
319
+   Content-Type: application/jose+json
320
+
321
+   {
322
+     "protected": base64url({
323
+       "alg": "ES256",
324
+       "kid": "https://example.com/acme/acct/1",
325
+       "nonce": "xWCM9lGbIyCgue8di6ueWQ",
326
+       "url": "https://example.com/acme/authz/1234"
327
+     }),
328
+     "payload": base64url({
329
+       "status": "deactivated"
330
+     }),
331
+     "signature": "srX9Ji7Le9bjszhu...WTFdtujObzMtZcx4"
332
+   }
333
+   */
334
+  function deactivate() {
335
+    var jws = me.RSA.signJws(
336
+      options.accountKeypair
337
+    , undefined
338
+    , { nonce: me._nonce, alg: 'RS256', url: ch.url, kid: me._kid }
339
+    , Buffer.from(JSON.stringify({ "status": "deactivated" }))
340
+    );
341
+    me._nonce = null;
342
+    return me._request({
343
+      method: 'POST'
344
+    , url: ch.url
345
+    , headers: { 'Content-Type': 'application/jose+json' }
346
+    , json: jws
347
+    }).then(function (resp) {
348
+      if (me.debug) { console.debug('[acme-v2.js] deactivate:'); }
349
+      if (me.debug) { console.debug(resp.headers); }
350
+      if (me.debug) { console.debug(resp.body); }
351
+      if (me.debug) { console.debug(); }
336 352
 
337
-        if ('processing' === resp.body.status) {
338
-          if (me.debug) { console.debug('poll: again'); }
339
-          return ACME._wait(RETRY_INTERVAL).then(pollStatus);
340
-        }
353
+      me._nonce = resp.toJSON().headers['replay-nonce'];
354
+      if (me.debug) { console.debug('deactivate challenge: resp.body:'); }
355
+      if (me.debug) { console.debug(resp.body); }
356
+      return ACME._wait(DEAUTH_INTERVAL);
357
+    });
358
+  }
341 359
 
342
-        // This state should never occur
343
-        if ('pending' === resp.body.status) {
344
-          if (count >= MAX_PEND) {
345
-            return ACME._wait(RETRY_INTERVAL).then(deactivate).then(testChallenge);
346
-          }
347
-          if (me.debug) { console.debug('poll: again'); }
348
-          return ACME._wait(RETRY_INTERVAL).then(testChallenge);
349
-        }
360
+  function pollStatus() {
361
+    if (count >= MAX_POLL) {
362
+      return Promise.reject(new Error("[acme-v2] stuck in bad pending/processing state"));
363
+    }
350 364
 
351
-        if ('valid' === resp.body.status) {
352
-          if (me.debug) { console.debug('poll: valid'); }
365
+    count += 1;
353 366
 
354
-          try {
355
-            if (1 === options.removeChallenge.length) {
356
-              options.removeChallenge(auth).then(function () {}, function () {});
357
-            } else if (2 === options.removeChallenge.length) {
358
-              options.removeChallenge(auth, function (err) { return err; });
359
-            } else {
360
-              options.removeChallenge(identifier.value, ch.token, function () {});
361
-            }
362
-          } catch(e) {}
363
-          return resp.body;
364
-        }
367
+    if (me.debug) { console.debug('\n[DEBUG] statusChallenge\n'); }
368
+    return me._request({ method: 'GET', url: ch.url, json: true }).then(function (resp) {
369
+      if ('processing' === resp.body.status) {
370
+        if (me.debug) { console.debug('poll: again'); }
371
+        return ACME._wait(RETRY_INTERVAL).then(pollStatus);
372
+      }
365 373
 
366
-        if (!resp.body.status) {
367
-          console.error("[acme-v2] (E_STATE_EMPTY) empty challenge state:");
368
-        }
369
-        else if ('invalid' === resp.body.status) {
370
-          console.error("[acme-v2] (E_STATE_INVALID) challenge state: '" + resp.body.status + "'");
371
-        }
372
-        else {
373
-          console.error("[acme-v2] (E_STATE_UKN) challenge state: '" + resp.body.status + "'");
374
+      // This state should never occur
375
+      if ('pending' === resp.body.status) {
376
+        if (count >= MAX_PEND) {
377
+          return ACME._wait(RETRY_INTERVAL).then(deactivate).then(respondToChallenge);
374 378
         }
379
+        if (me.debug) { console.debug('poll: again'); }
380
+        return ACME._wait(RETRY_INTERVAL).then(respondToChallenge);
381
+      }
375 382
 
376
-        return Promise.reject(new Error("[acme-v2] [error] unacceptable challenge state '" + resp.body.status + "'"));
377
-      });
378
-    }
383
+      if ('valid' === resp.body.status) {
384
+        if (me.debug) { console.debug('poll: valid'); }
379 385
 
380
-    function respondToChallenge() {
381
-      var jws = me.RSA.signJws(
382
-        options.accountKeypair
383
-      , undefined
384
-      , { nonce: me._nonce, alg: 'RS256', url: ch.url, kid: me._kid }
385
-      , Buffer.from(JSON.stringify({ }))
386
-      );
387
-      me._nonce = null;
388
-      return me._request({
389
-        method: 'POST'
390
-      , url: ch.url
391
-      , headers: { 'Content-Type': 'application/jose+json' }
392
-      , json: jws
393
-      }).then(function (resp) {
394
-        if (me.debug) { console.debug('[acme-v2.js] challenge accepted!'); }
395
-        if (me.debug) { console.debug(resp.headers); }
396
-        if (me.debug) { console.debug(resp.body); }
397
-        if (me.debug) { console.debug(); }
386
+        try {
387
+          if (1 === options.removeChallenge.length) {
388
+            options.removeChallenge(auth).then(function () {}, function () {});
389
+          } else if (2 === options.removeChallenge.length) {
390
+            options.removeChallenge(auth, function (err) { return err; });
391
+          } else {
392
+            options.removeChallenge(identifier.value, ch.token, function () {});
393
+          }
394
+        } catch(e) {}
395
+        return resp.body;
396
+      }
398 397
 
399
-        me._nonce = resp.toJSON().headers['replay-nonce'];
400
-        if (me.debug) { console.debug('respond to challenge: resp.body:'); }
401
-        if (me.debug) { console.debug(resp.body); }
402
-        return ACME._wait(RETRY_INTERVAL).then(pollStatus);
403
-      });
404
-    }
398
+      if (!resp.body.status) {
399
+        console.error("[acme-v2] (E_STATE_EMPTY) empty challenge state:");
400
+      }
401
+      else if ('invalid' === resp.body.status) {
402
+        console.error("[acme-v2] (E_STATE_INVALID) challenge state: '" + resp.body.status + "'");
403
+      }
404
+      else {
405
+        console.error("[acme-v2] (E_STATE_UKN) challenge state: '" + resp.body.status + "'");
406
+      }
405 407
 
406
-    function testChallenge() {
407
-      // TODO put check dns / http checks here?
408
-      // http-01: GET https://example.org/.well-known/acme-challenge/{{token}} => {{keyAuth}}
409
-      // dns-01: TXT _acme-challenge.example.org. => "{{urlSafeBase64(sha256(keyAuth))}}"
408
+      return Promise.reject(new Error("[acme-v2] [error] unacceptable challenge state '" + resp.body.status + "'"));
409
+    });
410
+  }
410 411
 
411
-      if (me.debug) {console.debug('\n[DEBUG] postChallenge\n'); }
412
-      //if (me.debug) console.debug('\n[DEBUG] stop to fix things\n'); return;
412
+  function respondToChallenge() {
413
+    var jws = me.RSA.signJws(
414
+      options.accountKeypair
415
+    , undefined
416
+    , { nonce: me._nonce, alg: 'RS256', url: ch.url, kid: me._kid }
417
+    , Buffer.from(JSON.stringify({ }))
418
+    );
419
+    me._nonce = null;
420
+    return me._request({
421
+      method: 'POST'
422
+    , url: ch.url
423
+    , headers: { 'Content-Type': 'application/jose+json' }
424
+    , json: jws
425
+    }).then(function (resp) {
426
+      if (me.debug) { console.debug('[acme-v2.js] challenge accepted!'); }
427
+      if (me.debug) { console.debug(resp.headers); }
428
+      if (me.debug) { console.debug(resp.body); }
429
+      if (me.debug) { console.debug(); }
413 430
 
414
-      return ACME._wait(RETRY_INTERVAL).then(function () {
415
-        if (!me.skipChallengeTest) {
416
-          return ACME.challengeTests[ch.type](me, auth);
417
-        }
418
-      }).then(respondToChallenge);
419
-    }
431
+      me._nonce = resp.toJSON().headers['replay-nonce'];
432
+      if (me.debug) { console.debug('respond to challenge: resp.body:'); }
433
+      if (me.debug) { console.debug(resp.body); }
434
+      return ACME._wait(RETRY_INTERVAL).then(pollStatus);
435
+    });
436
+  }
420 437
 
438
+  return ACME._setChallenge(me, options, auth).then(respondToChallenge);
439
+};
440
+ACME._setChallenge = function (me, options, auth) {
441
+  return new Promise(function (resolve, reject) {
421 442
     try {
422 443
       if (1 === options.setChallenge.length) {
423
-        options.setChallenge(auth).then(testChallenge).then(resolve, reject);
444
+        options.setChallenge(auth).then(resolve).catch(reject);
424 445
       } else if (2 === options.setChallenge.length) {
425 446
         options.setChallenge(auth, function (err) {
426
-          if(err) {
427
-            reject(err);
428
-          } else {
429
-            testChallenge().then(resolve, reject);
430
-          }
447
+          if(err) { reject(err); } else { resolve(); }
431 448
         });
432 449
       } else {
433 450
         var challengeCb = function(err) {
434
-          if(err) {
435
-            reject(err);
436
-          } else {
437
-            testChallenge().then(resolve, reject);
438
-          }
451
+          if(err) { reject(err); } else { resolve(); }
439 452
         };
453
+        // for backwards compat adding extra keys without changing params length
440 454
         Object.keys(auth).forEach(function (key) {
441 455
           challengeCb[key] = auth[key];
442 456
         });
443
-        options.setChallenge(identifier.value, ch.token, keyAuthorization, challengeCb);
457
+        options.setChallenge(auth.identifier.value, auth.token, auth.keyAuthorization, challengeCb);
444 458
       }
445 459
     } catch(e) {
446 460
       reject(e);
447 461
     }
462
+  }).then(function () {
463
+    // TODO: Do we still need this delay? Or shall we leave it to plugins to account for themselves?
464
+    var DELAY = me.setChallengeWait || 500;
465
+    if (me.debug) { console.debug('\n[DEBUG] waitChallengeDelay %s\n', DELAY); }
466
+    return ACME._wait(DELAY);
448 467
   });
449 468
 };
450 469
 ACME._finalizeOrder = function (me, options, validatedDomains) {
@@ -548,104 +567,106 @@ ACME._getCertificate = function (me, options) {
548 567
     }
549 568
   }
550 569
 
551
-  if (me.debug) { console.debug('[acme-v2] certificates.create'); }
552
-  return ACME._getNonce(me).then(function () {
553
-    var body = {
554
-      identifiers: options.domains.map(function (hostname) {
555
-        return { type: "dns" , value: hostname };
556
-      })
557
-      //, "notBefore": "2016-01-01T00:00:00Z"
558
-      //, "notAfter": "2016-01-08T00:00:00Z"
559
-    };
560
-
561
-    var payload = JSON.stringify(body);
562
-    var jws = me.RSA.signJws(
563
-      options.accountKeypair
564
-    , undefined
565
-    , { nonce: me._nonce, alg: 'RS256', url: me._directoryUrls.newOrder, kid: me._kid }
566
-    , Buffer.from(payload)
567
-    );
570
+  return ACME._testChallenges(me, options).then(function () {
571
+    if (me.debug) { console.debug('[acme-v2] certificates.create'); }
572
+    return ACME._getNonce(me).then(function () {
573
+      var body = {
574
+        identifiers: options.domains.map(function (hostname) {
575
+          return { type: "dns" , value: hostname };
576
+        })
577
+        //, "notBefore": "2016-01-01T00:00:00Z"
578
+        //, "notAfter": "2016-01-08T00:00:00Z"
579
+      };
580
+
581
+      var payload = JSON.stringify(body);
582
+      var jws = me.RSA.signJws(
583
+        options.accountKeypair
584
+      , undefined
585
+      , { nonce: me._nonce, alg: 'RS256', url: me._directoryUrls.newOrder, kid: me._kid }
586
+      , Buffer.from(payload)
587
+      );
568 588
 
569
-    if (me.debug) { console.debug('\n[DEBUG] newOrder\n'); }
570
-    me._nonce = null;
571
-    return me._request({
572
-      method: 'POST'
573
-    , url: me._directoryUrls.newOrder
574
-    , headers: { 'Content-Type': 'application/jose+json' }
575
-    , json: jws
576
-    }).then(function (resp) {
577
-      me._nonce = resp.toJSON().headers['replay-nonce'];
578
-      var location = resp.toJSON().headers.location;
579
-      var auths;
580
-      if (me.debug) { console.debug(location); } // the account id url
581
-      if (me.debug) { console.debug(resp.toJSON()); }
582
-      me._authorizations = resp.body.authorizations;
583
-      me._order = location;
584
-      me._finalize = resp.body.finalize;
585
-      //if (me.debug) console.debug('[DEBUG] finalize:', me._finalize); return;
586
-
587
-      if (!me._authorizations) {
588
-        console.error("[acme-v2.js] authorizations were not fetched:");
589
-        console.error(resp.body);
590
-        return Promise.reject(new Error("authorizations were not fetched"));
591
-      }
592
-      if (me.debug) { console.debug("[acme-v2] POST newOrder has authorizations"); }
593
-
594
-      //return resp.body;
595
-      auths = me._authorizations.slice(0);
596
-
597
-      function next() {
598
-        var authUrl = auths.shift();
599
-        if (!authUrl) { return; }
600
-
601
-        return ACME._getChallenges(me, options, authUrl).then(function (results) {
602
-          // var domain = options.domains[i]; // results.identifier.value
603
-          var chType = options.challengeTypes.filter(function (chType) {
604
-            return results.challenges.some(function (ch) {
605
-              return ch.type === chType;
606
-            });
607
-          })[0];
608
-
609
-          var challenge = results.challenges.filter(function (ch) {
610
-            if (chType === ch.type) {
611
-              return ch;
589
+      if (me.debug) { console.debug('\n[DEBUG] newOrder\n'); }
590
+      me._nonce = null;
591
+      return me._request({
592
+        method: 'POST'
593
+      , url: me._directoryUrls.newOrder
594
+      , headers: { 'Content-Type': 'application/jose+json' }
595
+      , json: jws
596
+      }).then(function (resp) {
597
+        me._nonce = resp.toJSON().headers['replay-nonce'];
598
+        var location = resp.toJSON().headers.location;
599
+        var auths;
600
+        if (me.debug) { console.debug(location); } // the account id url
601
+        if (me.debug) { console.debug(resp.toJSON()); }
602
+        me._authorizations = resp.body.authorizations;
603
+        me._order = location;
604
+        me._finalize = resp.body.finalize;
605
+        //if (me.debug) console.debug('[DEBUG] finalize:', me._finalize); return;
606
+
607
+        if (!me._authorizations) {
608
+          console.error("[acme-v2.js] authorizations were not fetched:");
609
+          console.error(resp.body);
610
+          return Promise.reject(new Error("authorizations were not fetched"));
611
+        }
612
+        if (me.debug) { console.debug("[acme-v2] POST newOrder has authorizations"); }
613
+
614
+        //return resp.body;
615
+        auths = me._authorizations.slice(0);
616
+
617
+        function next() {
618
+          var authUrl = auths.shift();
619
+          if (!authUrl) { return; }
620
+
621
+          return ACME._getChallenges(me, options, authUrl).then(function (results) {
622
+            // var domain = options.domains[i]; // results.identifier.value
623
+            var chType = options.challengeTypes.filter(function (chType) {
624
+              return results.challenges.some(function (ch) {
625
+                return ch.type === chType;
626
+              });
627
+            })[0];
628
+
629
+            var challenge = results.challenges.filter(function (ch) {
630
+              if (chType === ch.type) {
631
+                return ch;
632
+              }
633
+            })[0];
634
+
635
+            if (!challenge) {
636
+              return Promise.reject(new Error("Server didn't offer any challenge we can handle."));
612 637
             }
613
-          })[0];
614 638
 
615
-          if (!challenge) {
616
-            return Promise.reject(new Error("Server didn't offer any challenge we can handle."));
617
-          }
618
-
619
-          return ACME._postChallenge(me, options, results.identifier, challenge);
620
-        }).then(function () {
621
-          return next();
622
-        });
623
-      }
624
-
625
-      return next().then(function () {
626
-        if (me.debug) { console.debug("[getCertificate] next.then"); }
627
-        var validatedDomains = body.identifiers.map(function (ident) {
628
-          return ident.value;
629
-        });
639
+            return ACME._postChallenge(me, options, results.identifier, challenge);
640
+          }).then(function () {
641
+            return next();
642
+          });
643
+        }
630 644
 
631
-        return ACME._finalizeOrder(me, options, validatedDomains);
632
-      }).then(function (order) {
633
-        if (me.debug) { console.debug('acme-v2: order was finalized'); }
634
-        return me._request({ method: 'GET', url: me._certificate, json: true }).then(function (resp) {
635
-          if (me.debug) { console.debug('acme-v2: csr submitted and cert received:'); }
636
-          // https://github.com/certbot/certbot/issues/5721
637
-          var certsarr = ACME.splitPemChain(ACME.formatPemChain((resp.body||'')));
638
-          //  cert, chain, fullchain, privkey, /*TODO, subject, altnames, issuedAt, expiresAt */
639
-          var certs = {
640
-            expires: order.expires
641
-          , identifiers: order.identifiers
642
-          //, authorizations: order.authorizations
643
-          , cert: certsarr.shift()
644
-          //, privkey: privkeyPem
645
-          , chain: certsarr.join('\n')
646
-          };
647
-          if (me.debug) { console.debug(certs); }
648
-          return certs;
645
+        return next().then(function () {
646
+          if (me.debug) { console.debug("[getCertificate] next.then"); }
647
+          var validatedDomains = body.identifiers.map(function (ident) {
648
+            return ident.value;
649
+          });
650
+
651
+          return ACME._finalizeOrder(me, options, validatedDomains);
652
+        }).then(function (order) {
653
+          if (me.debug) { console.debug('acme-v2: order was finalized'); }
654
+          return me._request({ method: 'GET', url: me._certificate, json: true }).then(function (resp) {
655
+            if (me.debug) { console.debug('acme-v2: csr submitted and cert received:'); }
656
+            // https://github.com/certbot/certbot/issues/5721
657
+            var certsarr = ACME.splitPemChain(ACME.formatPemChain((resp.body||'')));
658
+            //  cert, chain, fullchain, privkey, /*TODO, subject, altnames, issuedAt, expiresAt */
659
+            var certs = {
660
+              expires: order.expires
661
+            , identifiers: order.identifiers
662
+            //, authorizations: order.authorizations
663
+            , cert: certsarr.shift()
664
+            //, privkey: privkeyPem
665
+            , chain: certsarr.join('\n')
666
+            };
667
+            if (me.debug) { console.debug(certs); }
668
+            return certs;
669
+          });
649 670
         });
650 671
       });
651 672
     });

+ 1
- 1
package.json View File

@@ -1,6 +1,6 @@
1 1
 {
2 2
   "name": "acme-v2",
3
-  "version": "1.3.1",
3
+  "version": "1.5.0",
4 4
   "description": "Free SSL. A framework for building Let's Encrypt v2 clients, and other ACME v2 (draft 11) clients. Successor to le-acme-core.js",
5 5
   "homepage": "https://git.coolaj86.com/coolaj86/acme-v2.js",
6 6
   "main": "node.js",

Loading…
Cancel
Save