v1.7.1: don't self-poison dns cache, more consistency

This commit is contained in:
AJ ONeal 2019-04-02 19:13:58 -06:00
parent b1182457cd
commit e5e7377712
2 changed files with 129 additions and 69 deletions

196
node.js
View File

@ -47,10 +47,9 @@ ACME.challengeTests = {
} }
, 'dns-01': function (me, auth) { , 'dns-01': function (me, auth) {
// remove leading *. on wildcard domains // remove leading *. on wildcard domains
var hostname = ACME.challengePrefixes['dns-01'] + '.' + auth.hostname.replace(/^\*\./, '');
return me._dig({ return me._dig({
type: 'TXT' type: 'TXT'
, name: hostname , name: auth.dnsHost
}).then(function (ans) { }).then(function (ans) {
var err; var err;
@ -62,7 +61,7 @@ ACME.challengeTests = {
err = new Error( err = new Error(
"Error: Failed DNS-01 Pre-Flight Dry Run.\n" "Error: Failed DNS-01 Pre-Flight Dry Run.\n"
+ "dig TXT '" + hostname + "' does not return '" + auth.dnsAuthorization + "'\n" + "dig TXT '" + auth.dnsHost + "' does not return '" + auth.dnsAuthorization + "'\n"
+ "See https://git.coolaj86.com/coolaj86/acme-v2.js/issues/4" + "See https://git.coolaj86.com/coolaj86/acme-v2.js/issues/4"
); );
err.code = 'E_FAIL_DRY_CHALLENGE'; err.code = 'E_FAIL_DRY_CHALLENGE';
@ -164,7 +163,7 @@ ACME._registerAccount = function (me, options) {
options.accountKeypair options.accountKeypair
, undefined , undefined
, { nonce: me._nonce , { nonce: me._nonce
, alg: 'RS256' , alg: (me._alg || 'RS256')
, url: me._directoryUrls.newAccount , url: me._directoryUrls.newAccount
, jwk: jwk , jwk: jwk
} }
@ -296,48 +295,58 @@ ACME._testChallenges = function (me, options) {
return Promise.resolve(); return Promise.resolve();
} }
var CHECK_DELAY = 0;
return Promise.all(options.domains.map(function (identifierValue) { return Promise.all(options.domains.map(function (identifierValue) {
// TODO we really only need one to pass, not all to pass // TODO we really only need one to pass, not all to pass
var results = ACME._testChallengeOptions(); var challenges = ACME._testChallengeOptions();
if (identifierValue.inludes("*")) { if (identifierValue.includes("*")) {
results = results.filter(function (ch) { return ch._wildcard; }); challenges = challenges.filter(function (ch) { return ch._wildcard; });
} }
var challenge = ACME._chooseChallenge(options, results);
var challenge = ACME._chooseChallenge(options, { challenges: challenges });
if (!challenge) { if (!challenge) {
// For example, wildcards require dns-01 and, if we don't have that, we have to bail // For example, wildcards require dns-01 and, if we don't have that, we have to bail
var enabled = options.challengeTypes.join(', ') || 'none'; var enabled = options.challengeTypes.join(', ') || 'none';
var suitable = results.map(function (r) { return r.type; }).join(', ') || 'none'; var suitable = challenges.map(function (r) { return r.type; }).join(', ') || 'none';
return Promise.reject(new Error( return Promise.reject(new Error(
"None of the challenge types that you've enabled ( " + enabled + " )" "None of the challenge types that you've enabled ( " + enabled + " )"
+ " are suitable for validating the domain you've selected (" + identifierValue + ")." + " are suitable for validating the domain you've selected (" + identifierValue + ")."
+ " You must enable one of ( " + suitable + " )." + " You must enable one of ( " + suitable + " )."
)); ));
} }
return Promise.resolve().then(function () { if ('dns-01' === challenge.type) {
var thumbprint = me.RSA.thumbprint(options.accountKeypair); // nameservers take a second to propagate
var keyAuthorization = challenge.token + '.' + thumbprint; CHECK_DELAY = 5 * 1000;
var auth = { }
identifier: { type: "dns", value: identifierValue }
, hostname: identifierValue
, type: challenge.type
, token: challenge.token
, thumbprint: thumbprint
, keyAuthorization: keyAuthorization
, dnsAuthorization: ACME._toWebsafeBase64(
require('crypto').createHash('sha256').update(keyAuthorization).digest('base64')
)
};
return Promise.resolve().then(function () {
var results = {
identifier: {
type: "dns"
, value: identifierValue.replace(/^\*\./, '')
, wildcard: identifierValue.includes('*.') || undefined
}
, challenges: [ challenge ]
, expires: new Date(Date.now() + (60 * 1000)).toISOString()
};
var dryrun = true;
var auth = ACME._challengeToAuth(me, options, results, challenge, dryrun);
return ACME._setChallenge(me, options, auth).then(function () { return ACME._setChallenge(me, options, auth).then(function () {
return ACME.challengeTests[challenge.type](me, auth); return auth;
}); });
}); });
})); })).then(function (auths) {
return ACME._wait(CHECK_DELAY).then(function () {
return Promise.all(auths.map(function (auth) {
return ACME.challengeTests[auth.type](me, auth);
}));
});
});
}; };
ACME._chooseChallenge = function(options, results) { ACME._chooseChallenge = function(options, results) {
// For each of the challenge types that we support // For each of the challenge types that we support
var challenge; var challenge;
options.challengesTypes.some(function (chType) { options.challengeTypes.some(function (chType) {
// And for each of the challenge types that are allowed // And for each of the challenge types that are allowed
return results.challenges.some(function (ch) { return results.challenges.some(function (ch) {
// Check to see if there are any matches // Check to see if there are any matches
@ -350,30 +359,57 @@ ACME._chooseChallenge = function(options, results) {
return challenge; return challenge;
}; };
ACME._challengeToAuth = function (me, options, request, challenge, dryrun) {
// we don't poison the dns cache with our dummy request
var dnsPrefix = ACME.challengePrefixes['dns-01'];
if (dryrun) {
dnsPrefix = dnsPrefix.replace('acme-challenge', 'greenlock-dryrun-' + Math.random().toString().slice(2,6));
}
var auth = {};
// straight copy from the new order response
// { identifier, status, expires, challenges, wildcard }
Object.keys(request).forEach(function (key) {
auth[key] = request[key];
});
// copy from the challenge we've chosen
// { type, status, url, token }
// (note the duplicate status overwrites the one above, but they should be the same)
Object.keys(challenge).forEach(function (key) {
auth[key] = challenge[key];
});
// batteries-included helpers
auth.hostname = request.identifier.value;
auth.thumbprint = me.RSA.thumbprint(options.accountKeypair);
// keyAuthorization = token || '.' || base64url(JWK_Thumbprint(accountKey))
auth.keyAuthorization = challenge.token + '.' + auth.thumbprint;
auth.dnsHost = dnsPrefix + '.' + auth.hostname.replace('*.', '');
auth.dnsAuthorization = ACME._toWebsafeBase64(
require('crypto').createHash('sha256').update(auth.keyAuthorization).digest('base64')
);
// because I'm not 100% clear if the wildcard identifier does or doesn't have the leading *. in all cases
auth.altname = ACME._untame(request.identifier.value, request.wildcard);
return auth;
};
ACME._untame = function (name, wild) {
if (wild) { name = '*.' + name.replace('*.', ''); }
return name;
};
// https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-7.5.1 // https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-7.5.1
ACME._postChallenge = function (me, options, identifier, ch) { ACME._postChallenge = function (me, options, auth) {
var RETRY_INTERVAL = me.retryInterval || 1000; var RETRY_INTERVAL = me.retryInterval || 1000;
var DEAUTH_INTERVAL = me.deauthWait || 10 * 1000; var DEAUTH_INTERVAL = me.deauthWait || 10 * 1000;
var MAX_POLL = me.retryPoll || 8; var MAX_POLL = me.retryPoll || 8;
var MAX_PEND = me.retryPending || 4; var MAX_PEND = me.retryPending || 4;
var count = 0; var count = 0;
var thumbprint = me.RSA.thumbprint(options.accountKeypair); var altname = ACME._untame(auth.identifier.value, auth.wildcard);
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: ACME._toWebsafeBase64(
require('crypto').createHash('sha256').update(keyAuthorization).digest('base64')
)
};
/* /*
POST /acme/authz/1234 HTTP/1.1 POST /acme/authz/1234 HTTP/1.1
@ -397,13 +433,13 @@ ACME._postChallenge = function (me, options, identifier, ch) {
var jws = me.RSA.signJws( var jws = me.RSA.signJws(
options.accountKeypair options.accountKeypair
, undefined , undefined
, { nonce: me._nonce, alg: (me._alg || 'RS256'), url: ch.url, kid: me._kid } , { nonce: me._nonce, alg: (me._alg || 'RS256'), url: auth.url, kid: me._kid }
, Buffer.from(JSON.stringify({ "status": "deactivated" })) , Buffer.from(JSON.stringify({ "status": "deactivated" }))
); );
me._nonce = null; me._nonce = null;
return me._request({ return me._request({
method: 'POST' method: 'POST'
, url: ch.url , url: auth.url
, headers: { 'Content-Type': 'application/jose+json' } , headers: { 'Content-Type': 'application/jose+json' }
, json: jws , json: jws
}).then(function (resp) { }).then(function (resp) {
@ -422,14 +458,14 @@ ACME._postChallenge = function (me, options, identifier, ch) {
function pollStatus() { function pollStatus() {
if (count >= MAX_POLL) { if (count >= MAX_POLL) {
return Promise.reject(new Error( return Promise.reject(new Error(
"[acme-v2] stuck in bad pending/processing state for '" + identifier.value + "'" "[acme-v2] stuck in bad pending/processing state for '" + altname + "'"
)); ));
} }
count += 1; count += 1;
if (me.debug) { console.debug('\n[DEBUG] statusChallenge\n'); } if (me.debug) { console.debug('\n[DEBUG] statusChallenge\n'); }
return me._request({ method: 'GET', url: ch.url, json: true }).then(function (resp) { return me._request({ method: 'GET', url: auth.url, json: true }).then(function (resp) {
if ('processing' === resp.body.status) { if ('processing' === resp.body.status) {
if (me.debug) { console.debug('poll: again'); } if (me.debug) { console.debug('poll: again'); }
return ACME._wait(RETRY_INTERVAL).then(pollStatus); return ACME._wait(RETRY_INTERVAL).then(pollStatus);
@ -453,7 +489,12 @@ ACME._postChallenge = function (me, options, identifier, ch) {
} else if (2 === options.removeChallenge.length) { } else if (2 === options.removeChallenge.length) {
options.removeChallenge(auth, function (err) { return err; }); options.removeChallenge(auth, function (err) { return err; });
} else { } else {
options.removeChallenge(identifier.value, ch.token, function () {}); if (!ACME._removeChallengeWarn) {
console.warn("Please update to acme-v2 removeChallenge(options) <Promise> or removeChallenge(options, cb).");
console.warn("The API has been changed for compatibility with all ACME / Let's Encrypt challenge types.");
ACME._removeChallengeWarn = true;
}
options.removeChallenge(auth.request.identifier, auth.token, function () {});
} }
} catch(e) {} } catch(e) {}
return resp.body; return resp.body;
@ -461,13 +502,13 @@ ACME._postChallenge = function (me, options, identifier, ch) {
var errmsg; var errmsg;
if (!resp.body.status) { if (!resp.body.status) {
errmsg = "[acme-v2] (E_STATE_EMPTY) empty challenge state for '" + identifier.value + "':"; errmsg = "[acme-v2] (E_STATE_EMPTY) empty challenge state for '" + altname + "':";
} }
else if ('invalid' === resp.body.status) { else if ('invalid' === resp.body.status) {
errmsg = "[acme-v2] (E_STATE_INVALID) challenge state for '" + identifier.value + "': '" + resp.body.status + "'"; errmsg = "[acme-v2] (E_STATE_INVALID) challenge state for '" + altname + "': '" + resp.body.status + "'";
} }
else { else {
errmsg = "[acme-v2] (E_STATE_UKN) challenge state for '" + identifier.value + "': '" + resp.body.status + "'"; errmsg = "[acme-v2] (E_STATE_UKN) challenge state for '" + altname + "': '" + resp.body.status + "'";
} }
return Promise.reject(new Error(errmsg)); return Promise.reject(new Error(errmsg));
@ -478,13 +519,13 @@ ACME._postChallenge = function (me, options, identifier, ch) {
var jws = me.RSA.signJws( var jws = me.RSA.signJws(
options.accountKeypair options.accountKeypair
, undefined , undefined
, { nonce: me._nonce, alg: 'RS256', url: ch.url, kid: me._kid } , { nonce: me._nonce, alg: (me._alg || 'RS256'), url: auth.url, kid: me._kid }
, Buffer.from(JSON.stringify({ })) , Buffer.from(JSON.stringify({ }))
); );
me._nonce = null; me._nonce = null;
return me._request({ return me._request({
method: 'POST' method: 'POST'
, url: ch.url , url: auth.url
, headers: { 'Content-Type': 'application/jose+json' } , headers: { 'Content-Type': 'application/jose+json' }
, json: jws , json: jws
}).then(function (resp) { }).then(function (resp) {
@ -519,6 +560,11 @@ ACME._setChallenge = function (me, options, auth) {
Object.keys(auth).forEach(function (key) { Object.keys(auth).forEach(function (key) {
challengeCb[key] = auth[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;
}
options.setChallenge(auth.identifier.value, auth.token, auth.keyAuthorization, challengeCb); options.setChallenge(auth.identifier.value, auth.token, auth.keyAuthorization, challengeCb);
} }
} catch(e) { } catch(e) {
@ -541,7 +587,7 @@ ACME._finalizeOrder = function (me, options, validatedDomains) {
var jws = me.RSA.signJws( var jws = me.RSA.signJws(
options.accountKeypair options.accountKeypair
, undefined , undefined
, { nonce: me._nonce, alg: 'RS256', url: me._finalize, kid: me._kid } , { nonce: me._nonce, alg: (me._alg || 'RS256'), url: me._finalize, kid: me._kid }
, Buffer.from(payload) , Buffer.from(payload)
); );
@ -642,14 +688,13 @@ ACME._getCertificate = function (me, options) {
} }
// TODO check that all challengeTypes are represented in challenges // TODO check that all challengeTypes are represented in challenges
if (!options.challengeTypes.length) { if (!options.challengeTypes.length) {
return Promise.reject(new Error("options.challengesTypes (string array) must be specified" return Promise.reject(new Error("options.challengeTypes (string array) must be specified"
+ " (and in order of preferential priority).")); + " (and in order of preferential priority)."));
} }
if (!(options.domains && options.domains.length)) { if (!(options.domains && options.domains.length)) {
return Promise.reject(new Error("options.domains must be a list of string domain names," return Promise.reject(new Error("options.domains must be a list of string domain names,"
+ " with the first being the subject of the domain (or options.subject must specified).")); + " with the first being the subject of the domain (or options.subject must specified)."));
} }
if (!options.subject) { options.subject = options.domains[0]; }
// It's just fine if there's no account, we'll go get the key id we need via the public key // It's just fine if there's no account, we'll go get the key id we need via the public key
if (!me._kid) { if (!me._kid) {
@ -670,8 +715,15 @@ ACME._getCertificate = function (me, options) {
if (me.debug) { console.debug('[acme-v2] certificates.create'); } if (me.debug) { console.debug('[acme-v2] certificates.create'); }
return ACME._getNonce(me).then(function () { return ACME._getNonce(me).then(function () {
var body = { var body = {
identifiers: options.domains.map(function (hostname) { // raw wildcard syntax MUST be used here
return { type: "dns" , value: hostname }; identifiers: options.domains.sort(function (a, b) {
// the first in the list will be the subject of the certificate, I believe (and hope)
if (!options.subject) { return 0; }
if (options.subject === a) { return -1; }
if (options.subject === b) { return 1; }
return 0;
}).map(function (hostname) {
return { type: "dns", value: hostname };
}) })
//, "notBefore": "2016-01-01T00:00:00Z" //, "notBefore": "2016-01-01T00:00:00Z"
//, "notAfter": "2016-01-08T00:00:00Z" //, "notAfter": "2016-01-08T00:00:00Z"
@ -698,7 +750,8 @@ ACME._getCertificate = function (me, options) {
}).then(function (resp) { }).then(function (resp) {
me._nonce = resp.toJSON().headers['replay-nonce']; me._nonce = resp.toJSON().headers['replay-nonce'];
var location = resp.toJSON().headers.location; var location = resp.toJSON().headers.location;
var auths; var setAuths;
var auths = [];
if (me.debug) { console.debug(location); } // the account id url if (me.debug) { console.debug(location); } // the account id url
if (me.debug) { console.debug(resp.toJSON()); } if (me.debug) { console.debug(resp.toJSON()); }
me._authorizations = resp.body.authorizations; me._authorizations = resp.body.authorizations;
@ -713,12 +766,10 @@ ACME._getCertificate = function (me, options) {
)); ));
} }
if (me.debug) { console.debug("[acme-v2] POST newOrder has authorizations"); } if (me.debug) { console.debug("[acme-v2] POST newOrder has authorizations"); }
setAuths = me._authorizations.slice(0);
//return resp.body; function setNext() {
auths = me._authorizations.slice(0); var authUrl = setAuths.shift();
function next() {
var authUrl = auths.shift();
if (!authUrl) { return; } if (!authUrl) { return; }
return ACME._getChallenges(me, options, authUrl).then(function (results) { return ACME._getChallenges(me, options, authUrl).then(function (results) {
@ -726,7 +777,7 @@ ACME._getCertificate = function (me, options) {
// If it's already valid, we're golden it regardless // If it's already valid, we're golden it regardless
if (results.challenges.some(function (ch) { return 'valid' === ch.status; })) { if (results.challenges.some(function (ch) { return 'valid' === ch.status; })) {
return; return setNext();
} }
var challenge = ACME._chooseChallenge(options, results); var challenge = ACME._chooseChallenge(options, results);
@ -737,13 +788,22 @@ ACME._getCertificate = function (me, options) {
)); ));
} }
return ACME._postChallenge(me, options, results.identifier, challenge); var auth = ACME._challengeToAuth(me, options, results, challenge);
}).then(function () { auths.push(auth);
return next(); return ACME._setChallenge(me, options, auth).then(setNext);
}); });
} }
return next().then(function () { function challengeNext() {
var auth = auths.shift();
if (!auth) { return; }
return ACME._postChallenge(me, options, auth).then(challengeNext);
}
// 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(challengeNext).then(function () {
if (me.debug) { console.debug("[getCertificate] next.then"); } if (me.debug) { console.debug("[getCertificate] next.then"); }
var validatedDomains = body.identifiers.map(function (ident) { var validatedDomains = body.identifiers.map(function (ident) {
return ident.value; return ident.value;

View File

@ -1,6 +1,6 @@
{ {
"name": "acme-v2", "name": "acme-v2",
"version": "1.7.0", "version": "1.7.1",
"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", "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", "homepage": "https://git.coolaj86.com/coolaj86/acme-v2.js",
"main": "node.js", "main": "node.js",