// Copyright 2018-present AJ ONeal. All rights reserved /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 'use strict'; /* globals Promise */ require('@root/encoding/bytes'); var Enc = require('@root/encoding/base64'); var ACME = module.exports; var Keypairs = require('@root/keypairs'); var CSR = require('@root/csr'); var sha2 = require('@root/keypairs/lib/node/sha2.js'); var http = require('./lib/node/http.js'); var A = require('./account.js'); var U = require('./utils.js'); var E = require('./errors.js'); var M = require('./maintainers.js'); var native = require('./lib/native.js'); ACME.create = function create(me) { if (!me) { me = {}; } // me.debug = true; me._nonces = []; me._canCheck = {}; if (!/.+@.+\..+/.test(me.maintainerEmail)) { throw new Error( 'you should supply `maintainerEmail` as a contact for security and critical bug notices' ); } if (!/\w\/v?\d/.test(me.packageAgent) && false !== me.packageAgent) { console.error( "\nyou should supply `packageAgent` as an rfc7231-style User-Agent such as Foo/v1.1\n\n\t// your package agent should be this:\n\tvar pkg = require('./package.json');\n\tvar agent = pkg.name + '/' + pkg.version\n" ); process.exit(1); return; } if (!me.dns01) { me.dns01 = function (ch) { return native._dns01(me, ch); }; } if (!me.http01) { // for browser version only if (!me._baseUrl) { me._baseUrl = ''; } me.http01 = function (ch) { return native._http01(me, ch); }; } if (!me.__request) { me.__request = http.request; } // passed to dependencies me.request = function (opts) { return U._request(me, opts); }; me.init = function (opts) { M.init(me); function fin(dir) { me._directoryUrls = dir; me._tos = dir.meta.termsOfService; return dir; } if (opts && opts.meta && opts.termsOfService) { return Promise.resolve(fin(opts)); } if (!me.directoryUrl) { me.directoryUrl = opts; } if ('string' !== typeof me.directoryUrl) { throw new Error( 'you must supply either the ACME directory url as a string or an object of the ACME urls' ); } var p = Promise.resolve(); if (!me.skipChallengeTest) { p = native._canCheck(me); } return p.then(function () { return ACME._directory(me).then(function (resp) { return fin(resp.body); }); }); }; me.accounts = { create: function (options) { try { return A._registerAccount(me, options); } catch (e) { return Promise.reject(e); } } }; /* me.authorizations = { // create + get challlenges get: function(options) { return A._getAccountKid(me, options).then(function(kid) { ACME._normalizePresenters(me, options, options.challenges); return ACME._orderCert(me, options, kid).then(function(order) { return order.claims; }); }); }, // set challenges, check challenges, finalize order, return order present: function(options) { return A._getAccountKid(me, options).then(function(kid) { ACME._normalizePresenters(me, options, options.challenges); return ACME._finalizeOrder(me, options, kid, options.order); }); } }; */ me.certificates = { create: function (options) { return A._getAccountKid(me, options).then(function (kid) { ACME._normalizePresenters(me, options, options.challenges); return ACME._getCertificate(me, options, kid); }); } }; return me; }; // 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' }; ACME.challengeTests = { 'http-01': function (me, auth) { var ch = auth.challenge; return me.http01(ch).then(function (keyAuth) { var err; // TODO limit the number of bytes that are allowed to be downloaded if (ch.keyAuthorization === (keyAuth || '').trim()) { return true; } err = new Error( 'Error: Failed HTTP-01 Pre-Flight / Dry Run.\n' + "curl '" + ch.challengeUrl + "'\n" + "Expected: '" + ch.keyAuthorization + "'\n" + "Got: '" + keyAuth + "'\n" + 'See https://git.rootprojects.org/root/acme.js/issues/4' ); err.code = 'E_FAIL_DRY_CHALLENGE'; throw err; }); }, 'dns-01': function (me, auth) { // remove leading *. on wildcard domains var ch = auth.challenge; return me.dns01(ch).then(function (ans) { var err; if ( ans.answer.some(function (txt) { return ch.dnsAuthorization === txt.data[0]; }) ) { return true; } err = new Error( 'Error: Failed DNS-01 Pre-Flight Dry Run.\n' + "dig TXT '" + ch.dnsHost + "' does not return '" + ch.dnsAuthorization + "'\n" + 'See https://git.rootprojects.org/root/acme.js/issues/4' ); err.code = 'E_FAIL_DRY_CHALLENGE'; throw err; }); } }; ACME._directory = function (me) { // TODO cache the directory URL // GET-as-GET ok return U._request(me, { method: 'GET', url: me.directoryUrl, json: true }); }; // registerAccount // postChallenge // finalizeOrder // getCertificate ACME._getCertificate = function (me, options, kid) { //#console.debug('[ACME.js] certificates.create'); return ACME._orderCert(me, options, kid).then(function (order) { return ACME._finalizeOrder(me, options, kid, order); }); }; ACME._normalizePresenters = function (me, options, presenters) { // Prefer this order for efficiency: // * http-01 is the fasest // * tls-alpn-01 is for networks that don't allow plain traffic // * dns-01 is the slowest (due to DNS propagation), // but is required for private networks and wildcards var presenterTypes = Object.keys(options.challenges || {}); options._presenterTypes = ['http-01', 'tls-alpn-01', 'dns-01'].filter( function (typ) { return -1 !== presenterTypes.indexOf(typ); } ); if ( presenters['dns-01'] && 'number' !== typeof presenters['dns-01'].propagationDelay ) { if (!ACME._propagationDelayWarning) { var err = new Error( "dns-01 challenge's `propagationDelay` not set, defaulting to 5000ms" ); err.code = 'E_NO_DNS_DELAY'; err.description = "Each dns-01 challenge should specify challenges['dns-01'].propagationDelay as an estimate of how long DNS propagation will take."; ACME._notify(me, options, 'warning', err); presenters['dns-01'].propagationDelay = 5000; ACME._propagationDelayWarning = true; } } Object.keys(presenters || {}).forEach(function (k) { var ch = presenters[k]; var warned = false; if (!ch.set || !ch.remove) { throw new Error('challenge plugin must have set() and remove()'); } if (!ch.get) { if ('dns-01' === k) { console.warn('dns-01 challenge plugin should have get()'); } else { throw new Error( 'http-01 and tls-alpn-01 challenge plugins must have get()' ); } } if ('dns-01' === k) { if (!ch.zones) { console.warn('dns-01 challenge plugin should have zones()'); } } function warn() { if (warned) { return; } warned = true; console.warn( "'" + k + "' may have incorrect function signatures, or contains deprecated use of callbacks" ); } function promisify(fn) { return function (opts) { new Promise(function (resolve, reject) { fn(opts, function (err, result) { if (err) { reject(err); return; } resolve(result); }); }); }; } // init, zones, set, get, remove if (ch.init && 2 === ch.init.length) { warn(); ch._thunk_init = ch.init; ch.init = promisify(ch._thunk_init); } if (ch.zones && 2 === ch.zones.length) { warn(); ch._thunk_zones = ch.zones; ch.zones = promisify(ch._thunk_zones); } if (2 === ch.set.length) { warn(); ch._thunk_set = ch.set; ch.set = promisify(ch._thunk_set); } if (2 === ch.remove.length) { warn(); ch._thunk_remove = ch.remove; ch.remove = promisify(ch._thunk_remove); } if (ch.get && 2 === ch.get.length) { warn(); ch._thunk_get = ch.get; ch.get = promisify(ch._thunk_get); } return ch; }); }; /* POST /acme/new-order HTTP/1.1 Host: example.com Content-Type: application/jose+json { "protected": base64url({ "alg": "ES256", "kid": "https://example.com/acme/acct/1", "nonce": "5XJ1L3lEkMG7tR6pA00clA", "url": "https://example.com/acme/new-order" }), "payload": base64url({ "identifiers": [{"type:"dns","value":"example.com"}], "notBefore": "2016-01-01T00:00:00Z", "notAfter": "2016-01-08T00:00:00Z" }), "signature": "H6ZXtGjTZyUnPeKn...wEA4TklBdh3e454g" } */ ACME._getAuthorization = function (me, options, kid, zonenames, authUrl) { //#console.debug('\n[DEBUG] getAuthorization\n'); return U._jwsRequest(me, { accountKey: options.accountKey, url: authUrl, protected: { kid: kid }, payload: '' }).then(function (resp) { // Pre-emptive rather than lazy for interfaces that need to show the // challenges to the user first return ACME._computeAuths( me, options, '', resp.body, zonenames, false ).then(function (auths) { resp.body._rawChallenges = resp.body.challenges; resp.body.challenges = auths; return resp.body; }); }); }; ACME._testChallengeOptions = function () { // we want this to be the same for the whole group var chToken = ACME._prnd(16); return [ { type: 'http-01', status: 'pending', url: 'https://acme-staging-v02.example.com/0', token: 'test-' + chToken + '-0' }, { type: 'dns-01', status: 'pending', url: 'https://acme-staging-v02.example.com/1', token: 'test-' + chToken + '-1', _wildcard: true }, { type: 'tls-alpn-01', status: 'pending', url: 'https://acme-staging-v02.example.com/3', token: 'test-' + chToken + '-3' } ]; }; ACME._thumber = function (options, thumb) { var thumbPromise; return function (key) { if (thumb) { return Promise.resolve(thumb); } if (thumbPromise) { return thumbPromise; } if (!key) { key = options.accountKey || options.accountKeypair; } thumbPromise = U._importKeypair(key).then(function (pair) { return Keypairs.thumbprint({ jwk: pair.public }); }); return thumbPromise; }; }; ACME._dryRun = function (me, realOptions, zonenames) { var noopts = {}; Object.keys(realOptions).forEach(function (key) { noopts[key] = realOptions[key]; }); noopts.order = {}; // memoized so that it doesn't run until it's first called var getThumbprint = ACME._thumber(noopts, ''); return Promise.all( noopts.domains.map(function (identifierValue) { // TODO we really only need one to pass, not all to pass var challenges = ACME._testChallengeOptions(); var wild = '*.' === identifierValue.slice(0, 2); if (wild) { challenges = challenges.filter(function (ch) { return ch._wildcard; }); } challenges = challenges.filter(function (auth) { return me._canCheck[auth.type]; }); return getThumbprint().then(function (accountKeyThumb) { var resp = { body: { identifier: { type: 'dns', value: identifierValue.replace(/^\*\./, '') }, challenges: challenges, expires: new Date(Date.now() + 60 * 1000).toISOString(), wildcard: identifierValue.includes('*.') || undefined } }; // The dry-run comes first in the spirit of "fail fast" // (and protecting against challenge failure rate limits) var dryrun = true; return ACME._computeAuths( me, noopts, accountKeyThumb, resp.body, zonenames, dryrun ).then(function (auths) { resp.body.challenges = auths; return resp.body; }); }); }) ).then(function (claims) { var selected = []; noopts.order._claims = claims.slice(0); noopts.notify = function (ev, params) { if ('_challenge_select' === ev) { selected.push(params.challenge); } }; function clear() { selected.forEach(function (ch) { ACME._notify(me, noopts, 'challenge_remove', { altname: ch.altname, type: ch.type //challenge: ch }); // ignore promise return noopts.challenges[ch.type] .remove({ challenge: ch }) .catch(function (err) { err.action = 'challenge_remove'; err.altname = ch.altname; err.type = ch.type; ACME._notify(me, noopts, 'error', err); }); }); } return ACME._setChallenges(me, noopts, noopts.order) .catch(function (err) { clear(); throw err; }) .then(clear); }); }; // Get the list of challenge types we can validate, // which is already ordered by preference. // Select the first matching offered challenge type ACME._chooseChallenge = function (options, results) { // For each of the challenge types that we support var challenge; options._presenterTypes.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 if (ch.type === chType) { challenge = ch; return true; } }); }); return challenge; }; ACME._getZones = function (me, challenges, domains) { var presenter = challenges['dns-01']; if (!presenter) { return Promise.resolve([]); } if ('function' !== typeof presenter.zones) { return Promise.resolve([]); } // a little bit of random to ensure that getZones() // actually returns the zones and not the hosts as zones var dnsHosts = domains.map(function (d) { var rnd = ACME._prnd(2); return rnd + '.' + d; }); var authChallenge = { type: 'dns-01', dnsHosts: dnsHosts }; return presenter.zones({ challenge: authChallenge }); }; ACME._challengesMap = { 'http-01': 0, 'dns-01': 0, 'tls-alpn-01': 0 }; ACME._computeAuths = function (me, options, thumb, authz, zonenames, 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-' + ACME._prnd(4) ); } var getThumbprint = ACME._thumber(options, thumb); return Promise.all( authz.challenges.map(function (challenge) { // Don't do extra work for challenges that we can't satisfy var _types = options._presenterTypes; if (_types && !_types.includes(challenge.type)) { return null; } var auth = {}; // straight copy from the new order response // { identifier, status, expires, challenges, wildcard } Object.keys(authz).forEach(function (key) { auth[key] = authz[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) { // don't confused devs with the id url auth[key] = challenge[key]; }); // 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); var zone = pluckZone(zonenames || [], auth.identifier.value); return ACME.computeChallenge({ accountKey: options.accountKey, _getThumbprint: getThumbprint, challenge: auth, zone: zone, dnsPrefix: dnsPrefix }).then(function (resp) { Object.keys(resp).forEach(function (k) { auth[k] = resp[k]; }); return auth; }); }) ).then(function (auths) { return auths.filter(Boolean); }); }; ACME.computeChallenge = function (opts) { var auth = opts.challenge; var hostname = auth.hostname || opts.hostname; var zone = opts.zone; var thumb = opts.thumbprint || ''; var accountKey = opts.accountKey; var getThumbprint = opts._getThumbprint || ACME._thumber(opts, thumb); var dnsPrefix = opts.dnsPrefix || ACME.challengePrefixes['dns-01']; return getThumbprint(accountKey).then(function (thumb) { var resp = {}; resp.thumbprint = thumb; // keyAuthorization = token + '.' + base64url(JWK_Thumbprint(accountKey)) resp.keyAuthorization = auth.token + '.' + thumb; if ('http-01' === auth.type) { // conflicts with ACME challenge id url is already in use, // so we call this challengeUrl instead // TODO auth.http01Url ? resp.challengeUrl = 'http://' + // `hostname` is an alias of `auth.indentifier.value` hostname + ACME.challengePrefixes['http-01'] + '/' + auth.token; } if ('dns-01' !== auth.type) { return resp; } // 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, resp.keyAuthorization) .then(function (hash) { return Enc.bufToUrlBase64(Uint8Array.from(hash)); }) .then(function (hash64) { resp.dnsHost = dnsPrefix + '.' + hostname; // .replace('*.', ''); // deprecated resp.dnsAuthorization = hash64; // should use this instead resp.keyAuthorizationDigest = hash64; if (zone) { resp.dnsZone = zone; resp.dnsPrefix = resp.dnsHost .replace(newZoneRegExp(zone), '') .replace(/\.$/, ''); } return resp; }); }); }; 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, kid, 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 altname = ACME._untame(auth.identifier.value, auth.wildcard); /* 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() { //#console.debug('[ACME.js] deactivate:'); return U._jwsRequest(me, { accountKey: options.accountKey, url: auth.url, protected: { kid: kid }, payload: Enc.strToBuf(JSON.stringify({ status: 'deactivated' })) }).then(function (/*#resp*/) { //#console.debug('deactivate challenge: resp.body:'); //#console.debug(resp.body); return ACME._wait(DEAUTH_INTERVAL); }); } function pollStatus() { if (count >= MAX_POLL) { var err = new Error( "[ACME.js] stuck in bad pending/processing state for '" + altname + "'" ); err.context = 'present_challenge'; return Promise.reject(err); } count += 1; //#console.debug('\n[DEBUG] statusChallenge\n'); // POST-as-GET return U._jwsRequest(me, { accountKey: options.accountKey, url: auth.url, protected: { kid: kid }, payload: Enc.binToBuf('') }) .then(checkResult) .catch(transformError); } function checkResult(resp) { ACME._notify(me, options, 'challenge_status', { // API-locked status: resp.body.status, type: auth.type, altname: altname }); // State can be pending while waiting ACME server to transition to // processing if ('pending' === resp.body.status) { if (count >= MAX_PEND) { return ACME._wait(RETRY_INTERVAL) .then(deactivate) .then(respondToChallenge); } //#console.debug('poll: again', auth.url); return ACME._wait(RETRY_INTERVAL).then(pollStatus); } if ('processing' === resp.body.status) { //#console.debug('poll: again', auth.url); return ACME._wait(RETRY_INTERVAL).then(pollStatus); } // REMOVE DNS records as soon as the state is non-processing // (valid or invalid or other) try { options.challenges[auth.type] .remove({ challenge: auth }) .catch(function (err) { err.action = 'challenge_remove'; err.altname = auth.altname; err.type = auth.type; ACME._notify(me, options, 'error', err); }); } catch (e) {} if ('valid' === resp.body.status) { if (me.debug) { console.debug('poll: valid'); } return resp.body; } var errmsg; if (!resp.body.status) { errmsg = "[ACME.js] (E_STATE_EMPTY) empty challenge state for '" + altname + "':" + JSON.stringify(resp.body); } else if ('invalid' === resp.body.status) { errmsg = "[ACME.js] (E_STATE_INVALID) challenge state for '" + altname + "': '" + //resp.body.status + JSON.stringify(resp.body) + "'"; } else { errmsg = "[ACME.js] (E_STATE_UKN) challenge state for '" + altname + "': '" + resp.body.status + "'"; } return Promise.reject(new Error(errmsg)); } function transformError(e) { var err = e; if (err.urn) { err = new Error( '[acme-v2] ' + auth.altname + ' status:' + e.status + ' ' + e.detail ); err.auth = auth; err.altname = auth.altname; err.type = auth.type; err.code = 'invalid' === e.status ? 'E_ACME_CHALLENGE' : 'E_ACME_UNKNOWN'; } throw err; } function respondToChallenge() { //#console.debug('[ACME.js] responding to accept challenge:'); // POST-as-POST (empty JSON object) return U._jwsRequest(me, { accountKey: options.accountKey, url: auth.url, protected: { kid: kid }, payload: Enc.strToBuf(JSON.stringify({})) }) .then(checkResult) .catch(transformError); } return respondToChallenge(); }; // options = { domains, claims, challenges } ACME._setChallenges = function (me, options, order) { var claims = order._claims.slice(0); var valids = []; var auths = []; var placed = []; var USE_DNS = false; var DNS_DELAY = 0; // Set any challenges, excpting ones that have already been validated function setNext() { var claim = claims.shift(); // check false for testing if (!claim || false === options.challenges) { return Promise.resolve(); } return Promise.resolve() .then(function () { // For any challenges that are already valid, // add to the list and skip any checks. if ( claim.challenges.some(function (ch) { if ('valid' === ch.status) { valids.push(ch); return true; } }) ) { return; } var selected = ACME._chooseChallenge(options, claim); if (!selected) { throw E.NO_SUITABLE_CHALLENGE( claim.altname, claim.challenges, options._presenterTypes ); } auths.push(selected); placed.push(selected); ACME._notify(me, options, 'challenge_select', { // API-locked altname: ACME._untame( claim.identifier.value, claim.wildcard ), type: selected.type, dnsHost: selected.dnsHost, keyAuthorization: selected.keyAuthorization }); ACME._notify(me, options, '_challenge_select', { altname: ACME._untame( claim.identifier.value, claim.wildcard ), type: selected.type, challenge: selected }); // Set a delay for nameservers a moment to propagate if ('dns-01' === selected.type) { if (options.challenges['dns-01'] && !USE_DNS) { USE_DNS = true; DNS_DELAY = parseInt( options.challenges['dns-01'].propagationDelay, 10 ); } } var ch = options.challenges[selected.type] || {}; if (!ch.set) { throw new Error('no handler for setting challenge'); } return ch.set({ challenge: selected }); }) .then(setNext); } function waitAll() { //#console.debug('\n[DEBUG] waitChallengeDelay %s\n', DELAY); if (!DNS_DELAY || DNS_DELAY <= 0) { DNS_DELAY = 5000; } return ACME._wait(DNS_DELAY); } function checkNext() { var auth = auths.shift(); if (!auth) { return Promise.resolve(valids); } // These are not as much "valids" as they are "not invalids" if (!me._canCheck[auth.type] || me.skipChallengeTest) { valids.push(auth); return checkNext(); } return ACME.challengeTests[auth.type](me, { challenge: auth }) .then(function () { valids.push(auth); }) .then(checkNext); } function removeAll(ch) { options.challenges[ch.type] .remove({ challenge: ch }) .catch(function (err) { err.action = 'challenge_remove'; err.altname = ch.altname; err.type = ch.type; ACME._notify(me, options, 'error', err); }); } // The reason we set every challenge in a batch first before checking any // is so that we don't poison our own DNS cache with misses. return setNext() .then(waitAll) .then(checkNext) .catch(function (err) { if (!options.debug) { placed.forEach(removeAll); } throw err; }); }; ACME._presentChallenges = function (me, options, kid, readyToPresent) { // Actually sets the challenge via ACME function challengeNext() { // First set, First presented var auth = readyToPresent.shift(); if (!auth) { return Promise.resolve(); } return ACME._postChallenge(me, options, kid, auth).then(challengeNext); } // BTW, these are done serially rather than parallel on purpose // (rate limits, propagation delays, etc) return challengeNext().then(function () { return readyToPresent; }); }; ACME._pollOrderStatus = function (me, options, kid, order, verifieds) { var csr64 = ACME._csrToUrlBase64(options.csr); var body = { csr: csr64 }; var payload = JSON.stringify(body); function processResponse(resp) { ACME._notify(me, options, 'certificate_status', { subject: options.domains[0], status: resp.body.status }); // https://tools.ietf.org/html/draft-ietf-acme-acme-12#section-7.1.3 // Possible values are: "pending" => ("invalid" || "ready") => "processing" => "valid" if ('valid' === resp.body.status) { var voucher = resp.body; voucher._certificateUrl = resp.body.certificate; return voucher; } if ('processing' === resp.body.status) { return ACME._wait().then(pollStatus); } if (me.debug) { console.debug( 'Error: bad status:\n' + JSON.stringify(resp.body, null, 2) ); } if ('pending' === resp.body.status) { return Promise.reject( new Error( "Did not finalize order: status 'pending'." + ' Best guess: You have not accepted at least one challenge for each domain:\n' + "Requested: '" + options.domains.join(', ') + "'\n" + "Validated: '" + verifieds.join(', ') + "'\n" + JSON.stringify(resp.body, null, 2) ) ); } if ('invalid' === resp.body.status) { return Promise.reject( E.ORDER_INVALID(options, verifieds, resp) ); } if ('ready' === resp.body.status) { return Promise.reject( E.DOUBLE_READY_ORDER(options, verifieds, resp) ); } return Promise.reject( E.UNHANDLED_ORDER_STATUS(options, verifieds, resp) ); } function pollStatus() { return U._jwsRequest(me, { accountKey: options.accountKey, url: order._orderUrl, protected: { kid: kid }, payload: Enc.binToBuf('') }).then(processResponse); } function finalizeOrder() { //#console.debug('[ACME.js] pollCert:', order._finalizeUrl); return U._jwsRequest(me, { accountKey: options.accountKey, url: order._finalizeUrl, protected: { kid: kid }, payload: Enc.strToBuf(payload) }).then(processResponse); } return finalizeOrder(); }; ACME._redeemCert = function (me, options, kid, voucher) { //#console.debug('ACME.js: order was finalized'); // POST-as-GET return U._jwsRequest(me, { accountKey: options.accountKey, url: voucher._certificateUrl, protected: { kid: kid }, payload: Enc.binToBuf(''), json: true }).then(function (resp) { //#console.debug('ACME.js: 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: voucher.expires, identifiers: voucher.identifiers, //, authorizations: order.authorizations cert: certsarr.shift(), //, privkey: privkeyPem chain: certsarr.join('\n') }; //#console.debug(certs); return certs; }); }; ACME._finalizeOrder = function (me, options, kid, order) { //#console.debug('[ACME.js] finalizeOrder:'); var readyToPresent; return A._getAccountKid(me, options).then(function (kid) { return ACME._setChallenges(me, options, order) .then(function (_readyToPresent) { readyToPresent = _readyToPresent; return ACME._presentChallenges( me, options, kid, readyToPresent ); }) .then(function () { return ACME._pollOrderStatus( me, options, kid, order, readyToPresent.map(function (ch) { return ACME._untame(ch.identifier.value, ch.wildcard); }) ); }) .then(function (voucher) { return ACME._redeemCert(me, options, kid, voucher); }); }); }; // Order a certificate request with all domains ACME._orderCert = function (me, options, kid) { var certificateRequest = { // raw wildcard syntax MUST be used here identifiers: options.domains.map(function (hostname) { return { type: 'dns', value: hostname }; }) //, "notBefore": "2016-01-01T00:00:00Z" //, "notAfter": "2016-01-08T00:00:00Z" }; return ACME._prepRequest(me, options) .then(function () { return ACME._getZones(me, options.challenges, options.domains); }) .then(function (zonenames) { var p; // Do a little dry-run / self-test if (!me.skipDryRun && !options.skipDryRun) { p = ACME._dryRun(me, options, zonenames); } else { p = Promise.resolve(null); } return p.then(function () { return A._getAccountKid(me, options) .then(function (kid) { ACME._notify(me, options, 'certificate_order', { // API-locked account: { key: { kid: kid } }, subject: options.domains[0], altnames: options.domains, challengeTypes: options._presenterTypes }); var payload = JSON.stringify(certificateRequest); //#console.debug('\n[DEBUG] newOrder\n'); return U._jwsRequest(me, { accountKey: options.accountKey, url: me._directoryUrls.newOrder, protected: { kid: kid }, payload: Enc.binToBuf(payload) }); }) .then(function (resp) { var order = resp.body; order._orderUrl = resp.headers.location; order._finalizeUrl = resp.body.finalize; order._identifiers = certificateRequest.identifiers; //#console.debug('[ordered]', location); // the account id url //#console.debug(resp); if (!order.authorizations) { return Promise.reject( E.NO_AUTHORIZATIONS(options, resp) ); } return order; }) .then(function (order) { return ACME._getAllChallenges( me, options, kid, zonenames, order ).then(function (claims) { order._claims = claims; return order; }); }); }); }); }; ACME._prepRequest = function (me, options) { return Promise.resolve().then(function () { // TODO check that all presenterTypes are represented in challenges if (!options._presenterTypes.length) { return Promise.reject( new Error('options.challenges must be specified') ); } if (!options.csr) { throw new Error( 'no `csr` option given (should be in DER or PEM format)' ); } // TODO validate csr signature? var _csr = CSR._info(options.csr); options.domains = options.domains || _csr.altnames; _csr.altnames = _csr.altnames || []; if ( options.domains.slice(0).sort().join(' ') !== _csr.altnames.slice(0).sort().join(' ') ) { return Promise.reject( new Error('certificate altnames do not match requested domains') ); } if (_csr.subject !== options.domains[0]) { return Promise.reject( new Error( 'certificate subject (commonName) does not match first altname (SAN)' ) ); } 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 certificate' ) ); } // a cheap check to see if there are non-ascii characters in any of the domains var nonAsciiDomains = options.domains.some(function (d) { // IDN / unicode / utf-8 / punycode return Enc.strToBin(d) !== d; }); if (nonAsciiDomains) { throw new Error( "please use the 'punycode' module to convert unicode domain names to punycode" ); } // TODO Promise.all()? (options._presenterTypes || []).forEach(function (key) { var presenter = options.challenges[key]; if ( 'function' === typeof presenter.init && !presenter._acme_initialized ) { presenter._acme_initialized = true; return presenter.init({ type: '*', request: me.request }); } }); }); }; // Request a challenge for each authorization in the order ACME._getAllChallenges = function (me, options, kid, zonenames, order) { var claims = []; //#console.debug("[acme-v2] POST newOrder has authorizations"); var challengeAuths = order.authorizations.slice(0); function getNext() { var authUrl = challengeAuths.shift(); if (!authUrl) { return claims; } return ACME._getAuthorization( me, options, kid, zonenames, authUrl ).then(function (claim) { // var domain = options.domains[i]; // claim.identifier.value claims.push(claim); return getNext(); }); } return getNext().then(function () { return claims; }); }; ACME.formatPemChain = function formatPemChain(str) { return ( str .trim() .replace(/[\r\n]+/g, '\n') .replace(/\-\n\-/g, '-\n\n-') + '\n' ); }; ACME.splitPemChain = function splitPemChain(str) { return str .trim() .split(/[\r\n]{2,}/g) .map(function (str) { return str + '\n'; }); }; ACME._csrToUrlBase64 = function (csr) { // if der, convert to base64 if ('string' !== typeof csr) { csr = Enc.bufToUrlBase64(csr); } // TODO use PEM.parseBlock() // nix PEM headers, if any if ('-' === csr[0]) { csr = csr.split(/\n+/).slice(1, -1).join(''); } return Enc.base64ToUrlBase64(csr.trim().replace(/\s+/g, '')); }; // In v8 this is crypto random, but we're just using it for pseudorandom ACME._prnd = function (n) { var rnd = ''; while (rnd.length / 2 < n) { var i = Math.random().toString().substr(2); var h = parseInt(i, 10).toString(16); if (h.length % 2) { h = '0' + h; } rnd += h; } return rnd.substr(0, n * 2); }; ACME._notify = function (me, options, ev, params) { if (!options.notify && !me.notify) { //console.info(ev, params); return; } try { (options.notify || me.notify)(ev, params); } catch (e) { console.error('`acme.notify(ev, params)` Error:'); console.error(e); } }; ACME._wait = function wait(ms) { return new Promise(function (resolve) { setTimeout(resolve, ms || 1100); }); }; 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]; }