Browse Source

v1.5.0: perform full test challenge first

pull/25/head v1.5.0
AJ ONeal 5 years ago
parent
commit
83137766bc
  1. 2
      README.md
  2. 485
      node.js
  3. 2
      package.json

2
README.md

@ -193,6 +193,8 @@ ACME.challengePrefixes['dns-01'] // '_acme-challenge'
# Changelog # Changelog
* v1.5
* perform full test challenge first (even before nonce)
* v1.3 * v1.3
* Use node RSA keygen by default * Use node RSA keygen by default
* No non-optional external deps! * No non-optional external deps!

485
node.js

@ -16,6 +16,9 @@ ACME.splitPemChain = function splitPemChain(str) {
}); });
}; };
// http-01: GET https://example.org/.well-known/acme-challenge/{{token}} => {{keyAuth}}
// dns-01: TXT _acme-challenge.example.org. => "{{urlSafeBase64(sha256(keyAuth))}}"
ACME.challengePrefixes = { ACME.challengePrefixes = {
'http-01': '/.well-known/acme-challenge' 'http-01': '/.well-known/acme-challenge'
, 'dns-01': '_acme-challenge' , 'dns-01': '_acme-challenge'
@ -255,6 +258,37 @@ ACME._wait = function wait(ms) {
setTimeout(resolve, (ms || 1100)); setTimeout(resolve, (ms || 1100));
}); });
}; };
ACME._testChallenges = function (me, options) {
if (me.skipChallengeTest) {
return Promise.resolve();
}
return Promise.all(options.domains.map(function (identifierValue) {
// TODO we really only need one to pass, not all to pass
return Promise.all(options.challengeTypes.map(function (chType) {
var chToken = require('crypto').randomBytes(16).toString('hex');
var thumbprint = me.RSA.thumbprint(options.accountKeypair);
var keyAuthorization = chToken + '.' + thumbprint;
var auth = {
identifier: { type: "dns", value: identifierValue }
, hostname: identifierValue
, type: chType
, token: chToken
, thumbprint: thumbprint
, keyAuthorization: keyAuthorization
, dnsAuthorization: me.RSA.utils.toWebsafeBase64(
require('crypto').createHash('sha256').update(keyAuthorization).digest('base64')
)
};
return ACME._setChallenge(me, options, auth).then(function () {
return ACME.challengeTests[chType](me, auth);
});
}));
}));
};
// 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, identifier, ch) {
var RETRY_INTERVAL = me.retryInterval || 1000; var RETRY_INTERVAL = me.retryInterval || 1000;
@ -279,172 +313,157 @@ ACME._postChallenge = function (me, options, identifier, ch) {
) )
}; };
return new Promise(function (resolve, reject) { /*
/* POST /acme/authz/1234 HTTP/1.1
POST /acme/authz/1234 HTTP/1.1 Host: example.com
Host: example.com Content-Type: application/jose+json
Content-Type: application/jose+json
{
{ "protected": base64url({
"protected": base64url({ "alg": "ES256",
"alg": "ES256", "kid": "https://example.com/acme/acct/1",
"kid": "https://example.com/acme/acct/1", "nonce": "xWCM9lGbIyCgue8di6ueWQ",
"nonce": "xWCM9lGbIyCgue8di6ueWQ", "url": "https://example.com/acme/authz/1234"
"url": "https://example.com/acme/authz/1234" }),
}), "payload": base64url({
"payload": base64url({ "status": "deactivated"
"status": "deactivated" }),
}), "signature": "srX9Ji7Le9bjszhu...WTFdtujObzMtZcx4"
"signature": "srX9Ji7Le9bjszhu...WTFdtujObzMtZcx4" }
} */
*/ function deactivate() {
function deactivate() { 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: 'RS256', url: ch.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: ch.url , headers: { 'Content-Type': 'application/jose+json' }
, headers: { 'Content-Type': 'application/jose+json' } , json: jws
, json: jws }).then(function (resp) {
}).then(function (resp) { if (me.debug) { console.debug('[acme-v2.js] deactivate:'); }
if (me.debug) { console.debug('[acme-v2.js] deactivate:'); } if (me.debug) { console.debug(resp.headers); }
if (me.debug) { console.debug(resp.headers); } if (me.debug) { console.debug(resp.body); }
if (me.debug) { console.debug(resp.body); } if (me.debug) { console.debug(); }
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(DEAUTH_INTERVAL);
});
}
function pollStatus() {
if (count >= MAX_POLL) {
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) { me._nonce = resp.toJSON().headers['replay-nonce'];
if (me.debug) { console.debug('poll: again'); } if (me.debug) { console.debug('deactivate challenge: resp.body:'); }
return ACME._wait(RETRY_INTERVAL).then(pollStatus); if (me.debug) { console.debug(resp.body); }
} return ACME._wait(DEAUTH_INTERVAL);
});
}
// This state should never occur function pollStatus() {
if ('pending' === resp.body.status) { if (count >= MAX_POLL) {
if (count >= MAX_PEND) { return Promise.reject(new Error("[acme-v2] stuck in bad pending/processing state"));
return ACME._wait(RETRY_INTERVAL).then(deactivate).then(testChallenge); }
}
if (me.debug) { console.debug('poll: again'); }
return ACME._wait(RETRY_INTERVAL).then(testChallenge);
}
if ('valid' === resp.body.status) { count += 1;
if (me.debug) { console.debug('poll: valid'); }
try { if (me.debug) { console.debug('\n[DEBUG] statusChallenge\n'); }
if (1 === options.removeChallenge.length) { return me._request({ method: 'GET', url: ch.url, json: true }).then(function (resp) {
options.removeChallenge(auth).then(function () {}, function () {}); if ('processing' === resp.body.status) {
} else if (2 === options.removeChallenge.length) { if (me.debug) { console.debug('poll: again'); }
options.removeChallenge(auth, function (err) { return err; }); return ACME._wait(RETRY_INTERVAL).then(pollStatus);
} else { }
options.removeChallenge(identifier.value, ch.token, function () {});
}
} catch(e) {}
return resp.body;
}
if (!resp.body.status) { // This state should never occur
console.error("[acme-v2] (E_STATE_EMPTY) empty challenge state:"); if ('pending' === resp.body.status) {
} if (count >= MAX_PEND) {
else if ('invalid' === resp.body.status) { return ACME._wait(RETRY_INTERVAL).then(deactivate).then(respondToChallenge);
console.error("[acme-v2] (E_STATE_INVALID) challenge state: '" + resp.body.status + "'");
}
else {
console.error("[acme-v2] (E_STATE_UKN) challenge state: '" + resp.body.status + "'");
} }
if (me.debug) { console.debug('poll: again'); }
return ACME._wait(RETRY_INTERVAL).then(respondToChallenge);
}
return Promise.reject(new Error("[acme-v2] [error] unacceptable challenge state '" + resp.body.status + "'")); if ('valid' === resp.body.status) {
}); if (me.debug) { console.debug('poll: valid'); }
}
function respondToChallenge() { try {
var jws = me.RSA.signJws( if (1 === options.removeChallenge.length) {
options.accountKeypair options.removeChallenge(auth).then(function () {}, function () {});
, undefined } else if (2 === options.removeChallenge.length) {
, { nonce: me._nonce, alg: 'RS256', url: ch.url, kid: me._kid } options.removeChallenge(auth, function (err) { return err; });
, Buffer.from(JSON.stringify({ })) } else {
); options.removeChallenge(identifier.value, ch.token, function () {});
me._nonce = null; }
return me._request({ } catch(e) {}
method: 'POST' return resp.body;
, 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 (!resp.body.status) {
if (me.debug) { console.debug('respond to challenge: resp.body:'); } console.error("[acme-v2] (E_STATE_EMPTY) empty challenge state:");
if (me.debug) { console.debug(resp.body); } }
return ACME._wait(RETRY_INTERVAL).then(pollStatus); else if ('invalid' === resp.body.status) {
}); console.error("[acme-v2] (E_STATE_INVALID) challenge state: '" + resp.body.status + "'");
} }
else {
console.error("[acme-v2] (E_STATE_UKN) challenge state: '" + resp.body.status + "'");
}
function testChallenge() { return Promise.reject(new Error("[acme-v2] [error] unacceptable challenge state '" + resp.body.status + "'"));
// 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'); } function respondToChallenge() {
//if (me.debug) console.debug('\n[DEBUG] stop to fix things\n'); return; var jws = me.RSA.signJws(
options.accountKeypair
, undefined
, { nonce: me._nonce, alg: 'RS256', url: ch.url, kid: me._kid }
, Buffer.from(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(); }
return ACME._wait(RETRY_INTERVAL).then(function () { me._nonce = resp.toJSON().headers['replay-nonce'];
if (!me.skipChallengeTest) { if (me.debug) { console.debug('respond to challenge: resp.body:'); }
return ACME.challengeTests[ch.type](me, auth); if (me.debug) { console.debug(resp.body); }
} return ACME._wait(RETRY_INTERVAL).then(pollStatus);
}).then(respondToChallenge); });
} }
return ACME._setChallenge(me, options, auth).then(respondToChallenge);
};
ACME._setChallenge = function (me, options, auth) {
return new Promise(function (resolve, reject) {
try { try {
if (1 === options.setChallenge.length) { if (1 === options.setChallenge.length) {
options.setChallenge(auth).then(testChallenge).then(resolve, reject); options.setChallenge(auth).then(resolve).catch(reject);
} else if (2 === options.setChallenge.length) { } else if (2 === options.setChallenge.length) {
options.setChallenge(auth, function (err) { options.setChallenge(auth, function (err) {
if(err) { if(err) { reject(err); } else { resolve(); }
reject(err);
} else {
testChallenge().then(resolve, reject);
}
}); });
} else { } else {
var challengeCb = function(err) { var challengeCb = function(err) {
if(err) { if(err) { reject(err); } else { resolve(); }
reject(err);
} else {
testChallenge().then(resolve, reject);
}
}; };
// for backwards compat adding extra keys without changing params length
Object.keys(auth).forEach(function (key) { Object.keys(auth).forEach(function (key) {
challengeCb[key] = auth[key]; challengeCb[key] = auth[key];
}); });
options.setChallenge(identifier.value, ch.token, keyAuthorization, challengeCb); options.setChallenge(auth.identifier.value, auth.token, auth.keyAuthorization, challengeCb);
} }
} catch(e) { } catch(e) {
reject(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) { ACME._finalizeOrder = function (me, options, validatedDomains) {
@ -548,104 +567,106 @@ ACME._getCertificate = function (me, options) {
} }
} }
if (me.debug) { console.debug('[acme-v2] certificates.create'); } return ACME._testChallenges(me, options).then(function () {
return ACME._getNonce(me).then(function () { if (me.debug) { console.debug('[acme-v2] certificates.create'); }
var body = { return ACME._getNonce(me).then(function () {
identifiers: options.domains.map(function (hostname) { var body = {
return { type: "dns" , value: hostname }; identifiers: options.domains.map(function (hostname) {
}) return { type: "dns" , value: hostname };
//, "notBefore": "2016-01-01T00:00:00Z" })
//, "notAfter": "2016-01-08T00:00:00Z" //, "notBefore": "2016-01-01T00:00:00Z"
}; //, "notAfter": "2016-01-08T00:00:00Z"
};
var payload = JSON.stringify(body);
var jws = me.RSA.signJws( var payload = JSON.stringify(body);
options.accountKeypair var jws = me.RSA.signJws(
, undefined options.accountKeypair
, { nonce: me._nonce, alg: 'RS256', url: me._directoryUrls.newOrder, kid: me._kid } , undefined
, Buffer.from(payload) , { nonce: me._nonce, alg: 'RS256', url: me._directoryUrls.newOrder, kid: me._kid }
); , Buffer.from(payload)
);
if (me.debug) { console.debug('\n[DEBUG] newOrder\n'); } if (me.debug) { console.debug('\n[DEBUG] newOrder\n'); }
me._nonce = null; me._nonce = null;
return me._request({ return me._request({
method: 'POST' method: 'POST'
, url: me._directoryUrls.newOrder , url: me._directoryUrls.newOrder
, headers: { 'Content-Type': 'application/jose+json' } , headers: { 'Content-Type': 'application/jose+json' }
, json: jws , json: jws
}).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 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;
me._order = location; me._order = location;
me._finalize = resp.body.finalize; me._finalize = resp.body.finalize;
//if (me.debug) console.debug('[DEBUG] finalize:', me._finalize); return; //if (me.debug) console.debug('[DEBUG] finalize:', me._finalize); return;
if (!me._authorizations) { if (!me._authorizations) {
console.error("[acme-v2.js] authorizations were not fetched:"); console.error("[acme-v2.js] authorizations were not fetched:");
console.error(resp.body); console.error(resp.body);
return Promise.reject(new Error("authorizations were not fetched")); return Promise.reject(new Error("authorizations were not fetched"));
} }
if (me.debug) { console.debug("[acme-v2] POST newOrder has authorizations"); } if (me.debug) { console.debug("[acme-v2] POST newOrder has authorizations"); }
//return resp.body; //return resp.body;
auths = me._authorizations.slice(0); auths = me._authorizations.slice(0);
function next() { function next() {
var authUrl = auths.shift(); 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) {
// var domain = options.domains[i]; // results.identifier.value // var domain = options.domains[i]; // results.identifier.value
var chType = options.challengeTypes.filter(function (chType) { var chType = options.challengeTypes.filter(function (chType) {
return results.challenges.some(function (ch) { return results.challenges.some(function (ch) {
return ch.type === chType; return ch.type === chType;
}); });
})[0]; })[0];
var challenge = results.challenges.filter(function (ch) { var challenge = results.challenges.filter(function (ch) {
if (chType === ch.type) { if (chType === ch.type) {
return ch; return ch;
}
})[0];
if (!challenge) {
return Promise.reject(new Error("Server didn't offer any challenge we can handle."));
} }
})[0];
if (!challenge) { return ACME._postChallenge(me, options, results.identifier, challenge);
return Promise.reject(new Error("Server didn't offer any challenge we can handle.")); }).then(function () {
} return next();
});
return ACME._postChallenge(me, options, results.identifier, challenge); }
}).then(function () {
return next();
});
}
return next().then(function () {
if (me.debug) { console.debug("[getCertificate] next.then"); }
var validatedDomains = body.identifiers.map(function (ident) {
return ident.value;
});
return ACME._finalizeOrder(me, options, validatedDomains); return next().then(function () {
}).then(function (order) { if (me.debug) { console.debug("[getCertificate] next.then"); }
if (me.debug) { console.debug('acme-v2: order was finalized'); } var validatedDomains = body.identifiers.map(function (ident) {
return me._request({ method: 'GET', url: me._certificate, json: true }).then(function (resp) { return ident.value;
if (me.debug) { console.debug('acme-v2: csr submitted and cert received:'); } });
// https://github.com/certbot/certbot/issues/5721
var certsarr = ACME.splitPemChain(ACME.formatPemChain((resp.body||''))); return ACME._finalizeOrder(me, options, validatedDomains);
// cert, chain, fullchain, privkey, /*TODO, subject, altnames, issuedAt, expiresAt */ }).then(function (order) {
var certs = { if (me.debug) { console.debug('acme-v2: order was finalized'); }
expires: order.expires return me._request({ method: 'GET', url: me._certificate, json: true }).then(function (resp) {
, identifiers: order.identifiers if (me.debug) { console.debug('acme-v2: csr submitted and cert received:'); }
//, authorizations: order.authorizations // https://github.com/certbot/certbot/issues/5721
, cert: certsarr.shift() var certsarr = ACME.splitPemChain(ACME.formatPemChain((resp.body||'')));
//, privkey: privkeyPem // cert, chain, fullchain, privkey, /*TODO, subject, altnames, issuedAt, expiresAt */
, chain: certsarr.join('\n') var certs = {
}; expires: order.expires
if (me.debug) { console.debug(certs); } , identifiers: order.identifiers
return certs; //, authorizations: order.authorizations
, cert: certsarr.shift()
//, privkey: privkeyPem
, chain: certsarr.join('\n')
};
if (me.debug) { console.debug(certs); }
return certs;
});
}); });
}); });
}); });

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "acme-v2", "name": "acme-v2",
"version": "1.3.1", "version": "1.5.0",
"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",

Loading…
Cancel
Save