diff --git a/node.js b/node.js index e490b8d..e32021c 100644 --- a/node.js +++ b/node.js @@ -47,10 +47,9 @@ ACME.challengeTests = { } , 'dns-01': function (me, auth) { // remove leading *. on wildcard domains - var hostname = ACME.challengePrefixes['dns-01'] + '.' + auth.hostname.replace(/^\*\./, ''); return me._dig({ type: 'TXT' - , name: hostname + , name: auth.dnsHost }).then(function (ans) { var err; @@ -62,7 +61,7 @@ ACME.challengeTests = { err = new Error( "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" ); err.code = 'E_FAIL_DRY_CHALLENGE'; @@ -164,7 +163,7 @@ ACME._registerAccount = function (me, options) { options.accountKeypair , undefined , { nonce: me._nonce - , alg: 'RS256' + , alg: (me._alg || 'RS256') , url: me._directoryUrls.newAccount , jwk: jwk } @@ -296,48 +295,58 @@ ACME._testChallenges = function (me, options) { return Promise.resolve(); } + var CHECK_DELAY = 0; return Promise.all(options.domains.map(function (identifierValue) { // TODO we really only need one to pass, not all to pass - var results = ACME._testChallengeOptions(); - if (identifierValue.inludes("*")) { - results = results.filter(function (ch) { return ch._wildcard; }); + var challenges = ACME._testChallengeOptions(); + if (identifierValue.includes("*")) { + challenges = challenges.filter(function (ch) { return ch._wildcard; }); } - var challenge = ACME._chooseChallenge(options, results); + + var challenge = ACME._chooseChallenge(options, { challenges: challenges }); if (!challenge) { // For example, wildcards require dns-01 and, if we don't have that, we have to bail 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( "None of the challenge types that you've enabled ( " + enabled + " )" + " are suitable for validating the domain you've selected (" + identifierValue + ")." + " You must enable one of ( " + suitable + " )." )); } - return Promise.resolve().then(function () { - var thumbprint = me.RSA.thumbprint(options.accountKeypair); - var keyAuthorization = challenge.token + '.' + thumbprint; - 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') - ) - }; + if ('dns-01' === challenge.type) { + // nameservers take a second to propagate + CHECK_DELAY = 5 * 1000; + } + 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.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) { // For each of the challenge types that we support var challenge; - options.challengesTypes.some(function (chType) { + options.challengeTypes.some(function (chType) { // And for each of the challenge types that are allowed return results.challenges.some(function (ch) { // Check to see if there are any matches @@ -350,30 +359,57 @@ ACME._chooseChallenge = function(options, results) { 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 -ACME._postChallenge = function (me, options, identifier, ch) { +ACME._postChallenge = function (me, options, auth) { var RETRY_INTERVAL = me.retryInterval || 1000; var DEAUTH_INTERVAL = me.deauthWait || 10 * 1000; var MAX_POLL = me.retryPoll || 8; var MAX_PEND = me.retryPending || 4; var count = 0; - var thumbprint = me.RSA.thumbprint(options.accountKeypair); - 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') - ) - }; + var altname = ACME._untame(auth.identifier.value, auth.wildcard); /* POST /acme/authz/1234 HTTP/1.1 @@ -397,13 +433,13 @@ ACME._postChallenge = function (me, options, identifier, ch) { var jws = me.RSA.signJws( options.accountKeypair , 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" })) ); me._nonce = null; return me._request({ method: 'POST' - , url: ch.url + , url: auth.url , headers: { 'Content-Type': 'application/jose+json' } , json: jws }).then(function (resp) { @@ -422,14 +458,14 @@ ACME._postChallenge = function (me, options, identifier, ch) { function pollStatus() { if (count >= MAX_POLL) { 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; 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 (me.debug) { console.debug('poll: again'); } return ACME._wait(RETRY_INTERVAL).then(pollStatus); @@ -453,7 +489,12 @@ ACME._postChallenge = function (me, options, identifier, ch) { } else if (2 === options.removeChallenge.length) { options.removeChallenge(auth, function (err) { return err; }); } else { - options.removeChallenge(identifier.value, ch.token, function () {}); + if (!ACME._removeChallengeWarn) { + console.warn("Please update to acme-v2 removeChallenge(options) 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) {} return resp.body; @@ -461,13 +502,13 @@ ACME._postChallenge = function (me, options, identifier, ch) { var errmsg; 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) { - 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 { - 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)); @@ -478,13 +519,13 @@ ACME._postChallenge = function (me, options, identifier, ch) { var jws = me.RSA.signJws( options.accountKeypair , 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({ })) ); me._nonce = null; return me._request({ method: 'POST' - , url: ch.url + , url: auth.url , headers: { 'Content-Type': 'application/jose+json' } , json: jws }).then(function (resp) { @@ -519,6 +560,11 @@ ACME._setChallenge = function (me, options, auth) { Object.keys(auth).forEach(function (key) { challengeCb[key] = auth[key]; }); + if (!ACME._setChallengeWarn) { + console.warn("Please update to acme-v2 setChallenge(options) 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); } } catch(e) { @@ -541,7 +587,7 @@ ACME._finalizeOrder = function (me, options, validatedDomains) { var jws = me.RSA.signJws( options.accountKeypair , 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) ); @@ -642,14 +688,13 @@ ACME._getCertificate = function (me, options) { } // TODO check that all challengeTypes are represented in challenges 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).")); } if (!(options.domains && options.domains.length)) { 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).")); } - 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 if (!me._kid) { @@ -670,8 +715,15 @@ 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 }; + // raw wildcard syntax MUST be used here + 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" //, "notAfter": "2016-01-08T00:00:00Z" @@ -698,7 +750,8 @@ ACME._getCertificate = function (me, options) { }).then(function (resp) { me._nonce = resp.toJSON().headers['replay-nonce']; 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(resp.toJSON()); } 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"); } + setAuths = me._authorizations.slice(0); - //return resp.body; - auths = me._authorizations.slice(0); - - function next() { - var authUrl = auths.shift(); + function setNext() { + var authUrl = setAuths.shift(); if (!authUrl) { return; } 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 (results.challenges.some(function (ch) { return 'valid' === ch.status; })) { - return; + return setNext(); } var challenge = ACME._chooseChallenge(options, results); @@ -737,13 +788,22 @@ ACME._getCertificate = function (me, options) { )); } - return ACME._postChallenge(me, options, results.identifier, challenge); - }).then(function () { - return next(); + var auth = ACME._challengeToAuth(me, options, results, challenge); + auths.push(auth); + 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"); } var validatedDomains = body.identifiers.map(function (ident) { return ident.value; diff --git a/package.json b/package.json index ffdc6f5..add69cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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", "homepage": "https://git.coolaj86.com/coolaj86/acme-v2.js", "main": "node.js",