v3.0.1: documented + examples

This commit is contained in:
AJ ONeal 2019-10-26 00:03:43 -06:00
parent e0bec09e43
commit a99a0cc211
11 changed files with 757 additions and 293 deletions

488
README.md
View File

@ -1,50 +1,16 @@
# [ACME.js](https://git.rootprojects.org/root/acme.js) (RFC 8555 / November 2019) # Let's Encrypt™ + JavaScript = [ACME.js](https://git.rootprojects.org/root/acme.js)
| Built by [Root](https://therootcompany.com) for [Greenlock](https://greenlock.domains) | Built by [Root](https://therootcompany.com) for [Hub](https://rootprojects.org/hub)
Free SSL Certificates from Let's Encrypt, for Node.js and Web Browsers ACME.js is a _low-level_ client for Let's Encrypt.
Lightweight. Fast. Modern Crypto. Zero external dependecies. Looking for an **easy**, _high-level_ client? Check out [Greenlock.js](https://git.rootprojects.org/root/greenlock.js).
# Features # Online Demo
| 15k gzipped | 55k minified | 88k (2,500 loc) source with comments | See https://greenlock.domains
The primary goal of this library is to make it easy to <!--
get Accounts and Certificates through Let's Encrypt.
- [x] Let's Encrypt v2 / ACME RFC 8555 (November 2019)
- [x] POST-as-GET support
- [x] Secure support for EC and RSA for account and server keys
- [x] Simple and lightweight PEM, DER, ASN1, X509, and CSR implementations
- [ ] (in-progress) StartTLS Everywhere&trade;
- [x] Supports International Domain Names (i.e. `.中国`)
- [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, Zero External Dependencies
- [x] Safe, Efficient, Maintained
- [x] Node.js\* (v6+)
- [x] WebPack
- [x] Online Demo
- See https://greenlock.domains
\* Although we use `async/await` in the examples, the code is written in CommonJS,
with Promises, so you can use it in Node.js and Browsers without transpiling.
# Want Quick and Easy?
ACME.js is a low-level tool for building Let's Encrypt clients in Node and Browsers.
If you're looking for maximum convenience, try
[Greenlock.js](https://git.rootprojects.org/root/greenlock-express.js).
- <https://git.rootprojects.org/root/greenlock-express.js>
# Online Demos
- Greenlock for the Web <https://greenlock.domains>
- ACME.js Demo <https://rootprojects.org/acme/>
We expect that our hosted versions will meet all of yours needs. We expect that our hosted versions will meet all of yours needs.
If they don't, please open an issue to let us know why. If they don't, please open an issue to let us know why.
@ -52,7 +18,71 @@ If they don't, please open an issue to let us know why.
We'd much rather improve the app than have a hundred different versions running in the wild. We'd much rather improve the app than have a hundred different versions running in the wild.
However, in keeping to our values we've made the source visible for others to inspect, improve, and modify. However, in keeping to our values we've made the source visible for others to inspect, improve, and modify.
# API Overview -->
# Features
| 15k gzipped | 55k minified | 88k (2,500 loc) source with comments |
Supports the latest (Nov 2019) release of Let's Encrypt in a small, lightweight, Vanilla JS package.
- [x] Let's Encrypt v2
- [x] ACME RFC 8555
- [x] November 2019
- [x] POST-as-GET
- [ ] StartTLS Everywhere&trade; (in-progress)
- [x] IDN (i.e. `.中国`)
- [x] ECDSA and RSA keypairs
- [x] JWK
- [x] PEM
- [x] DER
- [x] Native Crypto in Node.js
- [x] WebCrypto in Browsers
- [x] Domain Validation Plugins
- [x] tls-alpn-01
- [x] http-01
- [x] dns-01
- [x] **Wildcards**
- [x] **Localhost**
- [x] Private Networks
- [x] [Create your own](https://git.rootprojects.org/root/acme-challenge-test.js)
- [x] Vanilla JS\*
- [x] No Transpiling Necessary!
- [x] Node.js
- [x] Browsers
- [x] WebPack
- [x] Zero External Dependencies
- [x] Commercial Support
- [x] Safe, Efficient, Maintained
\* Although we use `async/await` in the examples,
the codebase is written entirely in Common JS.
# Use Cases
- Home Servers
- IoT
- Enterprise On-Prem
- Web Hosting
- Cloud Services
- Localhost Development
# API
The public API encapsulates the three high-level steps of the ACME protocol:
1. API Discovery
2. Account Creation
- Subscriber Agreement
3. Certificate Issuance
- Certificate Request
- Authorization Challenges
- Challenge Presentation
- Certificate Redemption
## Overview
The core API can be show in just four functions:
```js ```js
ACME.create({ maintainerEmail, packageAgent, notify }); ACME.create({ maintainerEmail, packageAgent, notify });
@ -68,38 +98,99 @@ acme.certificates.create({
}); });
``` ```
| Parameter | Description |
| --------------- | ----------------------------------------------------------------------------------------------------------- |
| account | an object containing the Let's Encrypt Account ID as "kid" (misnomer, not actually a key id/thumbprint) |
| accountKey | an RSA or EC public/private keypair in JWK format |
| agreeToTerms | set to `true` to agree to the Let's Encrypt Subscriber Agreement |
| challenges | the 'http-01', 'alpn-01', and/or 'dns-01' challenge plugins (`get`, `set`, and `remove` callbacks) to use |
| csr | a Certificate Signing Request (CSR), which may be generated with csr.js, openssl, or another |
| customerEmail | Don't use this. Given as an example to differentiate between Maintainer, Subscriber, and End-User |
| directoryUrl | should be the Let's Encrypt Directory URL<br>`https://acme-staging-v02.api.letsencrypt.org/directory` |
| domains | the list of altnames (subject first) that are listed in the CSR and will be listed on the certificate |
| notify | all callback for logging events and errors in the form `function (ev, args) { ... }` |
| maintainerEmail | should be a contact for the author of the code to receive critical bug and security notices |
| packageAgent | should be an RFC72321-style user-agent string to append to the ACME client (ex: mypackage/v1.1.1) |
| subscriberEmail | should be a contact for the service provider to receive renewal failure notices and manage the ACME account |
Helper Functions Helper Functions
```js ```js
ACME.computeChallenge({ ACME.computeChallenge({
accountKey: jwk, accountKey,
hostname: 'example.com', hostname: 'example.com',
challenge: { type: 'dns-01', token: 'xxxx' } challenge: { type: 'dns-01', token: 'xxxx' }
}); });
``` ```
| Parameter | Description |
| ------------------ | ----------------------------------------------------------------------------------------------------------- |
| account | an object containing the Let's Encrypt Account ID as "kid" (misnomer, not actually a key id/thumbprint) |
| accountKey | an RSA or EC public/private keypair in JWK format |
| agreeToTerms | set to `true` to agree to the Let's Encrypt Subscriber Agreement |
| challenges | the 'http-01', 'alpn-01', and/or 'dns-01' challenge plugins (`get`, `set`, and `remove` callbacks) to use |
| csr | a Certificate Signing Request (CSR), which may be generated with `@root/csr`, openssl, or another |
| customerEmail | Don't use this. Given as an example to differentiate between Maintainer, Subscriber, and End-User |
| directoryUrl | should be the Let's Encrypt Directory URL<br>`https://acme-staging-v02.api.letsencrypt.org/directory` |
| domains | the list of altnames (subject first) that are listed in the CSR and will be listed on the certificate |
| maintainerEmail | should be a contact for the author of the code to receive critical bug and security notices |
| notify | all callback for logging events and errors in the form `function (ev, args) { ... }` |
| packageAgent | should be an RFC72321-style user-agent string to append to the ACME client (ex: mypackage/v1.1.1) |
| skipChallengeTests | do not do a self-check that the ACME-issued challenges will pass (not recommended) |
| skipDryRun: false | do not do a self-check with self-issued challenges (not recommended) |
| subscriberEmail | should be a contact for the service provider to receive renewal failure notices and manage the ACME account |
**Maintainer vs Subscriber vs Customer**
- `maintainerEmail` should be the email address of the **author of the code**.
This person will receive critical security and API change notifications.
- `subscriberEmail` should be the email of the **admin of the hosting service**.
This person agrees to the Let's Encrypt Terms of Service and will be notified
when a certificate fails to renew.
- `customerEmail` should be the email of individual who owns the domain.
This is optional (not currently implemented).
Generally speaking **YOU** are the _maintainer_ and you **or your employer** is the _subscriber_.
If you (or your employer) is running any type of service
you **SHOULD NOT** pass the _customer_ email as the subscriber email.
If you are not running a service (you may be building a CLI, for example),
then you should prompt the user for their email address, and they are the subscriber.
## Events
These `notify` events are intended for _logging_ and debugging, NOT as a data API.
| Event Name | Example Message |
| -------------------- | --------------------------------------------------------------------------------- |
| `certificate_order` | `{ subject: 'example.com', altnames: ['...'], account: { key: { kid: '...' } } }` |
| `challenge_select` | `{ altname: '*.example.com', type: 'dns-01' }` |
| `challenge_status` | `{ altname: '*.example.com', type: 'dns-01', status: 'pending' }` |
| `challenge_remove` | `{ altname: '*.example.com', type: 'dns-01' }` |
| `certificate_status` | `{ subject: 'example.com', status: 'valid' }` |
| `warning` | `{ message: 'what went wrong', description: 'what action to take about it' }` |
| `error` | `{ message: 'a background process failed, and it may have side-effects' }` |
Note: DO NOT rely on **undocumented properties**. They are experimental and **will break**.
If you have a use case for a particular property **open an issue** - we can lock it down and document it.
# Example
A basic example includes the following:
1. Initialization
- maintainer contact
- package user-agent
- log events
2. Discover API
- retrieves Terms of Service and API endpoints
3. Get Subscriber Account
- create an ECDSA (or RSA) Account key in JWK format
- agree to terms
- register account by the key
4. Prepare a Certificate Signing Request
- create a RSA (or ECDSA) Server key in PEM format
- select domains
- choose challenges
- sign CSR
- order certificate
See [examples/README.md](https://git.rootprojects.org/root/acme.js/src/branch/master/examples/README.md)
# Install # Install
To make it easy to generate, encode, and decode keys and certificates, To make it easy to generate, encode, and decode keys and certificates,
ACME.js uses [Keypairs.js](https://git.rootprojects.org/root/keypairs.js) ACME.js uses [Keypairs.js](https://git.rootprojects.org/root/keypairs.js)
and [CSR.js](https://git.rootprojects.org/root/csr.js) and [CSR.js](https://git.rootprojects.org/root/csr.js)
## Node.js <detail>
<summary>Node.js</summary>
```js ```js
npm install --save @root/acme npm install --save @root/acme
@ -109,7 +200,10 @@ npm install --save @root/acme
var ACME = require('@root/acme'); var ACME = require('@root/acme');
``` ```
## WebPack </detail>
<detail>
<summary>WebPack</summary>
```html ```html
<meta charset="UTF-8" /> <meta charset="UTF-8" />
@ -121,7 +215,10 @@ var ACME = require('@root/acme');
var ACME = require('@root/acme'); var ACME = require('@root/acme');
``` ```
## Vanilla JS </detail>
<detail>
<summary>Vanilla JS</summary>
```html ```html
<meta charset="UTF-8" /> <meta charset="UTF-8" />
@ -145,234 +242,80 @@ Use
var ACME = window['@root/acme']; var ACME = window['@root/acme'];
``` ```
## Usage Examples </detail>
You can see `tests/index.js`, `examples/index.html`, `examples/app.js` in the repo for full example usage. # Challenge Callbacks
### Emails: Maintainer vs Subscriber vs Customer The challenge callbacks are documented in the [test suite](https://git.rootprojects.org/root/acme-dns-01-test.js),
essentially:
- `maintainerEmail` should be the email address of the **author of the code**.
This person will receive critical security and API change notifications.
- `subscriberEmail` should be the email of the **admin of the hosting service**.
This person agrees to the Let's Encrypt Terms of Service and will be notified
when a certificate fails to renew.
- `customerEmail` should be the email of individual who owns the domain.
This is optional (not currently implemented).
Generally speaking **YOU** are the _maintainer_ and you **or your employer** is the _subscriber_.
If you (or your employer) is running any type of service
you **SHOULD NOT** pass the _customer_ email as the subscriber email.
If you are not running a service (you may be building a CLI, for example),
then you should prompt the user for their email address, and they are the subscriber.
### Overview
1. Create an instance of ACME.js
2. Create and SAVE a Subscriber Account private key
3. Retrieve the Let's Encrypt Subscriber account (with the key)
- the account will be created if it doesn't exist
4. Create a Server Key
- this should be per-server, or perhaps per-end-user
5. Create a Certificate Signing Request
- International Domain Names must be converted with `punycode`
6. Create an ACME Order
- use a challenge plugin for HTTP-01 or DNS-01 challenges
### Instantiate ACME.js
Although built for Let's Encrypt, ACME.js will work with any server
that supports draft-15 of the ACME spec (includes POST-as-GET support).
The `init()` method takes a _directory url_ and initializes internal state according to its response.
```js ```js
var acme = ACME.create({ function create(options) {
maintainerEmail: 'jon@example.com' var plugin = {
}); init: async function(deps) {
acme.init('https://acme-staging-v02.api.letsencrypt.org/directory').then( // for http requests
function() { plugin.request = deps.request;
// Ready to use, show page
$('body').hidden = false;
}
);
```
### Create ACME Account with Let's Encrypt
ACME Accounts are key and device based, with an email address as a backup identifier.
A public account key must be registered before an SSL certificate can be requested.
```js
var accountPrivateJwk;
var account;
Keypairs.generate({ kty: 'EC' }).then(function(pair) {
accountPrivateJwk = pair.private;
return acme.accounts
.create({
agreeToTerms: function(tos) {
if (
window.confirm(
"Do you agree to the ACME.js and Let's Encrypt Terms of Service?"
)
) {
return Promise.resolve(tos);
}
}, },
accountKey: pair.private, zones: async function(args) {
subscriberEmail: $('.js-email-input').value // list zones relevant to the altnames
})
.then(function(_account) {
account = _account;
});
});
```
### Generate a Certificate Private Key
```js
var certKeypair = await Keypairs.generate({ kty: 'RSA' });
var pem = await Keypairs.export({
jwk: certKeypair.private,
encoding: 'pem'
});
// This should be saved as `privkey.pem`
console.log(pem);
```
### Generate a CSR
The easiest way to generate a Certificate Signing Request will be either with `openssl` or with `@root/CSR`.
```js
var CSR = require('@root/csr');
var Enc = require('@root/encoding');
// 'subject' should be first in list
// the domains may be in any order, but it should be consistent
var sortedDomains = ['example.com', 'www.example.com'];
var csr = await CSR.csr({
jwk: certKeypair.private,
domains: sortedDomains,
encoding: 'der'
}).then(function(der) {
return Enc.bufToUrlBase64(der);
});
```
### Get Free 90-day SSL Certificate
Creating an ACME "order" for a 90-day SSL certificate requires use of the account private key,
the names of domains to be secured, and a distinctly separate server private key.
A domain ownership verification "challenge" (uploading a file to an unsecured HTTP url or setting a DNS record)
is a required part of the process, which requires `set` and `remove` callbacks/promises.
```js
var certinfo = await acme.certificates.create({
account: account,
accountKey: accountPrivateJwk,
csr: csr,
domains: sortedDomains,
challenges: challenges, // must be implemented
customerEmail: null,
skipChallengeTests: false,
skipDryRun: false
});
console.log('Got SSL Certificate:');
console.log(results.expires);
// This should be saved as `fullchain.pem`
console.log([results.cert, results.chain].join('\n'));
```
### Example "Challenge" Implementation
Typically here you're just presenting some sort of dialog to the user to ask them to
upload a file or set a DNS record.
It may be possible to do something fancy like using OAuth2 to login to Google Domanis
to set a DNS address, etc, but it seems like that sort of fanciness is probably best
reserved for server-side plugins.
```js
var challenges = {
'http-01': {
set: function(opts) {
console.info('http-01 set challenge:');
console.info(opts.challengeUrl);
console.info(opts.keyAuthorization);
while (
!window.confirm('Upload the challenge file before continuing.')
) {
// spin and wait for the user to upload the challenge file
}
return Promise.resolve();
}, },
remove: function(opts) { set: async function(args) {
console.log('http-01 remove challenge:', opts.challengeUrl); // set TXT record
return Promise.resolve(); },
} get: async function(args) {
} // get TXT records
}; },
remove: async function(args) {
// remove TXT record
},
// how long to wait after *all* TXT records are set
// before presenting them for validation
propagationDelay: 5000
};
return plugin;
}
``` ```
The `http-01` plugin is similar, but without `zones` or `propagationDelay`.
Many challenge plugins are already available for popular platforms. Many challenge plugins are already available for popular platforms.
Search `acme-http-01-` or `acme-dns-01-` on npm to find more. Search `acme-http-01-` or `acme-dns-01-` on npm to find more.
- [x] DNS-01 Challenges | Type | Service | Plugin |
- CloudFlare | ----------- | ----------------------------------------------------------------------------------- | ------------------------ |
- [Digital Ocean](https://git.rootprojects.org/root/acme-dns-01-digitalocean.js) | dns-01 | CloudFlare | acme-dns-01-cloudflare |
- [DNSimple](https://git.rootprojects.org/root/acme-dns-01-dnsimple.js) | dns-01 | [Digital Ocean](https://git.rootprojects.org/root/acme-dns-01-digitalocean.js) | acme-dns-01-digitalocean |
- [DuckDNS](https://git.rootprojects.org/root/acme-dns-01-duckdns.js) | dns-01 | [DNSimple](https://git.rootprojects.org/root/acme-dns-01-dnsimple.js) | acme-dns-01-dnsimple |
- [GoDaddy](https://git.rootprojects.org/root/acme-dns-01-godaddy.js) | dns-01 | [DuckDNS](https://git.rootprojects.org/root/acme-dns-01-duckdns.js) | acme-dns-01-duckdns |
- [Gandi](https://git.rootprojects.org/root/acme-dns-01-gandi.js) | http-01 | File System / [Web Root](https://git.rootprojects.org/root/acme-http-01-webroot.js) | acme-http-01-webroot |
- [NameCheap](https://git.rootprojects.org/root/acme-dns-01-namecheap.js) | dns-01 | [GoDaddy](https://git.rootprojects.org/root/acme-dns-01-godaddy.js) | acme-dns-01-godaddy |
- [Name&#46;com](https://git.rootprojects.org/root/acme-dns-01-namedotcom.js) | dns-01 | [Gandi](https://git.rootprojects.org/root/acme-dns-01-gandi.js) | acme-dns-01-gandi |
- Route53 (AWS) | dns-01 | [NameCheap](https://git.rootprojects.org/root/acme-dns-01-namecheap.js) | acme-dns-01-namecheap |
- [Vultr](https://git.rootprojects.org/root/acme-dns-01-vultr.js) | dns-01 | [Name&#46;com](https://git.rootprojects.org/root/acme-dns-01-namedotcom.js) | acme-dns-01-namedotcom |
- Build your own | dns-01 | Route53 (AWS) | acme-dns-01-route53 |
- [x] HTTP-01 Challenges | http-01 | S3 (AWS, Digital Ocean, Scaleway) | acme-http-01-s3 |
- [In-Memory](https://git.rootprojects.org/root/acme-http-01-standalone.js) (Standalone) | dns-01 | [Vultr](https://git.rootprojects.org/root/acme-dns-01-vultr.js) | acme-dns-01-vultr |
- [FileSystem](https://git.rootprojects.org/root/acme-http-01-webroot.js) (WebRoot) | dns-01 | [Build your own](https://git.rootprojects.org/root/acme-dns-01-test.js) | acme-dns-01-test |
- S3 (AWS, Digital Ocean, etc) | http-01 | [Build your own](https://git.rootprojects.org/root/acme-http-01-test.js) | acme-http-01-test |
- [x] TLS-ALPN-01 Challenges | tls-alpn-01 | [Contact us](mailto:support@therootcompany.com) | - |
- Contact us to learn about Greenlock Pro
# IDN - International Domain Names # Running the Tests
Convert domain names to `punycode` before creating the certificate: ```bash
npm test
```js
var punycode = require('punycode');
acme.certificates.create({
// ...
domains: ['example.com', 'www.example.com'].map(function(name) {
return punycode.toASCII(name);
})
});
``` ```
The punycode library itself is lightweight and dependency-free. ## Usa a dns-01 challenge
It is available both in node and for browsers.
# Testing Although you can run the tests from a public facing server, its easiest to do so using a dns-01 challenge.
You will need to use one of the [`acme-dns-01-*` plugins](https://www.npmjs.com/search?q=acme-dns-01-) You will need to use one of the [`acme-dns-01-*` plugins](https://www.npmjs.com/search?q=acme-dns-01-)
to run the test locally. to run the test locally.
You'll also need a `.env` that looks something like the one in `examples/example.env`:
```bash ```bash
ENV=DEV ENV=DEV
MAINTAINER_EMAIL=letsencrypt+staging@example.com
SUBSCRIBER_EMAIL=letsencrypt+staging@example.com SUBSCRIBER_EMAIL=letsencrypt+staging@example.com
BASE_DOMAIN=test.example.com BASE_DOMAIN=test.example.com
CHALLENGE_TYPE=dns-01 CHALLENGE_TYPE=dns-01
@ -380,7 +323,7 @@ CHALLENGE_PLUGIN=acme-dns-01-digitalocean
CHALLENGE_OPTIONS='{"token":"xxxxxxxxxxxx"}' CHALLENGE_OPTIONS='{"token":"xxxxxxxxxxxx"}'
``` ```
For example: ### For Example
```bash ```bash
# Get the repo and change directories into it # Get the repo and change directories into it
@ -389,7 +332,13 @@ pushd acme.js/
# Install the challenge plugin you'll use for the tests # Install the challenge plugin you'll use for the tests
npm install --save-dev acme-dns-01-digitalocean npm install --save-dev acme-dns-01-digitalocean
```
## Create a `.env` config
You'll need a `.env` in the project root that looks something like the one in `examples/example.env`:
```bash
# Copy the sample .env file # Copy the sample .env file
rsync -av examples/example.env .env rsync -av examples/example.env .env
@ -403,9 +352,6 @@ node tests/index.js
# Developing # Developing
You can see `<script>` tags in the `index.html` in the repo, which references the original
source files.
Join `@rootprojects` `#general` on [Keybase](https://keybase.io) if you'd like to chat with us. Join `@rootprojects` `#general` on [Keybase](https://keybase.io) if you'd like to chat with us.
# Commercial Support # Commercial Support
@ -419,7 +365,7 @@ We also offer consulting for all-things-ACME and Let's Encrypt.
# Legal &amp; Rules of the Road # Legal &amp; Rules of the Road
Greenlock&trade; is a [trademark](https://rootprojects.org/legal/#trademark) of AJ ONeal ACME.jsk&trade; is a [trademark](https://rootprojects.org/legal/#trademark) of AJ ONeal
The rule of thumb is "attribute, but don't confuse". For example: The rule of thumb is "attribute, but don't confuse". For example:

View File

@ -46,10 +46,10 @@ A._getAccountKid = function(me, options) {
A._registerAccount = function(me, options) { A._registerAccount = function(me, options) {
//#console.debug('[ACME.js] accounts.create'); //#console.debug('[ACME.js] accounts.create');
function agree(tosUrl) { function agree(agreed) {
var err; var err;
if (me._tos !== tosUrl) { if (!agreed) {
err = new Error("must agree to '" + tosUrl + "'"); err = new Error("must agree to '" + me._tos + "'");
err.code = 'E_AGREE_TOS'; err.code = 'E_AGREE_TOS';
throw err; throw err;
} }
@ -136,12 +136,30 @@ A._registerAccount = function(me, options) {
.then(function() { .then(function() {
//#console.debug('[ACME.js] agreeToTerms'); //#console.debug('[ACME.js] agreeToTerms');
var agreeToTerms = options.agreeToTerms; var agreeToTerms = options.agreeToTerms;
if (true === agreeToTerms) { if (!agreeToTerms) {
agreeToTerms = function(tos) { agreeToTerms = function(terms) {
return tos; console.log(
'By using this software you accept this Subscriber Agreement and Terms of Service:'
);
console.info(
'ACME Subscriber Agreement:',
terms.acmeSubscriberTermsUrl
);
console.info(
'Greenlock/ACME.js Terms of Use:',
terms.terms.acmeJsTermsUrl
);
return true;
};
} else if (true === agreeToTerms) {
agreeToTerms = function(terms) {
return terms && true;
}; };
} }
return agreeToTerms(me._tos); return agreeToTerms({
acmeSubscriberTosUrl: me._tos,
acmeJsTosUrl: 'https://rootprojects.org/legal/#terms'
});
}) })
.then(agree) .then(agree)
.then(getAccount); .then(getAccount);

31
acme.js
View File

@ -110,7 +110,7 @@ ACME.create = function create(me) {
// create + get challlenges // create + get challlenges
get: function(options) { get: function(options) {
return A._getAccountKid(me, options).then(function(kid) { return A._getAccountKid(me, options).then(function(kid) {
ACME._normalizePresenters(options, options.challenges); ACME._normalizePresenters(me, options, options.challenges);
return ACME._orderCert(me, options, kid).then(function(order) { return ACME._orderCert(me, options, kid).then(function(order) {
return order.claims; return order.claims;
}); });
@ -119,7 +119,7 @@ ACME.create = function create(me) {
// set challenges, check challenges, finalize order, return order // set challenges, check challenges, finalize order, return order
present: function(options) { present: function(options) {
return A._getAccountKid(me, options).then(function(kid) { return A._getAccountKid(me, options).then(function(kid) {
ACME._normalizePresenters(options, options.challenges); ACME._normalizePresenters(me, options, options.challenges);
return ACME._finalizeOrder(me, options, kid, options.order); return ACME._finalizeOrder(me, options, kid, options.order);
}); });
} }
@ -128,7 +128,7 @@ ACME.create = function create(me) {
me.certificates = { me.certificates = {
create: function(options) { create: function(options) {
return A._getAccountKid(me, options).then(function(kid) { return A._getAccountKid(me, options).then(function(kid) {
ACME._normalizePresenters(options, options.challenges); ACME._normalizePresenters(me, options, options.challenges);
return ACME._getCertificate(me, options, kid); return ACME._getCertificate(me, options, kid);
}); });
} }
@ -216,7 +216,7 @@ ACME._getCertificate = function(me, options, kid) {
return ACME._finalizeOrder(me, options, kid, order); return ACME._finalizeOrder(me, options, kid, order);
}); });
}; };
ACME._normalizePresenters = function(options, presenters) { ACME._normalizePresenters = function(me, options, presenters) {
// Prefer this order for efficiency: // Prefer this order for efficiency:
// * http-01 is the fasest // * http-01 is the fasest
// * tls-alpn-01 is for networks that don't allow plain traffic // * tls-alpn-01 is for networks that don't allow plain traffic
@ -228,6 +228,22 @@ ACME._normalizePresenters = function(options, presenters) {
return -1 !== presenterTypes.indexOf(typ); return -1 !== presenterTypes.indexOf(typ);
} }
); );
if (
presenters['dns-01'] &&
'number' !== typeof presenters['dns-01'].propagationDelay
) {
if (!ACME._propagationDelayWarning) {
var err = new Error(
"dns-01 challenge's `propagationDelay` not set, defaulting to 5000ms"
);
err.code = 'E_NO_DNS_DELAY';
err.description =
"Each dns-01 challenge should specify challenges['dns-01'].propagationDelay as an estimate of how long DNS propagation will take.";
ACME._notify(me, options, 'warning', err);
presenters['dns-01'].propagationDelay = 5000;
ACME._propagationDelayWarning = true;
}
}
Object.keys(presenters || {}).forEach(function(k) { Object.keys(presenters || {}).forEach(function(k) {
var ch = presenters[k]; var ch = presenters[k];
var warned = false; var warned = false;
@ -908,13 +924,6 @@ ACME._setChallenges = function(me, options, order) {
function waitAll() { function waitAll() {
//#console.debug('\n[DEBUG] waitChallengeDelay %s\n', DELAY); //#console.debug('\n[DEBUG] waitChallengeDelay %s\n', DELAY);
if (!DNS_DELAY || DNS_DELAY <= 0) { if (!DNS_DELAY || DNS_DELAY <= 0) {
if (!ACME._propagationDelayWarning) {
console.warn(
'warn: the given dns-01 challenge did not specify `propagationDelay`'
);
console.warn('warn: the default of 5000ms will be used');
ACME._propagationDelayWarning = true;
}
DNS_DELAY = 5000; DNS_DELAY = 5000;
} }
return ACME._wait(DNS_DELAY); return ACME._wait(DNS_DELAY);

317
examples/README.md Normal file
View File

@ -0,0 +1,317 @@
# Example [ACME.js](https://git.rootprojects.org/root/acme.js) Usage
| Built by [Root](https://therootcompany.com) for [Hub](https://rootprojects.org/hub)
ACME.js is a _low-level_ client for Let's Encrypt.
Looking for an **easy**, _high-level_ client? Check out [Greenlock.js](https://git.rootprojects.org/root/greenlock.js).
# Overview
A basic example includes the following:
1. Initialization
- maintainer contact
- package user-agent
- log events
2. Discover API
- retrieves Terms of Service and API endpoints
3. Get Subscriber Account
- create an ECDSA (or RSA) Account key in JWK format
- agree to terms
- register account by the key
4. Prepare a Certificate Signing Request
- create a RSA (or ECDSA) Server key in PEM format
- select domains (as punycode)
- choose challenges
- sign CSR
- order certificate
# Code
The tested-working code for this is in [examples/get-certificate-full.js](https://git.rootprojects.org/root/acme.js/src/branch/master/examples/get-certificate-full.js)
# Walkthrough
Whereas [Greenlock.js](https://git.rootprojects.org/root/greenlock.js) is very much "batteries included",
the goal of ACME.js is to be lightweight and over more control.
## 1. Create an `acme` instance
The maintainer contact is used by Root to notify you of security notices and
bugfixes to ACME.js.
The subscriber contact is used by Let's Encrypt to manage your account and
notify you of renewal failures. In the future we plan to enable some of that,
but allowing for your own branding.
The customer email is provided as an example of what NOT to use as either of the other two.
Typically your customers are NOT directly Let's Encrypt subscribers.
```js
// In many cases all three of these are the same (your email)
// However, this is what they may look like when different:
var maintainerEmail = 'security@devshop.com';
var subscriberEmail = 'support@hostingcompany.com';
var customerEmail = 'jane.doe@gmail.com';
```
The ACME spec requires clients to have RFC 7231 style User Agent.
This will be contstructed automatically using your package name.
```js
var pkg = require('../package.json');
var packageAgent = 'test-' + pkg.name + '/' + pkg.version;
```
Set up your logging facility. It's fine to ignore the logs,
but you'll probably want to log `warning` and `error` at least.
```js
// This is intended to get at important messages without
// having to use even lower-level APIs in the code
function notify(ev, msg) {
if ('error' === ev || 'warning' === ev) {
errors.push(ev.toUpperCase() + ' ' + msg.message);
return;
}
// be brief on all others
console.log(ev, msg.altname || '', msg.status || ''');
}
```
```js
var ACME = require('acme');
var acme = ACME.create({ maintainerEmail, packageAgent, notify });
```
## 2. Fetch the API Directory
ACME defines an API discovery mechanism.
For Let's Encrypt specifically, these are the _production_ and _staging_ URLs:
```js
// Choose either the production or staging URL
var directoryUrl = 'https://acme-staging-v02.api.letsencrypt.org/directory';
//var directoryUrl = 'https://acme-v02.api.letsencrypt.org/directory'
```
The init function will fetch the API and set internal urls and such accordingly.
```js
await acme.init(directoryUrl);
```
## 3. Create (or import) an Account Keypair
You must create a Subscriber Account using a public/private keypair.
The Account key MUST be different from the server key.
Keypairs.js will use native node crypto or WebCrypto to generate the key, and a lightweight parser and packer to translate between formats.
```js
var Keypairs = require('@root/keypairs');
```
Unless you're multi-tenanted, you only ever need ONE account key. Save it.
```js
// You only need ONE account key, ever, in most cases
// save this and keep it safe. ECDSA is preferred.
var accountKeypair = await Keypairs.generate({ kty: 'EC', format: 'jwk' });
var accountKey = accountKeypair.private;
```
If you already have a key you would like to use, you can import it (as shown in the server key section below).
## 4. Create an ACME Subscriber Account
In order to use Let's Encrypt and ACME.js, you must agree to the respective Subscriber Agreement and Terms.
```js
// This can be `true` or an async function which presents the terms of use
var agreeToTerms = true;
// If you are multi-tenanted or white-labled and need to present the terms of
// use to the Subscriber running the service, you can do so with a function.
var agreeToTerms = async function() {
return true;
};
```
You create an account with a signed JWS message including your public key, which ACME.js handles for you with your account key.
All messages must be signed with your account key.
```js
console.info('registering new ACME account...');
var account = await acme.accounts.create({
subscriberEmail,
agreeToTerms,
accountKey
});
console.info('created account with id', account.key.kid);
```
## 5. Create (or import) a Server Keypair
You must have a SERVER keypair, which is different from your account keypair.
This isn't part of the ACME protocol, but rather something your Web Server uses and which you must use to sign the request for an SSL certificate, the same as with paid issuers in the days of yore.
In many situations you only ever need ONE of these.
```js
// This is the key used by your WEBSERVER, typically named `privkey.pem`,
// `key.crt`, or `bundle.pem`. RSA may be preferrable for legacy compatibility.
// You can generate it fresh
var serverKeypair = await Keypairs.generate({ kty: 'RSA', format: 'jwk' });
var serverKey = serverKeypair.private;
var serverPem = await Keypairs.export({ jwk: serverKey });
await fs.promises.writeFile('./privkey.pem', serverPem, 'ascii');
// Or you can load it from a file
var serverPem = await fs.promises.readFile('./privkey.pem', 'ascii');
console.info('wrote ./privkey.pem');
var serverKey = await Keypairs.import({ pem: serverPem });
```
## 6. Create a Signed Certificate Request (CSR)
Your domains must be `punycode`-encoded:
```js
var punycode = require('punycode');
var domains = ['example.com', '*.example.com', '你好.example.com'];
domains = domains.map(function(name) {
return punycode.toASCII(name);
});
```
```js
var CSR = require('@root/csr');
var PEM = require('@root/pem');
var encoding = 'der';
var typ = 'CERTIFICATE REQUEST';
var csrDer = await CSR.csr({ jwk: serverKey, domains, encoding });
var csr = PEM.packBlock({ type: typ, bytes: csrDer });
```
## 7. Choose Domain Validation Strategies
You can use one of the existing http-01 or dns-01 plugins, or you can build your own.
There's a test suite that makes this very easy to do:
- [acme-dns-01-test](https://git.rootprojects.org/root/acme-dns-01-test.js)
- [acme-http-01-test](https://git.rootprojects.org/root/acme-http-01-test.js)
```js
// You can pick from existing challenge modules
// which integrate with a variety of popular services
// or you can create your own.
//
// The order of priority will be http-01, tls-alpn-01, dns-01
// dns-01 will always be used for wildcards
// dns-01 should be the only option given for local/private domains
var webroot = require('acme-http-01-webroot').create({});
var challenges = {
'http-01': webroot,
'dns-01': {
init: async function(deps) {
// includes the http request object to use
},
zones: async function(args) {
// return a list of zones
},
set: async function(args) {
// set a TXT record with the lowest allowable TTL
},
get: async function(args) {
// check the TXT record exists
},
remove: async function(args) {
// remove the TXT record
},
// how long to wait after *all* TXTs are set
// before presenting them for validation
// (for most this is seconds, for some it may be minutes)
propagationDelay: 5000
}
};
```
## 8. Verify Domains & Get an SSL Certificate
```js
console.info('validating domain authorization for ' + domains.join(' '));
var pems = await acme.certificates.create({
account,
accountKey,
csr,
domains,
challenges
});
```
## 9. Save the Certificate
```js
var fullchain = pems.cert + '\n' + pems.chain + '\n';
await fs.promises.writeFile('fullchain.pem', fullchain, 'ascii');
console.info('wrote ./fullchain.pem');
```
## 10. Test Drive Your Cert
```js
'use strict';
var https = require('http2');
var fs = require('fs');
var key = fs.readFileSync('./privkey.pem');
var cert = fs.readFileSync('./fullchain.pem');
var server = https.createSecureServer({ key, cert }, function(req, res) {
res.end('Hello, Encrypted World!');
});
server.listen(443, function() {
console.info('Listening on', server.address());
});
```
Note: You can allow non-root `node` processes to bind to port 443 using `setcap`:
```bash
sudo setcap 'cap_net_bind_service=+ep' $(which node)
```
You can also set your domain to localhost by editing your `/etc/hosts`:
`/etc/hosts`:
```txt
127.0.0.1 test.example.com
127.0.0.1 localhost
255.255.255.255 broadcasthost
::1 localhost
```

View File

@ -1,6 +1,13 @@
ENV=DEV ENV=DEV
MAINTAINER_EMAIL=letsencrypt+staging@example.com
SUBSCRIBER_EMAIL=letsencrypt+staging@example.com SUBSCRIBER_EMAIL=letsencrypt+staging@example.com
# for example
DOMAINS=test.example.com,www.test.example.com
# for tests
BASE_DOMAIN=test.example.com BASE_DOMAIN=test.example.com
CHALLENGE_TYPE=dns-01 CHALLENGE_TYPE=dns-01
CHALLENGE_PLUGIN=acme-dns-01-digitalocean CHALLENGE_PLUGIN=acme-dns-01-digitalocean
CHALLENGE_OPTIONS='{"token":"xxxxxxxxxxxx"}' CHALLENGE_OPTIONS='{"token":"xxxxxxxxxxxx"}'

View File

@ -0,0 +1,151 @@
async function main() {
'use strict';
require('dotenv').config();
var fs = require('fs');
// just to trigger the warning message out of the way
await fs.promises.readFile().catch(function() {});
console.warn('\n');
var MY_DOMAINS = process.env.DOMAINS.split(/[,\s]+/);
// In many cases all three of these are the same (your email)
// However, this is what they may look like when different:
var maintainerEmail = process.env.MAINTAINER_EMAIL;
var subscriberEmail = process.env.SUBSCRIBER_EMAIL;
//var customerEmail = 'jane.doe@gmail.com';
var pkg = require('../package.json');
var packageAgent = 'test-' + pkg.name + '/' + pkg.version;
// Choose either the production or staging URL
var directoryUrl = 'https://acme-staging-v02.api.letsencrypt.org/directory';
//var directoryUrl = 'https://acme-v02.api.letsencrypt.org/directory'
// This is intended to get at important messages without
// having to use even lower-level APIs in the code
var errors = [];
function notify(ev, msg) {
if ('error' === ev || 'warning' === ev) {
errors.push(ev.toUpperCase() + ' ' + msg.message);
return;
}
// ignore all for now
console.log(ev, msg.altname || '', msg.status || '');
}
var Keypairs = require('@root/keypairs');
var ACME = require('../');
var acme = ACME.create({ maintainerEmail, packageAgent, notify });
await acme.init(directoryUrl);
// You only need ONE account key, ever, in most cases
// save this and keep it safe. ECDSA is preferred.
var accountKeypair = await Keypairs.generate({ kty: 'EC', format: 'jwk' });
var accountKey = accountKeypair.private;
// This can be `true` or an async function which presents the terms of use
var agreeToTerms = true;
// If you are multi-tenanted or white-labled and need to present the terms of
// use to the Subscriber running the service, you can do so with a function.
var agreeToTerms = async function() {
return true;
};
console.info('registering new ACME account...');
var account = await acme.accounts.create({
subscriberEmail,
agreeToTerms,
accountKey
});
console.info('created account with id', account.key.kid);
// This is the key used by your WEBSERVER, typically named `privkey.pem`,
// `key.crt`, or `bundle.pem`. RSA may be preferrable for legacy compatibility.
// You can generate it fresh
var serverKeypair = await Keypairs.generate({ kty: 'RSA', format: 'jwk' });
var serverKey = serverKeypair.private;
var serverPem = await Keypairs.export({ jwk: serverKey });
await fs.promises.writeFile('./privkey.pem', serverPem, 'ascii');
console.info('wrote ./privkey.pem');
// Or you can load it from a file
var serverPem = await fs.promises.readFile('./privkey.pem', 'ascii');
var serverKey = await Keypairs.import({ pem: serverPem });
var CSR = require('@root/csr');
var PEM = require('@root/pem');
var Enc = require('@root/encoding/base64');
var encoding = 'der';
var typ = 'CERTIFICATE REQUEST';
var domains = MY_DOMAINS;
var csrDer = await CSR.csr({ jwk: serverKey, domains, encoding });
//var csr64 = Enc.bufToBase64(csrDer);
var csr = PEM.packBlock({ type: typ, bytes: csrDer });
// You can pick from existing challenge modules
// which integrate with a variety of popular services
// or you can create your own.
//
// The order of priority will be http-01, tls-alpn-01, dns-01
// dns-01 will always be used for wildcards
// dns-01 should be the only option given for local/private domains
var challenges = {
'dns-01': loadDns01()
};
console.info('validating domain authorization for ' + domains.join(' '));
var pems = await acme.certificates.create({
account,
accountKey,
csr,
domains,
challenges
});
var fullchain = pems.cert + '\n' + pems.chain + '\n';
await fs.promises.writeFile('fullchain.pem', fullchain, 'ascii');
console.info('wrote ./fullchain.pem');
if (errors.length) {
console.warn();
console.warn('[Warning]');
console.warn('The following warnings and/or errors were encountered:');
console.warn(errors.join('\n'));
}
}
main().catch(function(e) {
console.error(e.stack);
});
function loadDns01() {
var pluginName = process.env.CHALLENGE_PLUGIN;
var pluginOptions = process.env.CHALLENGE_OPTIONS;
var plugin;
if (!pluginOptions) {
console.error(
'Please create a .env in the format of examples/example.env to run the tests'
);
process.exit(1);
}
try {
plugin = require(pluginName);
} catch (err) {
console.error("Couldn't find '" + pluginName + "'. Is it installed?");
console.error("\tnpm install --save-dev '" + pluginName + "'");
process.exit(1);
}
return plugin.create(JSON.parse(pluginOptions));
}

15
examples/https-server.js Normal file
View File

@ -0,0 +1,15 @@
'use strict';
var https = require('http2');
var fs = require('fs');
var key = fs.readFileSync('./privkey.pem');
var cert = fs.readFileSync('./fullchain.pem');
var server = https
.createSecureServer({ key, cert }, function(req, res) {
res.end('Hello, Encrypted World!');
})
.listen(443, function() {
console.info('Listening on', server.address());
});

View File

@ -55,6 +55,7 @@ M._init = function(me, tz, locale) {
'https://api.rootprojects.org/api/projects/ACME.js/dependents', 'https://api.rootprojects.org/api/projects/ACME.js/dependents',
json: { json: {
maintainer: me.maintainerEmail, maintainer: me.maintainerEmail,
package: me.packageAgent,
tz: tz, tz: tz,
locale: locale locale: locale
} }

2
package-lock.json generated
View File

@ -1,6 +1,6 @@
{ {
"name": "@root/acme", "name": "@root/acme",
"version": "3.0.0", "version": "3.0.1",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@root/acme", "name": "@root/acme",
"version": "3.0.0", "version": "3.0.1",
"description": "Free SSL certificates for Node.js and Browsers. Issued via Let's Encrypt", "description": "Free SSL certificates for Node.js and Browsers. Issued via Let's Encrypt",
"homepage": "https://rootprojects.org/acme/", "homepage": "https://rootprojects.org/acme/",
"main": "acme.js", "main": "acme.js",

View File

@ -135,7 +135,7 @@ module.exports = function() {
console.info(); console.info();
} }
agreed = true; agreed = true;
return Promise.resolve(tos); return Promise.resolve(agreed);
} }
if (config.debug) { if (config.debug) {
console.info('New Subscriber Account'); console.info('New Subscriber Account');