v1.8: transitional support for v2.0

This commit is contained in:
AJ ONeal 2019-06-14 01:32:54 -06:00
parent dfbee8aa79
commit e6497fe34b
8 changed files with 482 additions and 324 deletions

19
.gitignore vendored
View File

@ -1,32 +1,17 @@
.env
*.pem
letsencrypt.work
letsencrypt.logs
letsencrypt.config
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directory
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
node_modules

208
README.md
View File

@ -2,28 +2,77 @@
| [acme-v2-cli.js](https://git.coolaj86.com/coolaj86/acme-v2-cli.js)
| [greenlock.js](https://git.coolaj86.com/coolaj86/greenlock.js)
| [goldilocks.js](https://git.coolaj86.com/coolaj86/goldilocks.js)
|
| A [Root](https://therootcompany.com) Project
# [acme-v2.js](https://git.coolaj86.com/coolaj86/acme-v2.js) | a [Root](https://therootcompany.com) project
# [acme-v2.js](https://git.coolaj86.com/coolaj86/acme-v2.js)
A **Zero (External) Dependency**\* library for building
Let's Encrypt v2 (ACME draft 18) clients and getting Free SSL certificates.
A lightweight, **Low Dependency**\* framework for building
Let's Encrypt v2 (ACME draft 12) clients, successor to `le-acme-core.js`.
Built [by request](https://git.coolaj86.com/coolaj86/greenlock.js/issues/5#issuecomment-8).
The primary goal of this library is to make it easy to
get Accounts and Certificates through Let's Encrypt.
\* <small>although `node-forge` and `ursa` are included as `optionalDependencies`
for backwards compatibility with older versions of node, there are no other
dependencies except those that I wrote for this (and related) projects.</small>
# Features
- [x] Let's Encrypt&trade; v2 / ACME Draft 12
- [ ] (in-progress) Let's Encrypt&trade; v2.1 / ACME Draft 18
- [ ] (in-progress) StartTLS Everywhere&trade;
- [x] Works with any [generic ACME challenge handler](https://git.rootprojects.org/root/acme-challenge-test.js)
- [x] **http-01** for single or multiple domains per certificate
- [x] **dns-01** for wildcards, localhost, private networks, etc
- [x] VanillaJS
- [x] Zero External Dependencies
- [x] Safe, Efficient, Maintained
- [x] Works in Node v6+
- [ ] (v2) Works in Web Browsers (See [Demo](https://greenlock.domains))
\* <small>The only required dependencies were built by us, specifically for this and related libraries.
There are some, truly optional, backwards-compatibility dependencies for node v6.</small>
## Looking for Quick 'n' Easy&trade;?
If you're looking to _build a webserver_, try [greenlock.js](https://git.coolaj86.com/coolaj86/greenlock.js).
If you're looking for an _ACME-enabled webserver_, try [goldilocks.js](https://git.coolaj86.com/coolaj86/goldilocks.js).
If you want something that's more "batteries included" give
[greenlock.js](https://git.coolaj86.com/coolaj86/greenlock.js)
a try.
- [greenlock.js](https://git.coolaj86.com/coolaj86/greenlock.js)
- [goldilocks.js](https://git.coolaj86.com/coolaj86/goldilocks.js)
## v1.7+: Transitional v2 Support
By the end of June 2019 we expect to have completed the migration to Let's Encrypt v2.1 (ACME draft 18).
Although the draft 18 changes themselves don't requiring breaking the API,
we've been keeping backwards compatibility for a long time and the API has become messy.
We're taking this **mandatory ACME update** as an opportunity to **clean up** and **greatly simplify**
the code with a fresh new release.
As of **v1.7** we started adding **transitional support** for the **next major version**, v2.0 of acme-v2.js.
We've been really good about backwards compatibility for
## Recommended Example
Due to the upcoming changes we've removed the old documentation.
Instead we recommend that you take a look at the
[Digital Ocean DNS-01 Example](https://git.rootprojects.org/root/acme-v2.js/src/branch/master/examples/dns-01-digitalocean.js)
- [examples/dns-01-digitalocean.js](https://git.rootprojects.org/root/acme-v2.js/src/branch/master/examples/dns-01-digitalocean.js)
That's not exactly the new API, but it's close.
## Let's Encrypt v02 Directory URLs
```
# Production URL
https://acme-v02.api.letsencrypt.org/directory
```
```
# Staging URL
https://acme-staging-v02.api.letsencrypt.org/directory
```
<!--
## How to build ACME clients
As this is intended to build ACME clients, there is not a simple 2-line example
@ -63,136 +112,75 @@ examples/https-server.js
examples/http-server.js
```
## Let's Encrypt Directory URLs
-->
```
# Production URL
https://acme-v02.api.letsencrypt.org/directory
```
## API
```
# Staging URL
https://acme-staging-v02.api.letsencrypt.org/directory
```
## Two API versions, Two Implementations
This library (acme-v2.js) supports ACME [_draft 11_](https://tools.ietf.org/html/draft-ietf-acme-acme-11),
otherwise known as Let's Encrypt v2 (or v02).
- ACME draft 11
- Let's Encrypt v2
- Let's Encrypt v02
The predecessor (le-acme-core) supports Let's Encrypt v1 (or v01), which was a
[hodge-podge of various drafts](https://github.com/letsencrypt/boulder/blob/master/docs/acme-divergences.md)
of the ACME spec early on.
- ACME early draft
- Let's Encrypt v1
- Let's Encrypt v01
This library maintains compatibility with le-acme-core so that it can be used as a **drop-in replacement**
and requires **no changes to existing code**,
but also provides an updated API more congruent with draft 11.
## le-acme-core-compatible API (recommended)
Status: Stable, Locked, Bugfix-only
See Full Documentation at <https://git.coolaj86.com/coolaj86/le-acme-core.js>
```js
var RSA = require('rsa-compat').RSA;
var acme = require('acme-v2/compat.js').ACME.create({ RSA: RSA });
//
// Use exactly the same as le-acme-core
//
```
## Promise API (dev)
Status: Almost stable, but **not semver locked**
Status: Small, but breaking changes coming in v2
This API is a simple evolution of le-acme-core,
but tries to provide a better mapping to the new draft 11 APIs.
```js
// Create Instance (Dependency Injection)
var ACME = require('acme-v2').ACME.create({
RSA: require('rsa-compat').RSA
// other overrides
, request: require('request')
, promisify: require('util').promisify
// used for constructing user-agent
, os: require('os')
, process: require('process')
// used for overriding the default user-agent
, userAgent: 'My custom UA String'
, getUserAgentString: function (deps) { return 'My custom UA String'; }
userAgent: 'My custom UA String',
getUserAgentString: function(deps) {
return 'My custom UA String';
},
// don't try to validate challenges locally
, skipChallengeTest: false
skipChallengeTest: false,
skipDryRun: false,
// ask if the certificate can be issued up to 10 times before failing
, retryPoll: 8
retryPoll: 8,
// ask if the certificate has been validated up to 6 times before cancelling
, retryPending: 4
retryPending: 4,
// Wait 1000ms between retries
, retryInterval: 1000
retryInterval: 1000,
// Wait 10,000ms after deauthorizing a challenge before retrying
, deauthWait: 10 * 1000
deauthWait: 10 * 1000
});
// Discover Directory URLs
ACME.init(acmeDirectoryUrl) // returns Promise<acmeUrls={keyChange,meta,newAccount,newNonce,newOrder,revokeCert}>
ACME.init(acmeDirectoryUrl); // returns Promise<acmeUrls={keyChange,meta,newAccount,newNonce,newOrder,revokeCert}>
// Accounts
ACME.accounts.create(options) // returns Promise<regr> registration data
ACME.accounts.create(options); // returns Promise<regr> registration data
{ email: '<email>' // valid email (server checks MX records)
, accountKeypair: { // privateKeyPem or privateKeyJwt
options = {
email: '<email>', // valid email (server checks MX records)
accountKeypair: {
// privateKeyPem or privateKeyJwt
privateKeyPem: '<ASCII PEM>'
}
, agreeToTerms: fn (tosUrl) {} // returns Promise with tosUrl
}
},
agreeToTerms: function(tosUrl) {} // should Promise the same `tosUrl` back
};
// Registration
ACME.certificates.create(options) // returns Promise<pems={ privkey (key), cert, chain (ca) }>
ACME.certificates.create(options); // returns Promise<pems={ privkey (key), cert, chain (ca) }>
{ newAuthzUrl: '<url>' // specify acmeUrls.newAuthz
, newCertUrl: '<url>' // specify acmeUrls.newCert
, domainKeypair: {
options = {
domainKeypair: {
privateKeyPem: '<ASCII PEM>'
}
, accountKeypair: {
},
accountKeypair: {
privateKeyPem: '<ASCII PEM>'
}
, domains: [ 'example.com' ]
},
domains: ['example.com'],
, setChallenge: fn (hostname, key, val) // return Promise
, removeChallenge: fn (hostname, key) // return Promise
}
```
Helpers & Stuff
```javascript
// Constants
ACME.challengePrefixes['http-01']; // '/.well-known/acme-challenge'
ACME.challengePrefixes['dns-01']; // '_acme-challenge'
getZones: function(opts) {}, // should Promise an array of domain zone names
setChallenge: function(opts) {}, // should Promise the record id, or name
removeChallenge: function(opts) {} // should Promise null
};
```
# Changelog
- v1.8
- more transitional prepwork for new v2 API
- support newer (simpler) dns-01 and http-01 libraries
- v1.5
- perform full test challenge first (even before nonce)
- v1.3

View File

@ -0,0 +1,69 @@
(function(exports) {
'use strict';
// node[0] ./test.js[1] jon.doe@gmail.com[2] example.com,*.example.com[3] xxxxxx[4]
var email = process.argv[2] || process.env.ACME_EMAIL;
var domains = (process.argv[3] || process.env.ACME_DOMAINS).split(/[,\s]+/);
var token = process.argv[4] || process.env.DIGITALOCEAN_API_KEY;
// git clone https://git.rootprojects.org/root/acme-dns-01-digitalocean.js node_modules/acme-dns-01-digitalocean
var dns01 = require('acme-dns-01-digitalocean').create({
//baseUrl: 'https://api.digitalocean.com/v2/domains',
token: token
});
// This will be replaced with Keypairs.js in the next version
var promisify = require('util').promisify;
var generateKeypair = promisify(require('rsa-compat').RSA.generateKeypair);
//var ACME = exports.ACME || require('acme').ACME;
var ACME = exports.ACME || require('../').ACME;
var acme = ACME.create({});
acme
.init({
//directoryUrl: 'https://acme-staging-v02.api.letsencrypt.org/directory'
})
.then(function() {
return generateKeypair(null).then(function(accountPair) {
return generateKeypair(null).then(function(serverPair) {
return acme.accounts
.create({
// valid email (server checks MX records)
email: email,
accountKeypair: accountPair,
agreeToTerms: function(tosUrl) {
// ask user (if user is the host)
return tosUrl;
}
})
.then(function(account) {
console.info('Created Account:');
console.info(account);
return acme.certificates
.create({
domains: domains,
challenges: { 'dns-01': dns01 },
domainKeypair: serverPair,
accountKeypair: accountPair,
// v2 will be directly compatible with the new ACME modules,
// whereas this version needs a shim
getZones: dns01.zones,
setChallenge: dns01.set,
removeChallenge: dns01.remove
})
.then(function(certs) {
console.info('Secured SSL Certificates');
console.info(certs);
});
});
});
});
})
.catch(function(e) {
console.error('Something went wrong:');
console.error(e);
process.exit(500);
});
})('undefined' === typeof module ? window : module.exports);

3
examples/example.env Normal file
View File

@ -0,0 +1,3 @@
ACME_EMAIL=jon.doe@gmail.com
ACME_DOMAINS=example.com,foo.example.com,*.foo.example.com
DIGITALOCEAN_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

111
node.js
View File

@ -276,7 +276,10 @@ ACME._registerAccount = function(me, options) {
}
if (1 === options.agreeToTerms.length) {
// newer promise API
return options.agreeToTerms(me._tos).then(agree, reject);
return Promise.resolve(options.agreeToTerms(me._tos)).then(
agree,
reject
);
} else if (2 === options.agreeToTerms.length) {
// backwards compat cb API
return options.agreeToTerms(me._tos, function(err, tosUrl) {
@ -461,6 +464,58 @@ ACME._chooseChallenge = function(options, results) {
return challenge;
};
ACME._getZones = function(me, options, dnsHosts) {
if ('function' !== typeof options.getZones) {
options.getZones = function() {
return Promise.resolve([]);
};
}
return new Promise(function(resolve, reject) {
try {
if (options.getZones.length <= 1) {
options
.getZones({ dnsHosts: dnsHosts })
.then(resolve)
.catch(reject);
} else if (2 === options.getZones.length) {
options.getZones({ dnsHosts: dnsHosts }, function(err, zonenames) {
if (err) {
reject(err);
} else {
resolve(zonenames);
}
});
} else {
throw new Error(
'options.getZones should accept opts and Promise an array of zone names'
);
}
} catch (e) {
reject(e);
}
});
};
function newZoneRegExp(zonename) {
// (^|\.)example\.com$
// which matches:
// foo.example.com
// example.com
// but not:
// fooexample.com
return new RegExp('(^|\\.)' + zonename.replace(/\./g, '\\.') + '$');
}
function pluckZone(zonenames, dnsHost) {
return zonenames
.filter(function(zonename) {
// the only character that needs to be escaped for regex
// and is allowed in a domain name is '.'
return newZoneRegExp(zonename).test(dnsHost);
})
.sort(function(a, b) {
// longest match first
return b.length - a.length;
})[0];
}
ACME._challengeToAuth = function(me, options, request, challenge, dryrun) {
// we don't poison the dns cache with our dummy request
var dnsPrefix = ACME.challengePrefixes['dns-01'];
@ -490,6 +545,7 @@ ACME._challengeToAuth = function(me, options, request, challenge, dryrun) {
auth[key] = challenge[key];
});
var zone = pluckZone(options.zonenames || [], auth.identifier.value);
// batteries-included helpers
auth.hostname = auth.identifier.value;
// because I'm not 100% clear if the wildcard identifier does or doesn't have the leading *. in all cases
@ -511,7 +567,15 @@ ACME._challengeToAuth = function(me, options, request, challenge, dryrun) {
.update(auth.keyAuthorization)
.digest('base64')
);
if (zone) {
auth.dnsZone = zone;
auth.dnsPrefix = auth.dnsHost
.replace(newZoneRegExp(zone), '')
.replace(/\.$/, '');
}
// for backwards compat
auth.challenge = auth;
return auth;
};
@ -997,6 +1061,15 @@ ACME._getCertificate = function(me, options) {
}
}
var dnsHosts = options.domains.map(function(d) {
return (
require('crypto')
.randomBytes(2)
.toString('hex') + d
);
});
return ACME._getZones(me, options, dnsHosts).then(function(zonenames) {
options.zonenames = zonenames;
// Do a little dry-run / self-test
return ACME._testChallenges(me, options).then(function() {
if (me.debug) {
@ -1119,7 +1192,12 @@ ACME._getCertificate = function(me, options) {
);
}
var auth = ACME._challengeToAuth(me, options, results, challenge);
var auth = ACME._challengeToAuth(
me,
options,
results,
challenge
);
auths.push(auth);
return ACME._setChallenge(me, options, auth).then(setNext);
});
@ -1156,7 +1234,9 @@ ACME._getCertificate = function(me, options) {
._request({ method: 'GET', url: me._certificate, json: true })
.then(function(resp) {
if (me.debug) {
console.debug('acme-v2: csr submitted and cert received:');
console.debug(
'acme-v2: csr submitted and cert received:'
);
}
// https://github.com/certbot/certbot/issues/5721
var certsarr = ACME.splitPemChain(
@ -1180,6 +1260,7 @@ ACME._getCertificate = function(me, options) {
});
});
});
});
};
ACME.create = function create(me) {
@ -1190,7 +1271,7 @@ ACME.create = function create(me) {
me.challengePrefixes = ACME.challengePrefixes;
me.RSA = me.RSA || require('rsa-compat').RSA;
//me.Keypairs = me.Keypairs || require('keypairs');
me.request = me.request || require('@coolaj86/urequest');
me.request = me.request || require('@root/request');
me._dig = function(query) {
// TODO use digd.js
return new Promise(function(resolve, reject) {
@ -1241,7 +1322,27 @@ ACME.create = function create(me) {
}
me.init = function(_directoryUrl) {
me.directoryUrl = me.directoryUrl || _directoryUrl;
if (_directoryUrl) {
_directoryUrl = _directoryUrl.directoryUrl || _directoryUrl;
}
if ('string' === typeof _directoryUrl) {
me.directoryUrl = _directoryUrl;
}
if (!me.directoryUrl) {
me.directoryUrl =
'https://acme-staging-v02.api.letsencrypt.org/directory';
console.warn();
console.warn(
"No ACME `directoryUrl` was specified. Using Let's Encrypt's staging environment as the default, which will issue invalid certs."
);
console.warn('\t' + me.directoryUrl);
console.warn();
console.warn(
"To get valid certificates you will need to switch to a production URL. You might like Let's Encrypt v2:"
);
console.warn('\t' + me.directoryUrl.replace('-staging', ''));
console.warn();
}
return ACME._directory(me).then(function(resp) {
me._directoryUrls = resp.body;
me._tos = me._directoryUrls.meta.termsOfService;

22
package-lock.json generated
View File

@ -1,13 +1,19 @@
{
"name": "acme-v2",
"version": "1.7.6",
"version": "1.8.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@coolaj86/urequest": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/@coolaj86/urequest/-/urequest-1.3.7.tgz",
"integrity": "sha512-PPrVYra9aWvZjSCKl/x1pJ9ZpXda1652oJrPBYy5rQumJJMkmTBN3ux+sK2xAUwVvv2wnewDlaQaHLxLwSHnIA=="
"@root/request": {
"version": "1.3.11",
"resolved": "https://registry.npmjs.org/@root/request/-/request-1.3.11.tgz",
"integrity": "sha512-3a4Eeghcjsfe6zh7EJ+ni1l8OK9Fz2wL1OjP4UCa0YdvtH39kdXB9RGWuzyNv7dZi0+Ffkc83KfH0WbPMiuJFw=="
},
"dotenv": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.0.0.tgz",
"integrity": "sha512-30xVGqjLjiUOArT4+M5q9sYdvuR4riM6yK9wMcas9Vbp6zZa+ocC9dp6QoftuhTPhFAiLK/0C5Ni2nou/Bk8lg==",
"dev": true
},
"eckles": {
"version": "1.4.1",
@ -29,9 +35,9 @@
"integrity": "sha512-KxtX+/fBk+wM7O3CNgwjSh5elwFilLvqWajhr6wFr2Hd63JnKTTi43Tw+Jb1hxJQWOwoya+NZWR2xztn3hCrTw=="
},
"rsa-compat": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/rsa-compat/-/rsa-compat-2.0.6.tgz",
"integrity": "sha512-bQmpscAQec9442RaghDybrHMy1twQ3nUZOgTlqntio1yru+rMnDV64uGRzKp7dJ4VVhNv3mLh3X4MNON+YM0dA==",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/rsa-compat/-/rsa-compat-2.0.8.tgz",
"integrity": "sha512-BFiiSEbuxzsVdaxpejbxfX07qs+rtous49Y6mL/zw6YHh9cranDvm2BvBmqT3rso84IsxNlP5BXnuNvm1Wn3Tw==",
"requires": {
"keypairs": "^1.2.14"
}

View File

@ -1,11 +1,11 @@
{
"name": "acme-v2",
"version": "1.7.7",
"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",
"version": "1.8.0",
"description": "A lightweight library for getting Free SSL certifications through Let's Encrypt, using the ACME protocol.",
"homepage": "https://git.coolaj86.com/coolaj86/acme-v2.js",
"main": "node.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"test": "node ./test.js"
},
"repository": {
"type": "git",
@ -23,10 +23,13 @@
"automated https",
"letsencrypt"
],
"author": "AJ ONeal <coolaj86@gmail.com> (https://coolaj86.com/)",
"author": "AJ ONeal <coolaj86@gmail.com> (https://solderjs.com/)",
"license": "MPL-2.0",
"dependencies": {
"@coolaj86/urequest": "^1.3.6",
"rsa-compat": "^2.0.6"
"@root/request": "^1.3.11",
"rsa-compat": "^2.0.8"
},
"devDependencies": {
"dotenv": "^8.0.0"
}
}

3
test.js Normal file
View File

@ -0,0 +1,3 @@
'use strict';
require('dotenv').config();
require('./examples/dns-01-digitalocean.js');