WIP gets a cert... nice!

This commit is contained in:
AJ ONeal 2019-10-05 05:21:07 -06:00
parent e75c503356
commit 24c3633d75
5 changed files with 503 additions and 285 deletions

View File

@ -165,7 +165,7 @@ ACME._registerAccount = function(me, options) {
} else if (options.email) {
contact = ['mailto:' + options.email];
}
var body = {
var accountRequest = {
termsOfServiceAgreed: tosUrl === me._tos,
onlyReturnExisting: false,
contact: contact
@ -182,14 +182,14 @@ ACME._registerAccount = function(me, options) {
},
payload: Enc.strToBuf(JSON.stringify(pair.public))
}).then(function(jws) {
body.externalAccountBinding = jws;
return body;
accountRequest.externalAccountBinding = jws;
return accountRequest;
});
} else {
pExt = Promise.resolve(body);
pExt = Promise.resolve(accountRequest);
}
return pExt.then(function(body) {
var payload = JSON.stringify(body);
return pExt.then(function(accountRequest) {
var payload = JSON.stringify(accountRequest);
return ACME._jwsRequest(me, {
options: options,
url: me._directoryUrls.newAccount,
@ -199,10 +199,20 @@ ACME._registerAccount = function(me, options) {
.then(function(resp) {
var account = resp.body;
if (2 !== Math.floor(resp.statusCode / 100)) {
if (
resp.statusCode < 200 ||
resp.statusCode >= 300
) {
if ('string' !== typeof account) {
account = JSON.stringify(account);
}
throw new Error(
'account error: ' +
JSON.stringify(resp.body)
resp.statusCode +
' ' +
account +
'\n' +
JSON.stringify(accountRequest)
);
}
@ -344,7 +354,24 @@ ACME._testChallengeOptions = function() {
];
};
ACME._testChallenges = function(me, options) {
console.log('[debug] testChallenges');
var CHECK_DELAY = 0;
// memoized so that it doesn't run until it's first called
var getThumbnail = function() {
var thumbPromise = ACME._importKeypair(me, options.accountKeypair).then(
function(pair) {
return me.Keypairs.thumbprint({
jwk: pair.public
});
}
);
getThumbnail = function() {
return thumbPromise;
};
return thumbPromise;
};
return Promise.all(
options.domains.map(function(identifierValue) {
// TODO we really only need one to pass, not all to pass
@ -389,10 +416,11 @@ ACME._testChallenges = function(me, options) {
if ('dns-01' === challenge.type) {
// Give the nameservers a moment to propagate
CHECK_DELAY = 1.5 * 1000;
// TODO get this value from the plugin
CHECK_DELAY = 7 * 1000;
}
return Promise.resolve().then(function() {
return getThumbnail().then(function(accountKeyThumb) {
var results = {
identifier: {
type: 'dns',
@ -409,6 +437,7 @@ ACME._testChallenges = function(me, options) {
return ACME._challengeToAuth(
me,
options,
accountKeyThumb,
results,
challenge,
dryrun
@ -460,7 +489,14 @@ ACME._chooseChallenge = function(options, results) {
return challenge;
};
ACME._challengeToAuth = function(me, options, request, challenge, dryrun) {
ACME._challengeToAuth = function(
me,
options,
accountKeyThumb,
request,
challenge,
dryrun
) {
// we don't poison the dns cache with our dummy request
var dnsPrefix = ACME.challengePrefixes['dns-01'];
if (dryrun) {
@ -486,15 +522,15 @@ ACME._challengeToAuth = function(me, options, request, challenge, dryrun) {
auth[key] = challenge[key];
});
var zone = pluckZone(options.zonenames || [], auth.identifier.value);
// batteries-included helpers
auth.hostname = auth.identifier.value;
// because I'm not 100% clear if the wildcard identifier does or doesn't have the leading *. in all cases
auth.altname = ACME._untame(auth.identifier.value, auth.wildcard);
return ACME._importKeypair(me, options.accountKeypair).then(function(pair) {
return me.Keypairs.thumbprint({ jwk: pair.public }).then(function(
thumb
) {
auth.thumbprint = thumb;
// we must accept JWKs that we didn't generate and we can't guarantee
// that they properly set kid to thumbnail (especially since ACME doesn't do this)
// so we have to regenerate it every time we need it, which is quite often
auth.thumbprint = accountKeyThumb;
// keyAuthorization = token || '.' || base64url(JWK_Thumbprint(accountKey))
auth.keyAuthorization = challenge.token + '.' + auth.thumbprint;
// conflicts with ACME challenge id url is already in use, so we call this challengeUrl instead
@ -507,6 +543,9 @@ ACME._challengeToAuth = function(me, options, request, challenge, dryrun) {
auth.token;
auth.dnsHost = dnsPrefix + '.' + auth.hostname.replace('*.', '');
// Always calculate dnsAuthorization because we
// may need to present to the user for confirmation / instruction
// _as part of_ the decision making process
return sha2
.sum(256, auth.keyAuthorization)
.then(function(hash) {
@ -514,10 +553,27 @@ ACME._challengeToAuth = function(me, options, request, challenge, dryrun) {
})
.then(function(hash64) {
auth.dnsAuthorization = hash64;
if (zone) {
auth.dnsZone = zone;
auth.dnsPrefix = auth.dnsHost
.replace(newZoneRegExp(zone), '')
.replace(/\.$/, '');
}
// For backwards compat with the v2.7 plugins
auth.challenge = auth;
// TODO can we use just { challenge: auth }?
auth.request = function() {
// TODO see https://git.rootprojects.org/root/acme.js/issues/###
console.warn(
"[warn] deprecated use of request on '" +
auth.type +
"' challenge object. Receive from challenger.init() instead."
);
me.request.apply(null, arguments);
};
return auth;
});
});
});
};
ACME._untame = function(name, wild) {
@ -597,7 +653,7 @@ ACME._postChallenge = function(me, options, auth) {
.then(function(resp) {
if ('processing' === resp.body.status) {
if (me.debug) {
console.debug('poll: again');
console.debug('poll: again', auth.url);
}
return ACME._wait(RETRY_INTERVAL).then(pollStatus);
}
@ -610,14 +666,14 @@ ACME._postChallenge = function(me, options, auth) {
.then(respondToChallenge);
}
if (me.debug) {
console.debug('poll: again');
console.debug('poll: again', auth.url);
}
return ACME._wait(RETRY_INTERVAL).then(respondToChallenge);
}
if ('valid' === resp.body.status) {
if (me.debug) {
console.debug('poll: valid');
console.debug('VALID !!!!!!!!!!!!!!!! poll: valid');
}
try {
@ -637,7 +693,8 @@ ACME._postChallenge = function(me, options, auth) {
"[acme-v2] (E_STATE_INVALID) challenge state for '" +
altname +
"': '" +
resp.body.status +
//resp.body.status +
JSON.stringify(resp.body) +
"'";
} else {
errmsg =
@ -675,17 +732,20 @@ ACME._postChallenge = function(me, options, auth) {
return respondToChallenge();
};
ACME._setChallenge = function(me, options, auth) {
return new Promise(function(resolve, reject) {
return Promise.resolve().then(function() {
var challengers = options.challenges || {};
var challenger =
(challengers[auth.type] && challengers[auth.type].set) ||
options.setChallenge;
try {
var challenger = challengers[auth.type] && challengers[auth.type].set;
if (!challenger) {
throw new Error(
"options.challenges did not have a valid entry for '" +
auth.type +
"'"
);
}
if (1 === challenger.length) {
challenger(auth)
.then(resolve)
.catch(reject);
return Promise.resolve(challenger(auth));
} else if (2 === challenger.length) {
return new Promise(function(resolve, reject) {
challenger(auth, function(err) {
if (err) {
reject(err);
@ -693,45 +753,12 @@ ACME._setChallenge = function(me, options, auth) {
resolve();
}
});
} else {
// TODO remove this old backwards-compat
var challengeCb = function(err) {
if (err) {
reject(err);
} else {
resolve();
}
};
// for backwards compat adding extra keys without changing params length
Object.keys(auth).forEach(function(key) {
challengeCb[key] = auth[key];
});
if (!ACME._setChallengeWarn) {
console.warn(
'Please update to acme-v2 setChallenge(options) <Promise> or setChallenge(options, cb).'
);
console.warn(
"The API has been changed for compatibility with all ACME / Let's Encrypt challenge types."
);
ACME._setChallengeWarn = true;
}
challenger(
auth.identifier.value,
auth.token,
auth.keyAuthorization,
challengeCb
} else {
throw new Error(
"Bad function signature for '" + auth.type + "' challenge.set()"
);
}
} catch (e) {
reject(e);
}
}).then(function() {
// TODO: Do we still need this delay? Or shall we leave it to plugins to account for themselves?
var DELAY = me.setChallengeWait || 500;
if (me.debug) {
console.debug('\n[DEBUG] waitChallengeDelay %s\n', DELAY);
}
return ACME._wait(DELAY);
});
};
ACME._finalizeOrder = function(me, options, validatedDomains) {
@ -943,12 +970,45 @@ ACME._getCertificate = function(me, options) {
});
}
// TODO Promise.all()?
Object.keys(options.challenges).forEach(function(key) {
var presenter = options.challenges[key];
if ('function' === typeof presenter.init && !presenter._initialized) {
presenter._initialized = true;
return ACME._depInit(me, presenter);
}
});
var promiseZones;
if (options.challenges['dns-01']) {
// a little bit of random to ensure that getZones()
// actually returns the zones and not the hosts as zones
var dnsHosts = options.domains.map(function(d) {
var rnd = require('crypto')
.randomBytes(2)
.toString('hex');
return rnd + '.' + d;
});
promiseZones = ACME._getZones(
me,
options.challenges['dns-01'],
dnsHosts
);
} else {
promiseZones = Promise.resolve([]);
}
return promiseZones
.then(function(zonenames) {
options.zonenames = zonenames;
// Do a little dry-run / self-test
return ACME._testChallenges(me, options).then(function() {
return ACME._testChallenges(me, options);
})
.then(function() {
if (me.debug) {
console.debug('[acme-v2] certificates.create');
}
var body = {
var certOrder = {
// raw wildcard syntax MUST be used here
identifiers: options.domains
.sort(function(a, b) {
@ -971,7 +1031,7 @@ ACME._getCertificate = function(me, options) {
//, "notAfter": "2016-01-08T00:00:00Z"
};
var payload = JSON.stringify(body);
var payload = JSON.stringify(certOrder);
if (me.debug) {
console.debug('\n[DEBUG] newOrder\n');
}
@ -1011,15 +1071,27 @@ ACME._getCertificate = function(me, options) {
}
setAuths = options._authorizations.slice(0);
var accountKeyThumb;
function setThumbnail() {
return ACME._importKeypair(me, options.accountKeypair).then(
function(pair) {
return me.Keypairs.thumbprint({
jwk: pair.public
}).then(function(_thumb) {
accountKeyThumb = _thumb;
});
}
);
}
function setNext() {
var authUrl = setAuths.shift();
if (!authUrl) {
return;
}
return ACME._getChallenges(me, options, authUrl).then(function(
results
) {
return ACME._getChallenges(me, options, authUrl).then(
function(results) {
// var domain = options.domains[i]; // results.identifier.value
// If it's already valid, we're golden it regardless
@ -1031,7 +1103,10 @@ ACME._getCertificate = function(me, options) {
return setNext();
}
var challenge = ACME._chooseChallenge(options, results);
var challenge = ACME._chooseChallenge(
options,
results
);
if (!challenge) {
// For example, wildcards require dns-01 and, if we don't have that, we have to bail
return Promise.reject(
@ -1046,19 +1121,37 @@ ACME._getCertificate = function(me, options) {
return ACME._challengeToAuth(
me,
options,
accountKeyThumb,
results,
challenge,
false
).then(function(auth) {
console.log('ADD DUBIOUS AUTH');
auths.push(auth);
return ACME._setChallenge(me, options, auth).then(
setNext
return ACME._setChallenge(
me,
options,
auth
).then(setNext);
});
}
);
});
});
}
function waitAll() {
// TODO take the max wait of all challenge plugins and wait that long, or 1000ms
var DELAY = me.setChallengeWait || 7000;
if (true || me.debug) {
console.debug(
'\n[DEBUG] waitChallengeDelay %s\n',
DELAY
);
}
return ACME._wait(DELAY);
}
function checkNext() {
console.log('CONSUME DUBIOUS AUTH', auths.length);
var auth = auths.shift();
if (!auth) {
return;
@ -1068,45 +1161,43 @@ ACME._getCertificate = function(me, options) {
// not so much "valid" as "not invalid"
// but in this case we can't confirm either way
validAuths.push(auth);
return Promise.resolve();
console.log('ADD VALID AUTH (skip)', validAuths.length);
return checkNext();
}
return ACME.challengeTests[auth.type](me, auth)
.then(function() {
console.log('ADD VALID AUTH');
validAuths.push(auth);
})
.then(checkNext);
}
function challengeNext() {
function presentNext() {
console.log('CONSUME VALID AUTH', validAuths.length);
var auth = validAuths.shift();
if (!auth) {
return;
}
return ACME._postChallenge(me, options, auth).then(
challengeNext
presentNext
);
}
// First we set every challenge
// Then we ask for each challenge to be checked
// Doing otherwise would potentially cause us to poison our own DNS cache with misses
return setNext()
.then(checkNext)
.then(challengeNext)
.then(function() {
function finalizeOrder() {
if (me.debug) {
console.debug('[getCertificate] next.then');
}
var validatedDomains = body.identifiers.map(function(
var validatedDomains = certOrder.identifiers.map(function(
ident
) {
return ident.value;
});
return ACME._finalizeOrder(me, options, validatedDomains);
})
.then(function(order) {
}
function retrieveCerts(order) {
if (me.debug) {
console.debug('acme-v2: order was finalized');
}
@ -1141,10 +1232,22 @@ ACME._getCertificate = function(me, options) {
}
return certs;
});
});
}
// First we set each and every challenge
// Then we ask for each challenge to be checked
// Doing otherwise would potentially cause us to poison our own DNS cache with misses
return setThumbnail()
.then(setNext)
.then(waitAll)
.then(checkNext)
.then(presentNext)
.then(finalizeOrder)
.then(retrieveCerts);
});
});
};
ACME._generateCsrWeb64 = function(me, options, validatedDomains) {
var csr;
if (options.csr) {
@ -1153,6 +1256,7 @@ ACME._generateCsrWeb64 = function(me, options, validatedDomains) {
if ('string' !== typeof csr) {
csr = Enc.bufToUrlBase64(csr);
}
// TODO PEM.parseBlock()
// nix PEM headers, if any
if ('-' === csr[0]) {
csr = csr
@ -1168,13 +1272,11 @@ ACME._generateCsrWeb64 = function(me, options, validatedDomains) {
me,
options.serverKeypair || options.domainKeypair
).then(function(pair) {
return me
.CSR({
return me.CSR.csr({
jwk: pair.private,
domains: validatedDomains,
encoding: 'der'
})
.then(function(der) {
}).then(function(der) {
return Enc.bufToUrlBase64(der);
});
});
@ -1276,6 +1378,7 @@ ACME._jwsRequest = function(me, bigopts) {
bigopts.protected.kid = bigopts.options._kid;
}
}
// this will shasum the thumbnail the 2nd time
return me.Keypairs.signJws({
jwk: bigopts.options.accountKeypair.privateKeyJwk,
protected: bigopts.protected,
@ -1291,6 +1394,7 @@ ACME._jwsRequest = function(me, bigopts) {
});
});
};
// Handle some ACME-specific defaults
ACME._request = function(me, opts) {
if (!opts.headers) {
@ -1430,24 +1534,99 @@ ACME._http01 = function(me, auth) {
ACME._removeChallenge = function(me, options, auth) {
var challengers = options.challenges || {};
var removeChallenge =
(challengers[auth.type] && challengers[auth.type].remove) ||
options.removeChallenge;
challengers[auth.type] && challengers[auth.type].remove;
if (1 === removeChallenge.length) {
removeChallenge(auth).then(function() {}, function() {});
return Promise.resolve(removeChallenge(auth)).then(
function() {},
function() {}
);
} else if (2 === removeChallenge.length) {
removeChallenge(auth, function(err) {
return err;
});
} else {
if (!ACME._removeChallengeWarn) {
console.warn(
'Please update to acme-v2 removeChallenge(options) <Promise> or removeChallenge(options, cb).'
throw new Error(
"Bad function signature for '" + auth.type + "' challenge.remove()"
);
console.warn(
"The API has been changed for compatibility with all ACME / Let's Encrypt challenge types."
);
ACME._removeChallengeWarn = true;
}
removeChallenge(auth.request.identifier, auth.token, function() {});
}
};
ACME._depInit = function(me, presenter) {
if ('function' !== typeof presenter.init) {
return Promise.resolve(null);
}
return ACME._wrapCb(
me,
presenter,
'init',
{ type: '*', request: me.request },
'null'
);
};
ACME._getZones = function(me, presenter, dnsHosts) {
if ('function' !== typeof presenter.zones) {
presenter.zones = function() {
return Promise.resolve([]);
};
}
var challenge = {
type: 'dns-01',
dnsHosts: dnsHosts,
request: me.request
};
// back/forwards-compat
challenge.challenge = challenge;
return ACME._wrapCb(
me,
presenter,
'zones',
challenge,
'an array of zone names'
);
};
ACME._wrapCb = function(me, options, _name, args, _desc) {
return new Promise(function(resolve, reject) {
if (options[_name].length <= 1) {
return Promise.resolve(options[_name](args))
.then(resolve)
.catch(reject);
} else if (2 === options[_name].length) {
options[_name](args, function(err, results) {
if (err) {
reject(err);
} else {
resolve(results);
}
});
} else {
throw new Error(
'options.' + _name + ' should accept opts and Promise ' + _desc
);
}
});
};
function newZoneRegExp(zonename) {
// (^|\.)example\.com$
// which matches:
// foo.example.com
// example.com
// but not:
// fooexample.com
return new RegExp('(^|\\.)' + zonename.replace(/\./g, '\\.') + '$');
}
function pluckZone(zonenames, dnsHost) {
return zonenames
.filter(function(zonename) {
// the only character that needs to be escaped for regex
// and is allowed in a domain name is '.'
return newZoneRegExp(zonename).test(dnsHost);
})
.sort(function(a, b) {
// longest match first
return b.length - a.length;
})[0];
}

View File

@ -5,18 +5,19 @@
'use strict';
/*global Promise*/
var ASN1 = require('./asn1/parser.js'); // DER, actually
var ASN1 = require('./asn1/packer.js'); // DER, actually
var Asn1 = ASN1.Any;
var BitStr = ASN1.BitStr;
var UInt = ASN1.UInt;
var Asn1Parser = require('./asn1/packer.js'); // DER, actually
var Asn1Parser = require('./asn1/parser.js');
var Enc = require('omnibuffer');
var PEM = require('./pem.js');
var X509 = require('./x509.js');
var Keypairs = require('./keypairs');
// TODO find a way that the prior node-ish way of `module.exports = function () {}` isn't broken
var CSR = (exports.CSR = function(opts) {
var CSR = module.exports;
CSR.csr = function(opts) {
// We're using a Promise here to be compatible with the browser version
// which will probably use the webcrypto API for some of the conversions
return CSR._prepare(opts).then(function(opts) {
@ -24,11 +25,10 @@ var CSR = (exports.CSR = function(opts) {
return CSR._encode(opts, bytes);
});
});
});
};
CSR._prepare = function(opts) {
return Promise.resolve().then(function() {
var Keypairs;
opts = JSON.parse(JSON.stringify(opts));
// We do a bit of extra error checking for user convenience
@ -66,16 +66,6 @@ CSR._prepare = function(opts) {
throw new Error('You must pass options.key as a JSON web key');
}
Keypairs = exports.Keypairs;
if (!exports.Keypairs) {
throw new Error(
'Keypairs.js is an optional dependency for PEM-to-JWK.\n' +
"Install it if you'd like to use it:\n" +
'\tnpm install --save rasha\n' +
'Otherwise supply a jwk as the private key.'
);
}
return Keypairs.import({ pem: opts.pem || opts.key }).then(function(
pair
) {
@ -119,7 +109,7 @@ CSR._sign = function csrEcSig(jwk, request) {
// Took some tips from https://gist.github.com/codermapuche/da4f96cdb6d5ff53b7ebc156ec46a10a
// TODO will have to convert web ECDSA signatures to PEM ECDSA signatures (but RSA should be the same)
// TODO have a consistent non-private way to sign
return Keypairs._sign(
return Keypairs.sign(
{ jwk: jwk, format: 'x509' },
Enc.hexToBuf(request)
).then(function(sig) {

View File

@ -76,6 +76,7 @@ Keypairs.neuter = function(opts) {
};
Keypairs.thumbprint = function(opts) {
//console.log('[debug]', new Error('NOT_ERROR').stack);
return Promise.resolve().then(function() {
if (/EC/i.test(opts.jwk.kty)) {
console.log('[debug] EC thumbprint');
@ -121,6 +122,7 @@ Keypairs.publish = function(opts) {
// JWT a.k.a. JWS with Claims using Compact Serialization
Keypairs.signJwt = function(opts) {
console.log('[debug] signJwt');
return Keypairs.thumbprint({ jwk: opts.jwk }).then(function(thumb) {
var header = opts.header || {};
var claims = JSON.parse(JSON.stringify(opts.claims || {}));
@ -255,6 +257,9 @@ Keypairs.signJws = function(opts) {
});
};
// TODO expose consistently
Keypairs.sign = native._sign;
Keypairs._getBits = function(opts) {
if (opts.alg) {
return opts.alg.replace(/[a-z\-]/gi, '');

View File

@ -15,7 +15,7 @@ Keypairs._sign = function(opts, payload) {
.update(payload)
.sign(pem);
if ('EC' === opts.jwk.kty) {
if ('EC' === opts.jwk.kty && !/x509|asn1/i.test(opts.format)) {
// ECDSA JWT signatures differ from "normal" ECDSA signatures
// https://tools.ietf.org/html/rfc7518#section-3.4
binsig = Keypairs._ecdsaAsn1SigToJoseSig(binsig);

View File

@ -1,18 +1,39 @@
'use strict';
require('dotenv').config();
var ACME = require('../');
var Keypairs = require('../lib/keypairs.js');
var acme = ACME.create({});
var acme = ACME.create({ debug: true });
// TODO exec npm install --save-dev CHALLENGE_MODULE
var config = {
env: process.env.ENV,
email: process.env.SUBSCRIBER_EMAIL,
domain: process.env.BASE_DOMAIN
domain: process.env.BASE_DOMAIN,
challengeType: process.env.CHALLENGE_TYPE,
challengeModule: process.env.CHALLENGE_MODULE,
challengeOptions: JSON.parse(process.env.CHALLENGE_OPTIONS)
};
config.debug = !/^PROD/i.test(config.env);
config.challenger = require('acme-' +
config.challengeType +
'-' +
config.challengeModule).create(config.challengeOptions);
if (!config.challengeType || !config.domain) {
console.error(
new Error('Missing config variables. Check you .env and the docs')
.message
);
console.error(config);
process.exit(1);
}
var challenges = {};
challenges[config.challengeType] = config.challenger;
async function happyPath() {
var domains = randomDomains();
var agreed = false;
var metadata = await acme.init(
'https://acme-staging-v02.api.letsencrypt.org/directory'
@ -66,8 +87,31 @@ async function happyPath() {
if (config.debug) {
console.info('Server Key Created');
console.info(JSON.stringify(serverKeypair, null, 2));
console.info('');
console.info();
console.info();
}
var domains = randomDomains();
if (config.debug) {
console.info('Get certificates for random domains:');
console.info(domains);
}
var results = await acme.certificates.create({
account: account,
accountKeypair: { privateKeyJwk: accountKeypair.private },
serverKeypair: { privateKeyJwk: serverKeypair.private },
domains: domains,
challenges: challenges, // must be implemented
skipDryRun: true
});
if (config.debug) {
console.info('Got SSL Certificate:');
console.info(results.expires);
console.info(results.cert);
console.info(results.chain);
console.info('');
console.info('');
}
}