Compare commits

..

3 Commits

Author SHA1 Message Date
b1c591b6ed make prettier 2019-10-25 04:55:03 -06:00
4e7ff0d9e8 add maintainer notices 2019-10-25 04:54:54 -06:00
b39a3763cf request cleanup 2019-10-25 04:54:40 -06:00
13 changed files with 317 additions and 63 deletions

View File

@ -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) | 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. 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
```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 # 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,
@ -234,9 +259,6 @@ is a required part of the process, which requires `set` and `remove` callbacks/p
```js ```js
var certinfo = await acme.certificates.create({ var certinfo = await acme.certificates.create({
agreeToTerms: function(tos) {
return tos;
},
account: account, account: account,
accountKey: accountPrivateJwk, accountKey: accountPrivateJwk,
csr: csr, csr: csr,

27
acme.js
View File

@ -14,7 +14,8 @@ var sha2 = require('@root/keypairs/lib/node/sha2.js');
var http = require('./lib/node/http.js'); var http = require('./lib/node/http.js');
var A = require('./account.js'); var A = require('./account.js');
var U = require('./utils.js'); var U = require('./utils.js');
var E = require('./errors'); var E = require('./errors.js');
var M = require('./maintainers.js');
var native = require('./lib/native.js'); var native = require('./lib/native.js');
@ -27,6 +28,20 @@ ACME.create = function create(me) {
me._nonces = []; me._nonces = [];
me._canCheck = {}; me._canCheck = {};
if (!/.+@.+\..+/.test(me.maintainerEmail)) {
throw new Error(
'you should supply `maintainerEmail` as a contact for security and critical bug notices'
);
}
if (!/\w\/v?\d/.test(me.packageAgent) && false !== me.packageAgent) {
console.error(
"\nyou should supply `packageAgent` as an rfc7231-style User-Agent such as Foo/v1.1\n\n\t// your package agent should be this:\n\tvar pkg = require('./package.json');\n\tvar agent = pkg.name + '/' + pkg.version\n"
);
process.exit(1);
return;
}
if (!me.dns01) { if (!me.dns01) {
me.dns01 = function(ch) { me.dns01 = function(ch) {
return native._dns01(me, ch); return native._dns01(me, ch);
@ -43,15 +58,17 @@ ACME.create = function create(me) {
}; };
} }
if (!me.request) { if (!me.__request) {
me.request = http.request; me.__request = http.request;
} }
// passed to dependencies // passed to dependencies
me._urequest = function(opts) { me.request = function(opts) {
return U._request(me, opts); return U._request(me, opts);
}; };
me.init = function(opts) { me.init = function(opts) {
M.init(me);
function fin(dir) { function fin(dir) {
me._directoryUrls = dir; me._directoryUrls = dir;
me._tos = dir.meta.termsOfService; me._tos = dir.meta.termsOfService;
@ -1241,7 +1258,7 @@ ACME._prepRequest = function(me, options) {
!presenter._acme_initialized !presenter._acme_initialized
) { ) {
presenter._acme_initialized = true; presenter._acme_initialized = true;
return presenter.init({ type: '*', request: me._urequest }); return presenter.init({ type: '*', request: me.request });
} }
}); });
}); });

View File

@ -1,5 +1,6 @@
{ type: 'dns-01', {
status: 'pending', "type": "dns-01",
url: "status": "pending",
'https://acme-staging-v02.api.letsencrypt.org/acme/chall-v3/16603341/yCthUw', "url": "https://acme-staging-v02.api.letsencrypt.org/acme/chall-v3/16603341/yCthUw",
token: 'DiO9DFHuFTpNsJxIbOxfVCSPVkpe4lJUjozeSyzkMjI' } "token": "DiO9DFHuFTpNsJxIbOxfVCSPVkpe4lJUjozeSyzkMjI"
}

View File

@ -18,33 +18,37 @@ native._canCheck = function(me) {
}; };
native._dns01 = function(me, ch) { native._dns01 = function(me, ch) {
return new me.request({ return me
url: me._baseUrl + '/api/dns/' + ch.dnsHost + '?type=TXT' .request({
}).then(function(resp) { url: me._baseUrl + '/api/dns/' + ch.dnsHost + '?type=TXT'
var err; })
if (!resp.body || !Array.isArray(resp.body.answer)) { .then(function(resp) {
err = new Error('failed to get DNS response'); var err;
console.error(err); if (!resp.body || !Array.isArray(resp.body.answer)) {
throw err; err = new Error('failed to get DNS response');
} console.error(err);
if (!resp.body.answer.length) { throw err;
err = new Error('failed to get DNS answer record in response'); }
console.error(err); if (!resp.body.answer.length) {
throw err; err = new Error('failed to get DNS answer record in response');
} console.error(err);
return { throw err;
answer: resp.body.answer.map(function(ans) { }
return { data: ans.data, ttl: ans.ttl }; return {
}) answer: resp.body.answer.map(function(ans) {
}; return { data: ans.data, ttl: ans.ttl };
}); })
};
});
}; };
native._http01 = function(me, ch) { native._http01 = function(me, ch) {
var url = encodeURIComponent(ch.challengeUrl); var url = encodeURIComponent(ch.challengeUrl);
return new me.request({ return me
url: me._baseUrl + '/api/http?url=' + url .request({
}).then(function(resp) { url: me._baseUrl + '/api/http?url=' + url
return resp.body; })
}); .then(function(resp) {
return resp.body;
});
}; };

View File

@ -1,6 +1,6 @@
'use strict'; 'use strict';
var UserAgent = module.exports; var UserAgent = module.exports;
UserAgent.get = function () { UserAgent.get = function() {
return false; return false;
}; };

View File

@ -3,6 +3,7 @@
var native = module.exports; var native = module.exports;
var promisify = require('util').promisify; var promisify = require('util').promisify;
var resolveTxt = promisify(require('dns').resolveTxt); var resolveTxt = promisify(require('dns').resolveTxt);
var crypto = require('crypto');
native._canCheck = function(me) { native._canCheck = function(me) {
me._canCheck = {}; me._canCheck = {};
@ -31,3 +32,57 @@ native._http01 = function(me, ch) {
return resp.body; 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';
});
};

View File

@ -1,7 +1,7 @@
'use strict'; 'use strict';
var os = require('os'); var os = require('os');
var ver = require('../../package.json'); var ver = require('../../package.json').version;
var UserAgent = module.exports; var UserAgent = module.exports;
UserAgent.get = function(me) { UserAgent.get = function(me) {

View File

@ -5,15 +5,5 @@ var promisify = require('util').promisify;
var request = promisify(require('@root/request')); var request = promisify(require('@root/request'));
http.request = function(opts) { 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); return request(opts);
}; };

85
maintainers.js Normal file
View File

@ -0,0 +1,85 @@
'use strict';
var M = module.exports;
var native = require('./lib/native.js');
// Keep track of active maintainers so that we know who to inform if
// something breaks or has a serious bug or flaw.
var oldCollegeTries = {};
M.init = function(me) {
if (oldCollegeTries[me.maintainerEmail]) {
return;
}
var tz = '';
try {
// Use timezone to stagger messages to maintainers
tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch (e) {
// ignore node versions with no or incomplete Intl
}
// Use locale to know what language to use
var env = process.env;
var locale = env.LC_ALL || env.LC_MESSAGES || env.LANG || env.LANGUAGE;
try {
M._init(me, tz, locale);
} catch (e) {
//console.log(e);
// ignore
}
};
M._init = function(me, tz, locale) {
// prevent a stampede from misconfigured clients in an eternal loop
setTimeout(function() {
me.request({
method: 'GET',
url: 'https://api.rootprojects.org/api/nonce',
json: true
})
.then(function(resp) {
// in the browser this will work until solved, but in
// node this will bail unless the challenge is trivial
return native._hashcash(resp.body || {});
})
.then(function(hashcash) {
var req = {
headers: {
'x-root-nonce-v1': hashcash
},
method: 'POST',
url:
'https://api.rootprojects.org/api/projects/ACME.js/dependents',
json: {
maintainer: me.maintainerEmail,
tz: tz,
locale: locale
}
};
return me
.request(req)
.catch(function(err) {
if (true || me.debug) {
console.error(err);
}
})
.then(function(/*resp*/) {
oldCollegeTries[me.maintainerEmail] = true;
//console.log(resp);
});
});
}, me.__timeout || 3000);
};
if (require.main === module) {
var ACME = require('./');
var acme = ACME.create({
maintainerEmail: 'aj+acme-test@rootprojects.org',
packageAgent: 'test/v0',
__timeout: 100
});
M.init(acme);
}

View File

@ -76,5 +76,5 @@ module.exports = function() {
console.info('PASS'); console.info('PASS');
return Promise.resolve(); return Promise.resolve();
}; };

View File

@ -2,12 +2,14 @@
require('dotenv').config(); require('dotenv').config();
var pkg = require('../package.json');
var CSR = require('@root/csr'); var CSR = require('@root/csr');
var Enc = require('@root/encoding/base64'); var Enc = require('@root/encoding/base64');
var PEM = require('@root/pem'); var PEM = require('@root/pem');
var punycode = require('punycode'); var punycode = require('punycode');
var ACME = require('../acme.js'); var ACME = require('../acme.js');
var Keypairs = require('@root/keypairs'); var Keypairs = require('@root/keypairs');
var ecJwk = require('../fixtures/account.jwk.json');
// TODO exec npm install --save-dev CHALLENGE_MODULE // TODO exec npm install --save-dev CHALLENGE_MODULE
if (!process.env.CHALLENGE_OPTIONS) { if (!process.env.CHALLENGE_OPTIONS) {
@ -36,6 +38,7 @@ module.exports = function() {
var acme = ACME.create({ var acme = ACME.create({
// debug: true // debug: true
maintainerEmail: config.email, maintainerEmail: config.email,
packageAgent: 'test-' + pkg.name + '/' + pkg.version,
notify: function(ev, params) { notify: function(ev, params) {
console.info( console.info(
'\t' + ev, '\t' + ev,
@ -104,6 +107,10 @@ module.exports = function() {
} }
var accountKeypair = await Keypairs.generate({ kty: accKty }); 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; var accountKey = accountKeypair.private;
if (config.debug) { if (config.debug) {
console.info('Account Key Created'); console.info('Account Key Created');

74
tests/maintainer.js Normal file
View File

@ -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
);
});
});
});

View File

@ -82,9 +82,14 @@ U._request = function(me, opts) {
if (ua && !opts.headers['User-Agent']) { if (ua && !opts.headers['User-Agent']) {
opts.headers['User-Agent'] = ua; opts.headers['User-Agent'] = ua;
} }
if (opts.json && true !== opts.json) { if (opts.json) {
opts.headers['Content-Type'] = 'application/jose+json'; opts.headers.Accept = 'application/json';
opts.body = JSON.stringify(opts.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) { if (!opts.method) {
opts.method = 'GET'; opts.method = 'GET';
@ -92,16 +97,10 @@ U._request = function(me, opts) {
opts.method = 'POST'; 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('\n[debug] REQUEST');
//console.log(opts); //console.log(opts);
return me.request(opts).then(function(resp) { return me.__request(opts).then(function(resp) {
if (resp.toJSON) { if (resp.toJSON) {
resp = resp.toJSON(); resp = resp.toJSON();
} }