diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1423a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +tests/*.pem diff --git a/README.md b/README.md index fd13d68..fe0ad2c 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,43 @@ -| Sponsored by [ppl](https://ppl.family) -| **acme-v2.js** ([npm](https://www.npmjs.com/package/acme-v2)) -| [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) -| - -acme-v2.js +acme.js ========== -A framework for building Let's Encrypt v2 (ACME draft 11) clients, successor to `le-acme-core.js`. -Built [by request](https://git.coolaj86.com/coolaj86/greenlock.js/issues/5#issuecomment-8). +Free SSL for everybody. The bare essentials of the Let's Encrypt v2 (ACME draft 11) API. Built for [Greenlock](https://git.coolaj86.com/coolaj86/greenlock-express.js), [by request](https://git.coolaj86.com/coolaj86/greenlock.js/issues/5#issuecomment-8). -## Looking for Quick 'n' Easy™? +!["Monthly Downloads"](https://img.shields.io/npm/dm/acme-v2.svg "Monthly Download Count can't be shown") +!["Weekly Downloads"](https://img.shields.io/npm/dw/acme-v2.svg "Weekly Download Count can't be shown") +!["Stackoverflow Questions"](https://img.shields.io/stackexchange/stackoverflow/t/greenlock.svg "S.O. Question count can't be shown") -If you're looking for an *ACME-enabled webserver*, try [goldilocks.js](https://git.coolaj86.com/coolaj86/goldilocks.js). -If you're looking to *build a webserver*, try [greenlock.js](https://git.coolaj86.com/coolaj86/greenlock.js). +| Sponsored by [ppl](https://ppl.family) +| **acme.js** ([npm](https://www.npmjs.com/package/acme)) +| [Greenlock for Web Servers](https://git.coolaj86.com/coolaj86/greenlock-cli.js) +| [Greenlock for Express.js](https://git.coolaj86.com/coolaj86/greenlock-express.js) +| [Greenlock for API Integrations](https://git.coolaj86.com/coolaj86/greenlock.js) +| [Greenlock for Web Browsers](https://git.coolaj86.com/coolaj86/greenlock.html) +| -* [greenlock.js](https://git.coolaj86.com/coolaj86/greenlock.js) +This is intented for building ACME API clients in node.js. + +Looking for Quick 'n' Easy™? +======= + +If you're looking to *build* a *browser* client, try [Greenlock Web Browsers](https://git.coolaj86.com/coolaj86/greenlock.html). +If you're looking to *build* a node.js *service* or *cli*, try [Greenlock for node.js](https://git.coolaj86.com/coolaj86/greenlock.js). +If you're looking for an *ACME-enabled webserver*, try [Greenlock for Express.js](https://git.coolaj86.com/coolaj86/greenlock-express.js) or [goldilocks.js](https://git.coolaj86.com/coolaj86/goldilocks.js). + +* [Greenlock for Web Browsers](https://git.coolaj86.com/coolaj86/greenlock.html) +* [Greenlock for node.js](https://git.coolaj86.com/coolaj86/greenlock.js) +* [Greenlock for Express.js](https://git.coolaj86.com/coolaj86/greenlock-express.js) * [goldilocks.js](https://git.coolaj86.com/coolaj86/goldilocks.js) -## How to build ACME clients +Let's Encrypt v2 / ACME draft 11 Support +======== -As this is intended to build ACME clients, there is not a simple 2-line example. +This library (acme.js) supports ACME [*draft 11*](https://tools.ietf.org/html/draft-ietf-acme-acme-11), +otherwise known as Let's Encrypt v2 (or v02). -I'd recommend first running the example CLI client with a test domain and then investigating the files used for that example: - -```bash -node examples/cli.js -``` - -The example cli has the following prompts: - -``` -What web address(es) would you like to get certificates for? (ex: example.com,*.example.com) -What challenge will you be testing today? http-01 or dns-01? [http-01] -What email should we use? (optional) -What API style would you like to test? v1-compat or promise? [v1-compat] - -Put the string 'mBfh0SqaAV3MOK3B6cAhCbIReAyDuwuxlO1Sl70x6bM.VNAzCR4THe4czVzo9piNn73B1ZXRLaB2CESwJfKkvRM' into a file at 'example.com/.well-known/acme-challenge/mBfh0SqaAV3MOK3B6cAhCbIReAyDuwuxlO1Sl70x6bM' - -echo 'mBfh0SqaAV3MOK3B6cAhCbIReAyDuwuxlO1Sl70x6bM.VNAzCR4THe4czVzo9piNn73B1ZXRLaB2CESwJfKkvRM' > 'example.com/.well-known/acme-challenge/mBfh0SqaAV3MOK3B6cAhCbIReAyDuwuxlO1Sl70x6bM' - -Then hit the 'any' key to continue... -``` - -When you've completed the challenge you can hit a key to continue the process. - -If you place the certificate you receive back in `tests/fullchain.pem` -you can then test it with `examples/https-server.js`. - -``` -examples/cli.js -examples/genkeypair.js -tests/compat.js -examples/https-server.js -examples/http-server.js -``` - -## Let's Encrypt Directory URLs + * ACME draft 11 + * Let's Encrypt v2 + * Let's Encrypt v02 ``` # Production URL @@ -69,52 +49,94 @@ https://acme-v02.api.letsencrypt.org/directory https://acme-staging-v02.api.letsencrypt.org/directory ``` -## Two API versions, Two Implementations +Demonstration +============= -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). +As this is intended to build ACME clients, there is not a simple 2-line example. - * ACME draft 11 - * Let's Encrypt v2 - * Let's Encrypt v02 +I'd recommend first trying out one of the [Greenlock for Web Servers](https://git.coolaj86.com/coolaj86/greenlock-cli.js) +examples, which are guaranteed to work and have great error checking to help you debug. -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. +Then I'd recommend running the example CLI client with a test domain and then investigating the files used for that example: - * 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 - -``` -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 -// +```bash +git clone https://git.coolaj86.com/coolaj86/acme.js.git +pushd acme.js/ +node examples/cli.js ``` -## Promise API (dev) +The example cli has the following prompts: -Status: Almost stable, but **not semver locked** +``` +What web address(es) would you like to get certificates for? (ex: example.com,*.example.com) +What challenge will you be testing today? http-01 or dns-01? [http-01] +What email should we use? (optional) +What directoryUrl should we use? [https://acme-staging-v02.api.letsencrypt.org/directory] -This API is a simple evolution of le-acme-core, +Put the string 'mBfh0SqaAV3MOK3B6cAhCbIReAyDuwuxlO1Sl70x6bM.VNAzCR4THe4czVzo9piNn73B1ZXRLaB2CESwJfKkvRM' into a file at 'example.com/.well-known/acme-challenge/mBfh0SqaAV3MOK3B6cAhCbIReAyDuwuxlO1Sl70x6bM' + +echo 'mBfh0SqaAV3MOK3B6cAhCbIReAyDuwuxlO1Sl70x6bM.VNAzCR4THe4czVzo9piNn73B1ZXRLaB2CESwJfKkvRM' > 'example.com/.well-known/acme-challenge/mBfh0SqaAV3MOK3B6cAhCbIReAyDuwuxlO1Sl70x6bM' + +Then hit the 'any' key to continue... +``` + +When you've completed the challenge you can hit a key to continue the process. + +If you place the certificate you receive back in `tests/fullchain.pem` +then you can test it with `examples/https-server.js`. + +``` +examples/cli.js +examples/genkeypair.js +examples/https-server.js +examples/http-server.js +``` + +Install +======= + +Install via npm + +```bash +npm install --save acme +``` + +Install via git + +```bash +npm install https://git.coolaj86.com/coolaj86/acme.js.git +``` + +API +=== + +This API is an evolution of le-acme-core, but tries to provide a better mapping to the new draft 11 APIs. +Status: Almost stable, but **not semver locked**. + +Patch versions will not introduce breaking changes, +but may introduce lower-level APIs. +Minor versions may change return values to include more information. + +Overview: + ``` +var ACME = require('acme').ACME; + +ACME.create(opts) + +acme.init(acmeDirectoryUrl) +acme.accounts.create(opts) +acme.certificates.create(opts) +``` + +Detailed Explanation: +``` +var ACME = require('acme').ACME; + // Create Instance (Dependency Injection) -var ACME = require('acme-v2').ACME.create({ +var acme = ACME.create({ RSA: require('rsa-compat').RSA // other overrides @@ -135,11 +157,11 @@ var ACME = require('acme-v2').ACME.create({ // Discover Directory URLs -ACME.init(acmeDirectoryUrl) // returns Promise +acme.init(acmeDirectoryUrl) // returns Promise // Accounts -ACME.accounts.create(options) // returns Promise registration data +acme.accounts.create(options) // returns Promise registration data { email: '' // valid email (server checks MX records) , accountKeypair: { // privateKeyPem or privateKeyJwt @@ -150,7 +172,7 @@ ACME.accounts.create(options) // returns Promise registrat // Registration -ACME.certificates.create(options) // returns Promise +acme.certificates.create(options) // returns Promise { newAuthzUrl: '' // specify acmeUrls.newAuthz , newCertUrl: '' // specify acmeUrls.newCert @@ -179,6 +201,9 @@ ACME.challengePrefixes['dns-01'] // '_acme-challenge' Changelog --------- +* v1.0.8 - rename to acme.js +* v1.0.7 - improved error handling again, after user testing +* v1.0.6 - improved error handling * v1.0.5 - cleanup logging * v1.0.4 - v6- compat use `promisify` from node's util or bluebird * v1.0.3 - documentation cleanup diff --git a/compat.js b/compat.js deleted file mode 100644 index aaed431..0000000 --- a/compat.js +++ /dev/null @@ -1,79 +0,0 @@ -'use strict'; - -var ACME2 = require('./').ACME; - -function resolveFn(cb) { - return function (val) { - // nextTick to get out of Promise chain - process.nextTick(function () { cb(null, val); }); - }; -} -function rejectFn(cb) { - return function (err) { - console.error('[acme-v2] handled(?) rejection as errback:'); - console.error(err.stack); - - // nextTick to get out of Promise chain - process.nextTick(function () { cb(err); }); - - // do not resolve promise further - return new Promise(function () {}); - }; -} - -function create(deps) { - deps.LeCore = {}; - var acme2 = ACME2.create(deps); - acme2.registerNewAccount = function (options, cb) { - acme2.accounts.create(options).then(resolveFn(cb), rejectFn(cb)); - }; - acme2.getCertificate = function (options, cb) { - options.agreeToTerms = options.agreeToTerms || function (tos) { - return Promise.resolve(tos); - }; - acme2.certificates.create(options).then(function (chainPem) { - var privkeyPem = acme2.RSA.exportPrivatePem(options.domainKeypair); - resolveFn(cb)({ - cert: chainPem.split(/[\r\n]{2,}/g)[0] + '\r\n' - , privkey: privkeyPem - , chain: chainPem.split(/[\r\n]{2,}/g)[1] + '\r\n' - }); - }, rejectFn(cb)); - }; - acme2.getAcmeUrls = function (options, cb) { - acme2.init(options).then(resolveFn(cb), rejectFn(cb)); - }; - acme2.getOptions = function () { - var defs = {}; - - Object.keys(module.exports.defaults).forEach(function (key) { - defs[key] = defs[deps] || module.exports.defaults[key]; - }); - - return defs; - }; - acme2.stagingServerUrl = module.exports.defaults.stagingServerUrl; - acme2.productionServerUrl = module.exports.defaults.productionServerUrl; - acme2.acmeChallengePrefix = module.exports.defaults.acmeChallengePrefix; - return acme2; -} - -module.exports.ACME = { }; -module.exports.defaults = { - productionServerUrl: 'https://acme-v02.api.letsencrypt.org/directory' -, stagingServerUrl: 'https://acme-staging-v02.api.letsencrypt.org/directory' -, knownEndpoints: [ 'keyChange', 'meta', 'newAccount', 'newNonce', 'newOrder', 'revokeCert' ] -, challengeTypes: [ 'http-01', 'dns-01' ] -, challengeType: 'http-01' -//, keyType: 'rsa' // ecdsa -//, keySize: 2048 // 256 -, rsaKeySize: 2048 // 256 -, acmeChallengePrefix: '/.well-known/acme-challenge/' -}; -Object.keys(module.exports.defaults).forEach(function (key) { - module.exports.ACME[key] = module.exports.defaults[key]; -}); -Object.keys(ACME2).forEach(function (key) { - module.exports.ACME[key] = ACME2[key]; -}); -module.exports.ACME.create = create; diff --git a/examples/cli.js b/examples/cli.js index f26354a..0dd7a15 100644 --- a/examples/cli.js +++ b/examples/cli.js @@ -1,7 +1,7 @@ 'use strict'; -var RSA = require('rsa-compat').RSA; var readline = require('readline'); +var inquisitor = {}; var rl = readline.createInterface({ input: process.stdin, output: process.stdout @@ -9,60 +9,56 @@ var rl = readline.createInterface({ require('./genkeypair.js'); -function getWeb() { +inquisitor.getWeb = function getWeb() { rl.question('What web address(es) would you like to get certificates for? (ex: example.com,*.example.com) ', function (web) { web = (web||'').trim().split(/,/g); - if (!web[0]) { getWeb(); return; } + if (!web[0]) { inquisitor.getWeb(); return; } if (web.some(function (w) { return '*' === w[0]; })) { console.log('Wildcard domains must use dns-01'); - getEmail(web, 'dns-01'); + inquisitor.getEmail(web, 'dns-01'); } else { - getChallengeType(web); + inquisitor.getChallengeType(web); } }); -} +}; -function getChallengeType(web) { +inquisitor.getChallengeType = function getChallengeType(web) { rl.question('What challenge will you be testing today? http-01 or dns-01? [http-01] ', function (chType) { chType = (chType||'').trim(); if (!chType) { chType = 'http-01'; } - getEmail(web, chType); + inquisitor.getEmail(web, chType); }); -} +}; -function getEmail(web, chType) { +inquisitor.getEmail = function getEmail(web, chType) { rl.question('What email should we use? (optional) ', function (email) { email = (email||'').trim(); if (!email) { email = null; } - getApiStyle(web, chType, email); + inquisitor.getDirectoryUrl(web, chType, email); }); -} +}; -function getApiStyle(web, chType, email) { - var defaultStyle = 'compat'; - rl.question('What API style would you like to test? v1-compat or promise? [v1-compat] ', function (apiStyle) { - apiStyle = (apiStyle||'').trim(); - if (!apiStyle) { apiStyle = 'v1-compat'; } +inquisitor.getDirectoryUrl = function getDirectoryUrl(web, chType, email) { + var defaultDirectoryUrl = 'https://acme-staging-v02.api.letsencrypt.org/directory'; + rl.question('What directoryUrl should we use? [' + defaultDirectoryUrl + '] ', function (directoryUrl) { + directoryUrl = (directoryUrl||'').trim(); + if (!directoryUrl) { directoryUrl = 'https://acme-staging-v02.api.letsencrypt.org/directory'; } - rl.close(); - - var RSA = require('rsa-compat').RSA; - var accountKeypair = RSA.import({ privateKeyPem: require('fs').readFileSync(__dirname + '/../tests/account.privkey.pem') }); - var domainKeypair = RSA.import({ privateKeyPem: require('fs').readFileSync(__dirname + '/../tests/privkey.pem') }); - var directoryUrl = 'https://acme-staging-v02.api.letsencrypt.org/directory'; - - if ('promise' === apiStyle) { - require('../tests/promise.js').run(directoryUrl, RSA, web, chType, email, accountKeypair, domainKeypair); - } else if ('cb' === apiStyle) { - require('../tests/cb.js').run(directoryUrl, RSA, web, chType, email, accountKeypair, domainKeypair); - } else { - if ('v1-compat' !== apiStyle) { console.warn("Didn't understand '" + apiStyle + "', using 'v1-compat' instead..."); } - require('../tests/compat.js').run(directoryUrl, RSA, web, chType, email, accountKeypair, domainKeypair); - } + inquisitor.run(directoryUrl, web, chType, email); }); -} +}; -getWeb(); +inquisitor.run = function run(directoryUrl, web, chType, email) { + rl.close(); + + var RSA = require('rsa-compat').RSA; + var accountKeypair = RSA.import({ privateKeyPem: require('fs').readFileSync(__dirname + '/../tests/account.privkey.pem') }); + var domainKeypair = RSA.import({ privateKeyPem: require('fs').readFileSync(__dirname + '/../tests/privkey.pem') }); + + require('../tests/promise.js').run(directoryUrl, RSA, web, chType, email, accountKeypair, domainKeypair); +}; + +inquisitor.getWeb(); diff --git a/node.js b/node.js index fa26105..b346c95 100644 --- a/node.js +++ b/node.js @@ -1,684 +1,3 @@ -/*! - * acme-v2.js - * Copyright(c) 2018 AJ ONeal https://ppl.family - * Apache-2.0 OR MIT (and hence also MPL 2.0) - */ -'use strict'; -/* globals Promise */ - -var ACME = module.exports.ACME = {}; - -ACME.challengePrefixes = { - 'http-01': '/.well-known/acme-challenge' -, 'dns-01': '_acme-challenge' -}; -ACME.challengeTests = { - 'http-01': function (me, auth) { - var url = 'http://' + auth.hostname + ACME.challengePrefixes['http-01'] + '/' + auth.token; - return me._request({ url: url }).then(function (resp) { - var err; - - if (auth.keyAuthorization === resp.body.toString('utf8').trim()) { - return true; - } - - err = new Error( - "Error: Failed HTTP-01 Dry Run.\n" - + "curl '" + url + "' does not return '" + auth.keyAuthorization + "'\n" - + "See https://git.coolaj86.com/coolaj86/acme-v2.js/issues/4" - ); - err.code = 'E_FAIL_DRY_CHALLENGE'; - return Promise.reject(err); - }); - } -, 'dns-01': function (me, auth) { - var hostname = ACME.challengePrefixes['dns-01'] + '.' + auth.hostname; - return me._dig({ - type: 'TXT' - , name: hostname - }).then(function (ans) { - var err; - - if (ans.answer.some(function (txt) { - return auth.dnsAuthorization === txt.data[0]; - })) { - return true; - } - - err = new Error( - "Error: Failed DNS-01 Dry Run.\n" - + "dig TXT '" + hostname + "' does not return '" + auth.dnsAuthorization + "'\n" - + "See https://git.coolaj86.com/coolaj86/acme-v2.js/issues/4" - ); - err.code = 'E_FAIL_DRY_CHALLENGE'; - return Promise.reject(err); - }); - } -}; - -ACME._getUserAgentString = function (deps) { - var uaDefaults = { - pkg: "Greenlock/" + deps.pkg.version - , os: "(" + deps.os.type() + "; " + deps.process.arch + " " + deps.os.platform() + " " + deps.os.release() + ")" - , node: "Node.js/" + deps.process.version - , user: '' - }; - - var userAgent = []; - - //Object.keys(currentUAProps) - Object.keys(uaDefaults).forEach(function (key) { - if (uaDefaults[key]) { - userAgent.push(uaDefaults[key]); - } - }); - - return userAgent.join(' ').trim(); -}; -ACME._directory = function (me) { - return me._request({ url: me.directoryUrl, json: true }); -}; -ACME._getNonce = function (me) { - if (me._nonce) { return new Promise(function (resolve) { resolve(me._nonce); return; }); } - return me._request({ method: 'HEAD', url: me._directoryUrls.newNonce }).then(function (resp) { - me._nonce = resp.toJSON().headers['replay-nonce']; - return me._nonce; - }); -}; -// ACME RFC Section 7.3 Account Creation -/* - { - "protected": base64url({ - "alg": "ES256", - "jwk": {...}, - "nonce": "6S8IqOGY7eL2lsGoTZYifg", - "url": "https://example.com/acme/new-account" - }), - "payload": base64url({ - "termsOfServiceAgreed": true, - "onlyReturnExisting": false, - "contact": [ - "mailto:cert-admin@example.com", - "mailto:admin@example.com" - ] - }), - "signature": "RZPOnYoPs1PhjszF...-nh6X1qtOFPB519I" - } -*/ -ACME._registerAccount = function (me, options) { - if (me.debug) console.debug('[acme-v2] accounts.create'); - - return ACME._getNonce(me).then(function () { - return new Promise(function (resolve, reject) { - - function agree(tosUrl) { - var err; - if (me._tos !== tosUrl) { - err = new Error("You must agree to the ToS at '" + me._tos + "'"); - err.code = "E_AGREE_TOS"; - reject(err); - return; - } - - var jwk = me.RSA.exportPublicJwk(options.accountKeypair); - var contact; - if (options.contact) { - contact = options.contact.slice(0); - } else if (options.email) { - contact = [ 'mailto:' + options.email ] - } - var body = { - termsOfServiceAgreed: tosUrl === me._tos - , onlyReturnExisting: false - , contact: contact - }; - if (options.externalAccount) { - body.externalAccountBinding = me.RSA.signJws( - options.externalAccount.secret - , undefined - , { alg: "HS256" - , kid: options.externalAccount.id - , url: me._directoryUrls.newAccount - } - , new Buffer(JSON.stringify(jwk)) - ); - } - var payload = JSON.stringify(body); - var jws = me.RSA.signJws( - options.accountKeypair - , undefined - , { nonce: me._nonce - , alg: 'RS256' - , url: me._directoryUrls.newAccount - , jwk: jwk - } - , new Buffer(payload) - ); - - delete jws.header; - if (me.debug) console.debug('[acme-v2] accounts.create JSON body:'); - if (me.debug) console.debug(jws); - me._nonce = null; - return me._request({ - method: 'POST' - , url: me._directoryUrls.newAccount - , headers: { 'Content-Type': 'application/jose+json' } - , json: jws - }).then(function (resp) { - var account = resp.body; - - me._nonce = resp.toJSON().headers['replay-nonce']; - var location = resp.toJSON().headers.location; - // the account id url - me._kid = location; - if (me.debug) console.debug('[DEBUG] new account location:'); - if (me.debug) console.debug(location); - if (me.debug) console.debug(resp.toJSON()); - - /* - { - id: 5925245, - key: - { kty: 'RSA', - n: 'tBr7m1hVaUNQjUeakznGidnrYyegVUQrsQjNrcipljI9Vxvxd0baHc3vvRZWFyFO5BlS7UDl-KHQdbdqb-MQzfP6T2sNXsOHARQ41pCGY5BYzIPRJF0nD48-CY717is-7BKISv8rf9yx5iSjvK1wZ3Ke3YIpxzK2fWRqccVxXQ92VYioxOfGObACgEUSvdoEttWV2B0Uv4Sdi6zZbk5eo2zALvyGb1P4fKVfQycGLXC41AyhHOAuTqzNCyIkiWEkbfh2lZNcYClP2epS0pHRFXYyjJN6-c8InfM3PISo4k6Qew65HZ-oqUow0tTIgNwuen9q5O6Hc73GvU-2npGJVQ', - e: 'AQAB' }, - contact: [], - initialIp: '198.199.82.211', - createdAt: '2018-04-16T00:41:00.720584972Z', - status: 'valid' - } - */ - if (!account) { account = { _emptyResponse: true, key: {} }; } - account.key.kid = me._kid; - return account; - }).then(resolve, reject); - } - - if (me.debug) console.debug('[acme-v2] agreeToTerms'); - if (1 === options.agreeToTerms.length) { - // newer promise API - return 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) { - if (!err) { agree(tosUrl); return; } - reject(err); - }); - } - else { - reject(new Error('agreeToTerms has incorrect function signature.' - + ' Should be fn(tos) { return Promise; }')); - } - }); - }); -}; -/* - POST /acme/new-order HTTP/1.1 - Host: example.com - Content-Type: application/jose+json - - { - "protected": base64url({ - "alg": "ES256", - "kid": "https://example.com/acme/acct/1", - "nonce": "5XJ1L3lEkMG7tR6pA00clA", - "url": "https://example.com/acme/new-order" - }), - "payload": base64url({ - "identifiers": [{"type:"dns","value":"example.com"}], - "notBefore": "2016-01-01T00:00:00Z", - "notAfter": "2016-01-08T00:00:00Z" - }), - "signature": "H6ZXtGjTZyUnPeKn...wEA4TklBdh3e454g" - } -*/ -ACME._getChallenges = function (me, options, auth) { - if (me.debug) console.debug('\n[DEBUG] getChallenges\n'); - return me._request({ method: 'GET', url: auth, json: true }).then(function (resp) { - return resp.body; - }); -}; -ACME._wait = function wait(ms) { - return new Promise(function (resolve) { - setTimeout(resolve, (ms || 1100)); - }); -}; -// https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-7.5.1 -ACME._postChallenge = function (me, options, identifier, ch) { - var count = 0; - - var thumbprint = me.RSA.thumbprint(options.accountKeypair); - var keyAuthorization = ch.token + '.' + thumbprint; - // keyAuthorization = token || '.' || base64url(JWK_Thumbprint(accountKey)) - // /.well-known/acme-challenge/:token - var auth = { - identifier: identifier - , hostname: identifier.value - , type: ch.type - , token: ch.token - , thumbprint: thumbprint - , keyAuthorization: keyAuthorization - , dnsAuthorization: me.RSA.utils.toWebsafeBase64( - require('crypto').createHash('sha256').update(keyAuthorization).digest('base64') - ) - }; - - return new Promise(function (resolve, reject) { - /* - POST /acme/authz/1234 HTTP/1.1 - Host: example.com - Content-Type: application/jose+json - - { - "protected": base64url({ - "alg": "ES256", - "kid": "https://example.com/acme/acct/1", - "nonce": "xWCM9lGbIyCgue8di6ueWQ", - "url": "https://example.com/acme/authz/1234" - }), - "payload": base64url({ - "status": "deactivated" - }), - "signature": "srX9Ji7Le9bjszhu...WTFdtujObzMtZcx4" - } - */ - function deactivate() { - var jws = me.RSA.signJws( - options.accountKeypair - , undefined - , { nonce: me._nonce, alg: 'RS256', url: ch.url, kid: me._kid } - , new Buffer(JSON.stringify({ "status": "deactivated" })) - ); - me._nonce = null; - return me._request({ - method: 'POST' - , url: ch.url - , headers: { 'Content-Type': 'application/jose+json' } - , json: jws - }).then(function (resp) { - if (me.debug) console.debug('[acme-v2.js] deactivate:'); - if (me.debug) console.debug(resp.headers); - if (me.debug) console.debug(resp.body); - if (me.debug) console.debug(); - - me._nonce = resp.toJSON().headers['replay-nonce']; - if (me.debug) console.debug('deactivate challenge: resp.body:'); - if (me.debug) console.debug(resp.body); - return ACME._wait(10 * 1000); - }); - } - - function pollStatus() { - if (count >= 5) { - return Promise.reject(new Error("[acme-v2] stuck in bad pending/processing state")); - } - - count += 1; - - if (me.debug) console.debug('\n[DEBUG] statusChallenge\n'); - return me._request({ method: 'GET', url: ch.url, json: true }).then(function (resp) { - - if ('processing' === resp.body.status) { - if (me.debug) console.debug('poll: again'); - return ACME._wait(1 * 1000).then(pollStatus); - } - - // This state should never occur - if ('pending' === resp.body.status) { - if (count >= 4) { - return ACME._wait(1 * 1000).then(deactivate).then(testChallenge); - } - if (me.debug) console.debug('poll: again'); - return ACME._wait(1 * 1000).then(testChallenge); - } - - if ('valid' === resp.body.status) { - if (me.debug) console.debug('poll: valid'); - - try { - if (1 === options.removeChallenge.length) { - options.removeChallenge(auth).then(function () {}, function () {}); - } else if (2 === options.removeChallenge.length) { - options.removeChallenge(auth, function (err) { return err; }); - } else { - options.removeChallenge(identifier.value, ch.token, function () {}); - } - } catch(e) {} - return resp.body; - } - - if (!resp.body.status) { - console.error("[acme-v2] (E_STATE_EMPTY) empty challenge state:"); - } - else if ('invalid' === resp.body.status) { - console.error("[acme-v2] (E_STATE_INVALID) invalid challenge state:"); - } - else { - console.error("[acme-v2] (E_STATE_UKN) unkown challenge state:"); - } - - return Promise.reject(new Error("[acme-v2] challenge state error")); - }); - } - - function respondToChallenge() { - var jws = me.RSA.signJws( - options.accountKeypair - , undefined - , { nonce: me._nonce, alg: 'RS256', url: ch.url, kid: me._kid } - , new Buffer(JSON.stringify({ })) - ); - me._nonce = null; - return me._request({ - method: 'POST' - , url: ch.url - , headers: { 'Content-Type': 'application/jose+json' } - , json: jws - }).then(function (resp) { - if (me.debug) console.debug('[acme-v2.js] challenge accepted!'); - if (me.debug) console.debug(resp.headers); - if (me.debug) console.debug(resp.body); - if (me.debug) console.debug(); - - me._nonce = resp.toJSON().headers['replay-nonce']; - if (me.debug) console.debug('respond to challenge: resp.body:'); - if (me.debug) console.debug(resp.body); - return ACME._wait(1 * 1000).then(pollStatus).then(resolve, reject); - }); - } - - function failChallenge(err) { - if (err) { reject(err); return; } - return testChallenge(); - } - - function testChallenge() { - // TODO put check dns / http checks here? - // http-01: GET https://example.org/.well-known/acme-challenge/{{token}} => {{keyAuth}} - // dns-01: TXT _acme-challenge.example.org. => "{{urlSafeBase64(sha256(keyAuth))}}" - - if (me.debug) {console.debug('\n[DEBUG] postChallenge\n'); } - //if (me.debug) console.debug('\n[DEBUG] stop to fix things\n'); return; - - return ACME._wait(1 * 1000).then(function () { - if (!me.skipChallengeTest) { - return ACME.challengeTests[ch.type](me, auth); - } - }).then(respondToChallenge); - } - - try { - if (1 === options.setChallenge.length) { - options.setChallenge(auth).then(testChallenge, reject); - } else if (2 === options.setChallenge.length) { - options.setChallenge(auth, failChallenge); - } else { - options.setChallenge(identifier.value, ch.token, keyAuthorization, failChallenge); - } - } catch(e) { - reject(e); - } - }); -}; -ACME._finalizeOrder = function (me, options, validatedDomains) { - if (me.debug) console.debug('finalizeOrder:'); - var csr = me.RSA.generateCsrWeb64(options.domainKeypair, validatedDomains); - var body = { csr: csr }; - var payload = JSON.stringify(body); - - function pollCert() { - var jws = me.RSA.signJws( - options.accountKeypair - , undefined - , { nonce: me._nonce, alg: 'RS256', url: me._finalize, kid: me._kid } - , new Buffer(payload) - ); - - if (me.debug) console.debug('finalize:', me._finalize); - me._nonce = null; - return me._request({ - method: 'POST' - , url: me._finalize - , headers: { 'Content-Type': 'application/jose+json' } - , json: jws - }).then(function (resp) { - // https://tools.ietf.org/html/draft-ietf-acme-acme-12#section-7.1.3 - // Possible values are: "pending" => ("invalid" || "ready") => "processing" => "valid" - me._nonce = resp.toJSON().headers['replay-nonce']; - - if (me.debug) console.debug('order finalized: resp.body:'); - if (me.debug) console.debug(resp.body); - - if ('valid' === resp.body.status) { - me._expires = resp.body.expires; - me._certificate = resp.body.certificate; - - return resp.body; - } - - if ('processing' === resp.body.status) { - return ACME._wait().then(pollCert); - } - - if (me.debug) console.debug("Error: bad status:\n" + JSON.stringify(resp.body, null, 2)); - - if ('pending' === resp.body.status) { - return Promise.reject(new Error( - "Did not finalize order: status 'pending'." - + " Best guess: You have not accepted at least one challenge for each domain." + "\n\n" - + JSON.stringify(resp.body, null, 2) - )); - } - - if ('invalid' === resp.body.status) { - return Promise.reject(new Error( - "Did not finalize order: status 'invalid'." - + " Best guess: One or more of the domain challenges could not be verified" - + " (or the order was canceled)." + "\n\n" - + JSON.stringify(resp.body, null, 2) - )); - } - - if ('ready' === resp.body.status) { - return Promise.reject(new Error( - "Did not finalize order: status 'ready'." - + " Hmmm... this state shouldn't be possible here. That was the last state." - + " This one should at least be 'processing'." + "\n\n" - + JSON.stringify(resp.body, null, 2) + "\n\n" - + "Please open an issue at https://git.coolaj86.com/coolaj86/acme-v2.js" - )); - } - - return Promise.reject(new Error( - "Didn't finalize order: Unhandled status '" + resp.body.status + "'." - + " This is not one of the known statuses...\n\n" - + JSON.stringify(resp.body, null, 2) + "\n\n" - + "Please open an issue at https://git.coolaj86.com/coolaj86/acme-v2.js" - )); - }); - } - - return pollCert(); -}; -ACME._getCertificate = function (me, options) { - if (me.debug) console.debug('[acme-v2] DEBUG get cert 1'); - - if (!options.challengeTypes) { - if (!options.challengeType) { - return Promise.reject(new Error("challenge type must be specified")); - } - options.challengeTypes = [ options.challengeType ]; - } - - if (!me._kid) { - if (options.accountKid) { - me._kid = options.accountKid; - } else { - //return Promise.reject(new Error("must include KeyID")); - return ACME._registerAccount(me, options).then(function () { - return ACME._getCertificate(me, options); - }); - } - } - - if (me.debug) console.debug('[acme-v2] certificates.create'); - return ACME._getNonce(me).then(function () { - var body = { - identifiers: options.domains.map(function (hostname) { - return { type: "dns" , value: hostname }; - }) - //, "notBefore": "2016-01-01T00:00:00Z" - //, "notAfter": "2016-01-08T00:00:00Z" - }; - - var payload = JSON.stringify(body); - var jws = me.RSA.signJws( - options.accountKeypair - , undefined - , { nonce: me._nonce, alg: 'RS256', url: me._directoryUrls.newOrder, kid: me._kid } - , new Buffer(payload) - ); - - if (me.debug) console.debug('\n[DEBUG] newOrder\n'); - me._nonce = null; - return me._request({ - method: 'POST' - , url: me._directoryUrls.newOrder - , headers: { 'Content-Type': 'application/jose+json' } - , json: jws - }).then(function (resp) { - me._nonce = resp.toJSON().headers['replay-nonce']; - var location = resp.toJSON().headers.location; - var auths; - if (me.debug) console.debug(location); // the account id url - if (me.debug) console.debug(resp.toJSON()); - me._authorizations = resp.body.authorizations; - me._order = location; - me._finalize = resp.body.finalize; - //if (me.debug) console.debug('[DEBUG] finalize:', me._finalize); return; - - if (!me._authorizations) { - console.error("[acme-v2.js] authorizations were not fetched:"); - console.error(resp.body); - return Promise.reject(new Error("authorizations were not fetched")); - } - if (me.debug) console.debug("47 &#&#&#&#&#&#&&##&#&#&#&#&#&#&#&"); - - //return resp.body; - auths = me._authorizations.slice(0); - - function next() { - var authUrl = auths.shift(); - if (!authUrl) { return; } - - return ACME._getChallenges(me, options, authUrl).then(function (results) { - // var domain = options.domains[i]; // results.identifier.value - var chType = options.challengeTypes.filter(function (chType) { - return results.challenges.some(function (ch) { - return ch.type === chType; - }); - })[0]; - - var challenge = results.challenges.filter(function (ch) { - if (chType === ch.type) { - return ch; - } - })[0]; - - if (!challenge) { - return Promise.reject(new Error("Server didn't offer any challenge we can handle.")); - } - - return ACME._postChallenge(me, options, results.identifier, challenge); - }).then(function () { - return next(); - }); - } - - return next().then(function () { - if (me.debug) console.debug("37 &#&#&#&#&#&#&&##&#&#&#&#&#&#&#&"); - var validatedDomains = body.identifiers.map(function (ident) { - return ident.value; - }); - - return ACME._finalizeOrder(me, options, validatedDomains); - }).then(function () { - if (me.debug) console.debug('acme-v2: order was finalized'); - return me._request({ method: 'GET', url: me._certificate, json: true }).then(function (resp) { - if (me.debug) console.debug('acme-v2: csr submitted and cert received:'); - if (me.debug) console.debug(resp.body); - return resp.body; - }); - }); - }); - }); -}; - -ACME.create = function create(me) { - if (!me) { me = {}; } - // me.debug = true; - me.challengePrefixes = ACME.challengePrefixes; - me.RSA = me.RSA || require('rsa-compat').RSA; - me.request = me.request || require('request'); - me._dig = function (query) { - // TODO use digd.js - return new Promise(function (resolve, reject) { - var dns = require('dns'); - dns.resolveTxt(query.name, function (err, records) { - if (err) { reject(err); return; } - - resolve({ - answer: records.map(function (rr) { - return { - data: rr - }; - }) - }); - }); - }); - }; - me.promisify = me.promisify || require('util').promisify /*node v8+*/ || require('bluebird').promisify /*node v6*/; - - - if ('function' !== typeof me.getUserAgentString) { - me.pkg = me.pkg || require('./package.json'); - me.os = me.os || require('os'); - me.process = me.process || require('process'); - me.userAgent = ACME._getUserAgentString(me); - } - - function getRequest(opts) { - if (!opts) { opts = {}; } - - return me.request.defaults({ - headers: { - 'User-Agent': opts.userAgent || me.userAgent || me.getUserAgentString(me) - } - }); - } - - if ('function' !== typeof me._request) { - me._request = me.promisify(getRequest({})); - } - - me.init = function (_directoryUrl) { - me.directoryUrl = me.directoryUrl || _directoryUrl; - return ACME._directory(me).then(function (resp) { - me._directoryUrls = resp.body; - me._tos = me._directoryUrls.meta.termsOfService; - return me._directoryUrls; - }); - }; - me.accounts = { - create: function (options) { - return ACME._registerAccount(me, options); - } - }; - me.certificates = { - create: function (options) { - return ACME._getCertificate(me, options); - } - }; - return me; -}; +// For the time being I'm still pulling in my acme-v2 module until I transition over +// I export as ".ACME" rather than bare so that this can be compatible with the browser version too +module.exports.ACME = require('acme-v2').ACME; diff --git a/package.json b/package.json index 914d837..73955b4 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,15 @@ { - "name": "acme-v2", - "version": "1.0.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", - "homepage": "https://git.coolaj86.com/coolaj86/acme-v2.js", + "name": "acme", + "version": "1.0.0", + "description": "Free SSL for everybody. The bare essentials of the Let's Encrypt v2 (ACME draft 11) API. Built for Greenlock.", + "homepage": "https://git.coolaj86.com/coolaj86/acme.js", "main": "node.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "repository": { "type": "git", - "url": "ssh://gitea@git.coolaj86.com:22042/coolaj86/acme-v2.js.git" + "url": "ssh://gitea@git.coolaj86.com:22042/coolaj86/acme.js.git" }, "keywords": [ "acmev2", @@ -39,6 +39,7 @@ "author": "AJ ONeal (https://coolaj86.com/)", "license": "(MIT OR Apache-2.0)", "dependencies": { + "acme-v2": "^1.0.7", "request": "^2.85.0", "rsa-compat": "^1.3.0" }, diff --git a/tests/cb.js b/tests/cb.js deleted file mode 100644 index 550c285..0000000 --- a/tests/cb.js +++ /dev/null @@ -1,79 +0,0 @@ -'use strict'; - -module.exports.run = function run(directoryUrl, RSA, web, chType, email, accountKeypair, domainKeypair) { - // [ 'test.ppl.family' ] 'coolaj86@gmail.com''http-01' - var acme2 = require('../').ACME.create({ RSA: RSA }); - acme2.init(directoryUrl).then(function () { - var options = { - agreeToTerms: function (tosUrl, agree) { - agree(null, tosUrl); - } - , setChallenge: function (opts, cb) { - var pathname; - - console.log(""); - console.log('identifier:'); - console.log(opts.identifier); - console.log('hostname:'); - console.log(opts.hostname); - console.log('type:'); - console.log(opts.type); - console.log('token:'); - console.log(opts.token); - console.log('thumbprint:'); - console.log(opts.thumbprint); - console.log('keyAuthorization:'); - console.log(opts.keyAuthorization); - console.log('dnsAuthorization:'); - console.log(opts.dnsAuthorization); - console.log(""); - - if ('http-01' === opts.type) { - pathname = opts.hostname + acme2.challengePrefixes['http-01'] + "/" + opts.token; - console.log("Put the string '" + opts.keyAuthorization + "' into a file at '" + pathname + "'"); - console.log("echo '" + opts.keyAuthorization + "' > '" + pathname + "'"); - } else if ('dns-01' === opts.type) { - pathname = acme2.challengePrefixes['dns-01'] + "." + opts.hostname.replace(/^\*\./, ''); - console.log("Put the string '" + opts.dnsAuthorization + "' into the TXT record '" + pathname + "'"); - console.log("ddig TXT " + pathname + " '" + opts.dnsAuthorization + "'"); - } else { - cb(new Error("[acme-v2] unrecognized challenge type")); - return; - } - console.log("\nThen hit the 'any' key to continue..."); - - function onAny() { - console.log("'any' key was hit"); - process.stdin.pause(); - process.stdin.removeListener('data', onAny); - process.stdin.setRawMode(false); - cb(); - } - - process.stdin.setRawMode(true); - process.stdin.resume(); - process.stdin.on('data', onAny); - } - , removeChallenge: function (opts, cb) { - // hostname, key - console.log('[acme-v2] remove challenge', opts.hostname, opts.keyAuthorization); - setTimeout(cb, 1 * 1000); - } - , challengeType: chType - , email: email - , accountKeypair: accountKeypair - , domainKeypair: domainKeypair - , domains: web - }; - - acme2.accounts.create(options).then(function (account) { - console.log('[acme-v2] account:'); - console.log(account); - - acme2.certificates.create(options).then(function (fullchainPem) { - console.log('[acme-v2] fullchain.pem:'); - console.log(fullchainPem); - }); - }); - }); -}; diff --git a/tests/compat.js b/tests/compat.js deleted file mode 100644 index d0a66b1..0000000 --- a/tests/compat.js +++ /dev/null @@ -1,55 +0,0 @@ -'use strict'; - -module.exports.run = function (directoryUrl, RSA, web, chType, email, accountKeypair, domainKeypair) { - console.log('[DEBUG] run', web, chType, email); - - var acme2 = require('../compat.js').ACME.create({ RSA: RSA }); - acme2.getAcmeUrls(acme2.stagingServerUrl, function (err/*, directoryUrls*/) { - if (err) { console.log('err 1'); throw err; } - - var options = { - agreeToTerms: function (tosUrl, agree) { - agree(null, tosUrl); - } - , setChallenge: function (hostname, token, val, cb) { - var pathname = hostname + acme2.acmeChallengePrefix + token; - console.log("Put the string '" + val + "' into a file at '" + pathname + "'"); - console.log("echo '" + val + "' > '" + pathname + "'"); - console.log("\nThen hit the 'any' key to continue..."); - - function onAny() { - console.log("'any' key was hit"); - process.stdin.pause(); - process.stdin.removeListener('data', onAny); - process.stdin.setRawMode(false); - cb(); - } - - process.stdin.setRawMode(true); - process.stdin.resume(); - process.stdin.on('data', onAny); - } - , removeChallenge: function (hostname, key, cb) { - console.log('[DEBUG] remove challenge', hostname, key); - setTimeout(cb, 1 * 1000); - } - , challengeType: chType - , email: email - , accountKeypair: accountKeypair - , domainKeypair: domainKeypair - , domains: web - }; - - acme2.registerNewAccount(options, function (err, account) { - if (err) { console.log('err 2'); throw err; } - if (options.debug) console.debug('account:'); - if (options.debug) console.log(account); - - acme2.getCertificate(options, function (err, fullchainPem) { - if (err) { console.log('err 3'); throw err; } - console.log('[acme-v2] A fullchain.pem:'); - console.log(fullchainPem); - }); - }); - }); -};