diff --git a/README.md b/README.md index 912f251..a242145 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,8 @@ ACME.challengePrefixes['dns-01'] // '_acme-challenge' # Changelog +* v1.5 + * perform full test challenge first (even before nonce) * v1.3 * Use node RSA keygen by default * No non-optional external deps! diff --git a/node.js b/node.js index 63c64c9..b3c5b50 100644 --- a/node.js +++ b/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 = { 'http-01': '/.well-known/acme-challenge' , 'dns-01': '_acme-challenge' @@ -255,6 +258,37 @@ ACME._wait = function wait(ms) { 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 ACME._postChallenge = function (me, options, identifier, ch) { 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 - Host: example.com - Content-Type: application/jose+json - - { - "protected": base64url({ - "alg": "ES256", - "kid": "https://example.com/acme/acct/1", - "nonce": "xWCM9lGbIyCgue8di6ueWQ", - "url": "https://example.com/acme/authz/1234" - }), - "payload": base64url({ - "status": "deactivated" - }), - "signature": "srX9Ji7Le9bjszhu...WTFdtujObzMtZcx4" - } - */ - function deactivate() { - var jws = me.RSA.signJws( - options.accountKeypair - , undefined - , { nonce: me._nonce, alg: 'RS256', url: ch.url, kid: me._kid } - , Buffer.from(JSON.stringify({ "status": "deactivated" })) - ); - 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] deactivate:'); } - 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 (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) { + /* + POST /acme/authz/1234 HTTP/1.1 + Host: example.com + Content-Type: application/jose+json + + { + "protected": base64url({ + "alg": "ES256", + "kid": "https://example.com/acme/acct/1", + "nonce": "xWCM9lGbIyCgue8di6ueWQ", + "url": "https://example.com/acme/authz/1234" + }), + "payload": base64url({ + "status": "deactivated" + }), + "signature": "srX9Ji7Le9bjszhu...WTFdtujObzMtZcx4" + } + */ + function deactivate() { + var jws = me.RSA.signJws( + options.accountKeypair + , undefined + , { nonce: me._nonce, alg: 'RS256', url: ch.url, kid: me._kid } + , Buffer.from(JSON.stringify({ "status": "deactivated" })) + ); + 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] deactivate:'); } + if (me.debug) { console.debug(resp.headers); } + if (me.debug) { console.debug(resp.body); } + if (me.debug) { console.debug(); } - if ('processing' === resp.body.status) { - if (me.debug) { console.debug('poll: again'); } - return ACME._wait(RETRY_INTERVAL).then(pollStatus); - } + 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); + }); + } - // This state should never occur - if ('pending' === resp.body.status) { - if (count >= MAX_PEND) { - return ACME._wait(RETRY_INTERVAL).then(deactivate).then(testChallenge); - } - if (me.debug) { console.debug('poll: again'); } - return ACME._wait(RETRY_INTERVAL).then(testChallenge); - } + function pollStatus() { + if (count >= MAX_POLL) { + return Promise.reject(new Error("[acme-v2] stuck in bad pending/processing state")); + } - if ('valid' === resp.body.status) { - if (me.debug) { console.debug('poll: valid'); } + count += 1; - try { - if (1 === options.removeChallenge.length) { - options.removeChallenge(auth).then(function () {}, function () {}); - } else if (2 === options.removeChallenge.length) { - options.removeChallenge(auth, function (err) { return err; }); - } else { - options.removeChallenge(identifier.value, ch.token, function () {}); - } - } catch(e) {} - return resp.body; - } + 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) { + if (me.debug) { console.debug('poll: again'); } + return ACME._wait(RETRY_INTERVAL).then(pollStatus); + } - if (!resp.body.status) { - console.error("[acme-v2] (E_STATE_EMPTY) empty challenge state:"); - } - 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 + "'"); + // This state should never occur + if ('pending' === resp.body.status) { + if (count >= MAX_PEND) { + return ACME._wait(RETRY_INTERVAL).then(deactivate).then(respondToChallenge); } + 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() { - 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(); } + try { + if (1 === options.removeChallenge.length) { + options.removeChallenge(auth).then(function () {}, function () {}); + } else if (2 === options.removeChallenge.length) { + options.removeChallenge(auth, function (err) { return err; }); + } else { + options.removeChallenge(identifier.value, ch.token, function () {}); + } + } catch(e) {} + return resp.body; + } - me._nonce = resp.toJSON().headers['replay-nonce']; - if (me.debug) { console.debug('respond to challenge: resp.body:'); } - if (me.debug) { console.debug(resp.body); } - return ACME._wait(RETRY_INTERVAL).then(pollStatus); - }); - } + if (!resp.body.status) { + console.error("[acme-v2] (E_STATE_EMPTY) empty challenge state:"); + } + 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() { - // 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))}}" + return Promise.reject(new Error("[acme-v2] [error] unacceptable challenge state '" + resp.body.status + "'")); + }); + } - if (me.debug) {console.debug('\n[DEBUG] postChallenge\n'); } - //if (me.debug) console.debug('\n[DEBUG] stop to fix things\n'); return; + function respondToChallenge() { + 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 () { - if (!me.skipChallengeTest) { - return ACME.challengeTests[ch.type](me, auth); - } - }).then(respondToChallenge); - } + me._nonce = resp.toJSON().headers['replay-nonce']; + if (me.debug) { console.debug('respond to challenge: resp.body:'); } + if (me.debug) { console.debug(resp.body); } + return ACME._wait(RETRY_INTERVAL).then(pollStatus); + }); + } + return ACME._setChallenge(me, options, auth).then(respondToChallenge); +}; +ACME._setChallenge = function (me, options, auth) { + return new Promise(function (resolve, reject) { try { 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) { options.setChallenge(auth, function (err) { - if(err) { - reject(err); - } else { - testChallenge().then(resolve, reject); - } + if(err) { reject(err); } else { resolve(); } }); } else { var challengeCb = function(err) { - if(err) { - reject(err); - } else { - testChallenge().then(resolve, reject); - } + 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]; }); - options.setChallenge(identifier.value, ch.token, keyAuthorization, challengeCb); + options.setChallenge(auth.identifier.value, auth.token, auth.keyAuthorization, challengeCb); } } 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) { @@ -548,104 +567,106 @@ ACME._getCertificate = function (me, options) { } } - if (me.debug) { console.debug('[acme-v2] certificates.create'); } - return ACME._getNonce(me).then(function () { - var body = { - identifiers: options.domains.map(function (hostname) { - return { type: "dns" , value: hostname }; - }) - //, "notBefore": "2016-01-01T00:00:00Z" - //, "notAfter": "2016-01-08T00:00:00Z" - }; - - var payload = JSON.stringify(body); - var jws = me.RSA.signJws( - options.accountKeypair - , undefined - , { nonce: me._nonce, alg: 'RS256', url: me._directoryUrls.newOrder, kid: me._kid } - , Buffer.from(payload) - ); + return ACME._testChallenges(me, options).then(function () { + if (me.debug) { console.debug('[acme-v2] certificates.create'); } + return ACME._getNonce(me).then(function () { + var body = { + identifiers: options.domains.map(function (hostname) { + return { type: "dns" , value: hostname }; + }) + //, "notBefore": "2016-01-01T00:00:00Z" + //, "notAfter": "2016-01-08T00:00:00Z" + }; + + var payload = JSON.stringify(body); + var jws = me.RSA.signJws( + options.accountKeypair + , undefined + , { nonce: me._nonce, alg: 'RS256', url: me._directoryUrls.newOrder, kid: me._kid } + , Buffer.from(payload) + ); - if (me.debug) { console.debug('\n[DEBUG] newOrder\n'); } - me._nonce = null; - return me._request({ - method: 'POST' - , url: me._directoryUrls.newOrder - , headers: { 'Content-Type': 'application/jose+json' } - , json: jws - }).then(function (resp) { - me._nonce = resp.toJSON().headers['replay-nonce']; - var location = resp.toJSON().headers.location; - var auths; - if (me.debug) { console.debug(location); } // the account id url - if (me.debug) { console.debug(resp.toJSON()); } - me._authorizations = resp.body.authorizations; - me._order = location; - me._finalize = resp.body.finalize; - //if (me.debug) console.debug('[DEBUG] finalize:', me._finalize); return; - - if (!me._authorizations) { - console.error("[acme-v2.js] authorizations were not fetched:"); - console.error(resp.body); - return Promise.reject(new Error("authorizations were not fetched")); - } - if (me.debug) { console.debug("[acme-v2] POST newOrder has authorizations"); } - - //return resp.body; - auths = me._authorizations.slice(0); - - function next() { - var authUrl = auths.shift(); - if (!authUrl) { return; } - - return ACME._getChallenges(me, options, authUrl).then(function (results) { - // var domain = options.domains[i]; // results.identifier.value - var chType = options.challengeTypes.filter(function (chType) { - return results.challenges.some(function (ch) { - return ch.type === chType; - }); - })[0]; - - var challenge = results.challenges.filter(function (ch) { - if (chType === ch.type) { - return ch; + if (me.debug) { console.debug('\n[DEBUG] newOrder\n'); } + me._nonce = null; + return me._request({ + method: 'POST' + , url: me._directoryUrls.newOrder + , headers: { 'Content-Type': 'application/jose+json' } + , json: jws + }).then(function (resp) { + me._nonce = resp.toJSON().headers['replay-nonce']; + var location = resp.toJSON().headers.location; + var auths; + if (me.debug) { console.debug(location); } // the account id url + if (me.debug) { console.debug(resp.toJSON()); } + me._authorizations = resp.body.authorizations; + me._order = location; + me._finalize = resp.body.finalize; + //if (me.debug) console.debug('[DEBUG] finalize:', me._finalize); return; + + if (!me._authorizations) { + console.error("[acme-v2.js] authorizations were not fetched:"); + console.error(resp.body); + return Promise.reject(new Error("authorizations were not fetched")); + } + if (me.debug) { console.debug("[acme-v2] POST newOrder has authorizations"); } + + //return resp.body; + auths = me._authorizations.slice(0); + + function next() { + var authUrl = auths.shift(); + if (!authUrl) { return; } + + return ACME._getChallenges(me, options, authUrl).then(function (results) { + // var domain = options.domains[i]; // results.identifier.value + var chType = options.challengeTypes.filter(function (chType) { + return results.challenges.some(function (ch) { + return ch.type === chType; + }); + })[0]; + + var challenge = results.challenges.filter(function (ch) { + if (chType === ch.type) { + return ch; + } + })[0]; + + if (!challenge) { + return Promise.reject(new Error("Server didn't offer any challenge we can handle.")); } - })[0]; - if (!challenge) { - return Promise.reject(new Error("Server didn't offer any challenge we can handle.")); - } - - 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._postChallenge(me, options, results.identifier, challenge); + }).then(function () { + return next(); + }); + } - return ACME._finalizeOrder(me, options, validatedDomains); - }).then(function (order) { - if (me.debug) { console.debug('acme-v2: order was finalized'); } - return me._request({ method: 'GET', url: me._certificate, json: true }).then(function (resp) { - 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||''))); - // cert, chain, fullchain, privkey, /*TODO, subject, altnames, issuedAt, expiresAt */ - var certs = { - expires: order.expires - , identifiers: order.identifiers - //, authorizations: order.authorizations - , cert: certsarr.shift() - //, privkey: privkeyPem - , chain: certsarr.join('\n') - }; - if (me.debug) { console.debug(certs); } - return certs; + 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); + }).then(function (order) { + if (me.debug) { console.debug('acme-v2: order was finalized'); } + return me._request({ method: 'GET', url: me._certificate, json: true }).then(function (resp) { + 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||''))); + // cert, chain, fullchain, privkey, /*TODO, subject, altnames, issuedAt, expiresAt */ + var certs = { + expires: order.expires + , identifiers: order.identifiers + //, authorizations: order.authorizations + , cert: certsarr.shift() + //, privkey: privkeyPem + , chain: certsarr.join('\n') + }; + if (me.debug) { console.debug(certs); } + return certs; + }); }); }); }); diff --git a/package.json b/package.json index 6f84c2e..594de84 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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", "homepage": "https://git.coolaj86.com/coolaj86/acme-v2.js", "main": "node.js",