diff --git a/README.md b/README.md index 23f7515..1deed68 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# [ACME.js](https://git.rootprojects.org/root/acme.js) v3 +# [ACME.js](https://git.rootprojects.org/root/acme.js) (RFC 8555 / November 2019) | Built by [Root](https://therootcompany.com) for [Greenlock](https://greenlock.domains) @@ -52,6 +52,31 @@ 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. However, in keeping to our values we've made the source visible for others to inspect, improve, and modify. +# API Overview + +```js +ACME.create({ maintainerEmail, packageAgent }); +acme.init(directoryUrl); +acme.accounts.create({ subscriberEmail, agreeToTerms, accountKey }); +acme.certificates.create({ + customerEmail, // do not use + account, + accountKey, + serverKey, + csr, + domains, + challenges +}); +``` + +```js +ACME.computeChallenge({ + accountKey: jwk, + hostname: 'example.com', + challenge: { type: 'dns-01', token: 'xxxx' } +}); +``` + # Install To make it easy to generate, encode, and decode keys and certificates, @@ -234,9 +259,6 @@ is a required part of the process, which requires `set` and `remove` callbacks/p ```js var certinfo = await acme.certificates.create({ - agreeToTerms: function(tos) { - return tos; - }, account: account, accountKey: accountPrivateJwk, csr: csr, diff --git a/lib/browser.js b/lib/browser.js index 669c4fc..3feae1b 100644 --- a/lib/browser.js +++ b/lib/browser.js @@ -18,33 +18,37 @@ native._canCheck = function(me) { }; native._dns01 = function(me, ch) { - return new me.request({ - url: me._baseUrl + '/api/dns/' + ch.dnsHost + '?type=TXT' - }).then(function(resp) { - var err; - if (!resp.body || !Array.isArray(resp.body.answer)) { - err = new Error('failed to get DNS response'); - console.error(err); - throw err; - } - if (!resp.body.answer.length) { - err = new Error('failed to get DNS answer record in response'); - console.error(err); - throw err; - } - return { - answer: resp.body.answer.map(function(ans) { - return { data: ans.data, ttl: ans.ttl }; - }) - }; - }); + return me + .request({ + url: me._baseUrl + '/api/dns/' + ch.dnsHost + '?type=TXT' + }) + .then(function(resp) { + var err; + if (!resp.body || !Array.isArray(resp.body.answer)) { + err = new Error('failed to get DNS response'); + console.error(err); + throw err; + } + if (!resp.body.answer.length) { + err = new Error('failed to get DNS answer record in response'); + console.error(err); + throw err; + } + return { + answer: resp.body.answer.map(function(ans) { + return { data: ans.data, ttl: ans.ttl }; + }) + }; + }); }; native._http01 = function(me, ch) { var url = encodeURIComponent(ch.challengeUrl); - return new me.request({ - url: me._baseUrl + '/api/http?url=' + url - }).then(function(resp) { - return resp.body; - }); + return me + .request({ + url: me._baseUrl + '/api/http?url=' + url + }) + .then(function(resp) { + return resp.body; + }); }; diff --git a/lib/browser/client-user-agent.js b/lib/browser/client-user-agent.js index f64eb37..d129b73 100644 --- a/lib/browser/client-user-agent.js +++ b/lib/browser/client-user-agent.js @@ -1,6 +1,6 @@ 'use strict'; var UserAgent = module.exports; -UserAgent.get = function () { - return false; +UserAgent.get = function() { + return false; }; diff --git a/lib/native.js b/lib/native.js index 88c90ef..9f548bc 100644 --- a/lib/native.js +++ b/lib/native.js @@ -3,6 +3,7 @@ var native = module.exports; var promisify = require('util').promisify; var resolveTxt = promisify(require('dns').resolveTxt); +var crypto = require('crypto'); native._canCheck = function(me) { me._canCheck = {}; @@ -31,3 +32,57 @@ native._http01 = function(me, ch) { return resp.body; }); }; + +// the hashcash here is for browser parity only +// basically we ask the client to find a needle in a haystack +// (very similar to CloudFlare's api protection) +native._hashcash = function(ch) { + if (!ch || !ch.nonce) { + ch = { nonce: 'xxx' }; + } + return Promise.resolve() + .then(function() { + // only get easy answers + var len = ch.needle.length; + var start = ch.start || 0; + var end = ch.end || Math.ceil(len / 2); + var window = parseInt(end - start, 10) || 0; + + var maxLen = 6; + var maxTries = Math.pow(2, maxLen * 8); + if ( + len > maxLen || + window < Math.ceil(len / 2) || + ch.needle.toLowerCase() !== ch.needle || + ch.alg !== 'SHA-256' + ) { + // bail unless the server is issuing very easy challenges + throw new Error('possible and easy answers only, please'); + } + + var haystack; + var i; + var answer; + var needle = Buffer.from(ch.needle, 'hex'); + for (i = 0; i < maxTries; i += 1) { + answer = i.toString(16); + if (answer.length % 2) { + answer = '0' + answer; + } + haystack = crypto + .createHash('sha256') + .update(Buffer.from(ch.nonce + answer, 'hex')) + .digest() + .slice(ch.start, ch.end); + if (-1 !== haystack.indexOf(needle)) { + return ch.nonce + ':' + answer; + } + } + return ch.nonce + ':xxx'; + }) + .catch(function() { + //console.log('[debug]', err); + // ignore any error + return ch.nonce + ':xxx'; + }); +}; diff --git a/lib/node/client-user-agent.js b/lib/node/client-user-agent.js index b3ec96a..01cb4e9 100644 --- a/lib/node/client-user-agent.js +++ b/lib/node/client-user-agent.js @@ -1,7 +1,7 @@ 'use strict'; var os = require('os'); -var ver = require('../../package.json'); +var ver = require('../../package.json').version; var UserAgent = module.exports; UserAgent.get = function(me) { diff --git a/lib/node/http.js b/lib/node/http.js index 61ee480..8c9e73b 100644 --- a/lib/node/http.js +++ b/lib/node/http.js @@ -5,15 +5,5 @@ var promisify = require('util').promisify; var request = promisify(require('@root/request')); http.request = function(opts) { - if (!opts.headers) { - opts.headers = {}; - } - if ( - !Object.keys(opts.headers).some(function(key) { - return 'user-agent' === key.toLowerCase(); - }) - ) { - // TODO opts.headers['User-Agent'] = 'TODO'; - } return request(opts); }; diff --git a/tests/issue-certificates.js b/tests/issue-certificates.js index 8a63a58..c77ae57 100644 --- a/tests/issue-certificates.js +++ b/tests/issue-certificates.js @@ -2,12 +2,14 @@ require('dotenv').config(); +var pkg = require('../package.json'); var CSR = require('@root/csr'); var Enc = require('@root/encoding/base64'); var PEM = require('@root/pem'); var punycode = require('punycode'); var ACME = require('../acme.js'); var Keypairs = require('@root/keypairs'); +var ecJwk = require('../fixtures/account.jwk.json'); // TODO exec npm install --save-dev CHALLENGE_MODULE if (!process.env.CHALLENGE_OPTIONS) { @@ -36,6 +38,7 @@ module.exports = function() { var acme = ACME.create({ // debug: true maintainerEmail: config.email, + packageAgent: 'test-' + pkg.name + '/' + pkg.version, notify: function(ev, params) { console.info( '\t' + ev, @@ -104,6 +107,10 @@ module.exports = function() { } var accountKeypair = await Keypairs.generate({ kty: accKty }); + if (/EC/i.test(accKty)) { + // to test that an existing account gets back data + accountKeypair = ecJwk; + } var accountKey = accountKeypair.private; if (config.debug) { console.info('Account Key Created'); diff --git a/tests/maintainer.js b/tests/maintainer.js new file mode 100644 index 0000000..7ef1cdb --- /dev/null +++ b/tests/maintainer.js @@ -0,0 +1,74 @@ +'use strict'; + +var native = require('../lib/native.js'); +var crypto = require('crypto'); + +native + ._hashcash({ + alg: 'SHA-256', + nonce: '00', + needle: '0000', + start: 0, + end: 2 + }) + .then(function(hashcash) { + if ('00:76de' !== hashcash) { + throw new Error('hashcash algorthim changed'); + } + console.info('PASS: known hash solves correctly'); + + return native + ._hashcash({ + alg: 'SHA-256', + nonce: '10', + needle: '', + start: 0, + end: 2 + }) + .then(function(hashcash) { + if ('10:00' !== hashcash) { + throw new Error('hashcash algorthim changed'); + } + console.info('PASS: empty hash solves correctly'); + + var now = Date.now(); + var nonce = '20'; + var needle = crypto + .randomBytes(3) + .toString('hex') + .slice(0, 5); + native + ._hashcash({ + alg: 'SHA-256', + nonce: nonce, + needle: needle, + start: 0, + end: Math.ceil(needle.length / 2) + }) + .then(function(hashcash) { + var later = Date.now(); + var parts = hashcash.split(':'); + var answer = parts[1]; + if (parts[0] !== nonce) { + throw new Error('incorrect nonce'); + } + var haystack = crypto + .createHash('sha256') + .update(Buffer.from(nonce + answer, 'hex')) + .digest() + .slice(0, Math.ceil(needle.length / 2)); + if ( + -1 === haystack.indexOf(Buffer.from(needle, 'hex')) + ) { + throw new Error('incorrect solution'); + } + if (later - now > 2000) { + throw new Error('took too long to solve'); + } + console.info( + 'PASS: rando hash solves correctly (and in good time - %dms)', + later - now + ); + }); + }); + }); diff --git a/utils.js b/utils.js index aa2da3d..9e1595e 100644 --- a/utils.js +++ b/utils.js @@ -82,9 +82,14 @@ U._request = function(me, opts) { if (ua && !opts.headers['User-Agent']) { opts.headers['User-Agent'] = ua; } - if (opts.json && true !== opts.json) { - opts.headers['Content-Type'] = 'application/jose+json'; - opts.body = JSON.stringify(opts.json); + if (opts.json) { + opts.headers.Accept = 'application/json'; + if (true !== opts.json) { + opts.body = JSON.stringify(opts.json); + } + if (/*opts.jose ||*/ opts.json.protected) { + opts.headers['Content-Type'] = 'application/jose+json'; + } } if (!opts.method) { opts.method = 'GET'; @@ -92,16 +97,10 @@ U._request = function(me, opts) { opts.method = 'POST'; } } - if (opts.json) { - opts.headers.Accept = 'application/json'; - if (true !== opts.json) { - opts.body = JSON.stringify(opts.json); - } - } //console.log('\n[debug] REQUEST'); //console.log(opts); - return me.request(opts).then(function(resp) { + return me.__request(opts).then(function(resp) { if (resp.toJSON) { resp = resp.toJSON(); }