From 24c3633d75f8f61068597812d16e0a3ae660aa40 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sat, 5 Oct 2019 05:21:07 -0600 Subject: [PATCH] WIP gets a cert... nice! --- lib/acme.js | 703 +++++++++++++++++++++++++++---------------- lib/csr.js | 22 +- lib/keypairs.js | 9 +- lib/node/keypairs.js | 2 +- tests/index.js | 52 +++- 5 files changed, 503 insertions(+), 285 deletions(-) diff --git a/lib/acme.js b/lib/acme.js index 793089a..6c20a34 100644 --- a/lib/acme.js +++ b/lib/acme.js @@ -165,7 +165,7 @@ ACME._registerAccount = function(me, options) { } else if (options.email) { contact = ['mailto:' + options.email]; } - var body = { + var accountRequest = { termsOfServiceAgreed: tosUrl === me._tos, onlyReturnExisting: false, contact: contact @@ -182,14 +182,14 @@ ACME._registerAccount = function(me, options) { }, payload: Enc.strToBuf(JSON.stringify(pair.public)) }).then(function(jws) { - body.externalAccountBinding = jws; - return body; + accountRequest.externalAccountBinding = jws; + return accountRequest; }); } else { - pExt = Promise.resolve(body); + pExt = Promise.resolve(accountRequest); } - return pExt.then(function(body) { - var payload = JSON.stringify(body); + return pExt.then(function(accountRequest) { + var payload = JSON.stringify(accountRequest); return ACME._jwsRequest(me, { options: options, url: me._directoryUrls.newAccount, @@ -199,10 +199,20 @@ ACME._registerAccount = function(me, options) { .then(function(resp) { var account = resp.body; - if (2 !== Math.floor(resp.statusCode / 100)) { + if ( + resp.statusCode < 200 || + resp.statusCode >= 300 + ) { + if ('string' !== typeof account) { + account = JSON.stringify(account); + } throw new Error( 'account error: ' + - JSON.stringify(resp.body) + resp.statusCode + + ' ' + + account + + '\n' + + JSON.stringify(accountRequest) ); } @@ -344,7 +354,24 @@ ACME._testChallengeOptions = function() { ]; }; ACME._testChallenges = function(me, options) { + console.log('[debug] testChallenges'); var CHECK_DELAY = 0; + + // memoized so that it doesn't run until it's first called + var getThumbnail = function() { + var thumbPromise = ACME._importKeypair(me, options.accountKeypair).then( + function(pair) { + return me.Keypairs.thumbprint({ + jwk: pair.public + }); + } + ); + getThumbnail = function() { + return thumbPromise; + }; + return thumbPromise; + }; + return Promise.all( options.domains.map(function(identifierValue) { // TODO we really only need one to pass, not all to pass @@ -389,10 +416,11 @@ ACME._testChallenges = function(me, options) { if ('dns-01' === challenge.type) { // Give the nameservers a moment to propagate - CHECK_DELAY = 1.5 * 1000; + // TODO get this value from the plugin + CHECK_DELAY = 7 * 1000; } - return Promise.resolve().then(function() { + return getThumbnail().then(function(accountKeyThumb) { var results = { identifier: { type: 'dns', @@ -409,6 +437,7 @@ ACME._testChallenges = function(me, options) { return ACME._challengeToAuth( me, options, + accountKeyThumb, results, challenge, dryrun @@ -460,7 +489,14 @@ ACME._chooseChallenge = function(options, results) { return challenge; }; -ACME._challengeToAuth = function(me, options, request, challenge, dryrun) { +ACME._challengeToAuth = function( + me, + options, + accountKeyThumb, + request, + challenge, + dryrun +) { // we don't poison the dns cache with our dummy request var dnsPrefix = ACME.challengePrefixes['dns-01']; if (dryrun) { @@ -486,38 +522,58 @@ ACME._challengeToAuth = function(me, options, request, challenge, dryrun) { auth[key] = challenge[key]; }); + var zone = pluckZone(options.zonenames || [], auth.identifier.value); // 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); - return ACME._importKeypair(me, options.accountKeypair).then(function(pair) { - return me.Keypairs.thumbprint({ jwk: pair.public }).then(function( - thumb - ) { - auth.thumbprint = thumb; - // keyAuthorization = token || '.' || base64url(JWK_Thumbprint(accountKey)) - auth.keyAuthorization = challenge.token + '.' + auth.thumbprint; - // conflicts with ACME challenge id url is already in use, so we call this challengeUrl instead - // TODO auth.http01Url ? - auth.challengeUrl = - 'http://' + - auth.identifier.value + - ACME.challengePrefixes['http-01'] + - '/' + - auth.token; - auth.dnsHost = dnsPrefix + '.' + auth.hostname.replace('*.', ''); + // we must accept JWKs that we didn't generate and we can't guarantee + // that they properly set kid to thumbnail (especially since ACME doesn't do this) + // so we have to regenerate it every time we need it, which is quite often + auth.thumbprint = accountKeyThumb; + // keyAuthorization = token || '.' || base64url(JWK_Thumbprint(accountKey)) + auth.keyAuthorization = challenge.token + '.' + auth.thumbprint; + // conflicts with ACME challenge id url is already in use, so we call this challengeUrl instead + // TODO auth.http01Url ? + auth.challengeUrl = + 'http://' + + auth.identifier.value + + ACME.challengePrefixes['http-01'] + + '/' + + auth.token; + auth.dnsHost = dnsPrefix + '.' + auth.hostname.replace('*.', ''); - return sha2 - .sum(256, auth.keyAuthorization) - .then(function(hash) { - return Enc.bufToUrlBase64(new Uint8Array(hash)); - }) - .then(function(hash64) { - auth.dnsAuthorization = hash64; - return auth; - }); + // 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, auth.keyAuthorization) + .then(function(hash) { + return Enc.bufToUrlBase64(new Uint8Array(hash)); + }) + .then(function(hash64) { + auth.dnsAuthorization = hash64; + if (zone) { + auth.dnsZone = zone; + auth.dnsPrefix = auth.dnsHost + .replace(newZoneRegExp(zone), '') + .replace(/\.$/, ''); + } + + // For backwards compat with the v2.7 plugins + auth.challenge = auth; + // TODO can we use just { challenge: auth }? + auth.request = function() { + // TODO see https://git.rootprojects.org/root/acme.js/issues/### + console.warn( + "[warn] deprecated use of request on '" + + auth.type + + "' challenge object. Receive from challenger.init() instead." + ); + me.request.apply(null, arguments); + }; + return auth; }); - }); }; ACME._untame = function(name, wild) { @@ -597,7 +653,7 @@ ACME._postChallenge = function(me, options, auth) { .then(function(resp) { if ('processing' === resp.body.status) { if (me.debug) { - console.debug('poll: again'); + console.debug('poll: again', auth.url); } return ACME._wait(RETRY_INTERVAL).then(pollStatus); } @@ -610,14 +666,14 @@ ACME._postChallenge = function(me, options, auth) { .then(respondToChallenge); } if (me.debug) { - console.debug('poll: again'); + console.debug('poll: again', auth.url); } return ACME._wait(RETRY_INTERVAL).then(respondToChallenge); } if ('valid' === resp.body.status) { if (me.debug) { - console.debug('poll: valid'); + console.debug('VALID !!!!!!!!!!!!!!!! poll: valid'); } try { @@ -637,7 +693,8 @@ ACME._postChallenge = function(me, options, auth) { "[acme-v2] (E_STATE_INVALID) challenge state for '" + altname + "': '" + - resp.body.status + + //resp.body.status + + JSON.stringify(resp.body) + "'"; } else { errmsg = @@ -675,17 +732,20 @@ ACME._postChallenge = function(me, options, auth) { return respondToChallenge(); }; ACME._setChallenge = function(me, options, auth) { - return new Promise(function(resolve, reject) { + return Promise.resolve().then(function() { var challengers = options.challenges || {}; - var challenger = - (challengers[auth.type] && challengers[auth.type].set) || - options.setChallenge; - try { - if (1 === challenger.length) { - challenger(auth) - .then(resolve) - .catch(reject); - } else if (2 === challenger.length) { + var challenger = challengers[auth.type] && challengers[auth.type].set; + if (!challenger) { + throw new Error( + "options.challenges did not have a valid entry for '" + + auth.type + + "'" + ); + } + if (1 === challenger.length) { + return Promise.resolve(challenger(auth)); + } else if (2 === challenger.length) { + return new Promise(function(resolve, reject) { challenger(auth, function(err) { if (err) { reject(err); @@ -693,45 +753,12 @@ ACME._setChallenge = function(me, options, auth) { resolve(); } }); - } else { - // TODO remove this old backwards-compat - var challengeCb = function(err) { - 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]; - }); - 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; - } - challenger( - auth.identifier.value, - auth.token, - auth.keyAuthorization, - challengeCb - ); - } - } catch (e) { - reject(e); + }); + } else { + throw new Error( + "Bad function signature for '" + auth.type + "' challenge.set()" + ); } - }).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) { @@ -943,170 +970,234 @@ ACME._getCertificate = function(me, options) { }); } - // Do a little dry-run / self-test - return ACME._testChallenges(me, options).then(function() { - if (me.debug) { - console.debug('[acme-v2] certificates.create'); + // TODO Promise.all()? + Object.keys(options.challenges).forEach(function(key) { + var presenter = options.challenges[key]; + if ('function' === typeof presenter.init && !presenter._initialized) { + presenter._initialized = true; + return ACME._depInit(me, presenter); } - var body = { - // 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) { + }); + + var promiseZones; + if (options.challenges['dns-01']) { + // a little bit of random to ensure that getZones() + // actually returns the zones and not the hosts as zones + var dnsHosts = options.domains.map(function(d) { + var rnd = require('crypto') + .randomBytes(2) + .toString('hex'); + return rnd + '.' + d; + }); + promiseZones = ACME._getZones( + me, + options.challenges['dns-01'], + dnsHosts + ); + } else { + promiseZones = Promise.resolve([]); + } + + return promiseZones + .then(function(zonenames) { + options.zonenames = zonenames; + // Do a little dry-run / self-test + return ACME._testChallenges(me, options); + }) + .then(function() { + if (me.debug) { + console.debug('[acme-v2] certificates.create'); + } + var certOrder = { + // 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; - } - 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" - }; - - var payload = JSON.stringify(body); - if (me.debug) { - console.debug('\n[DEBUG] newOrder\n'); - } - return ACME._jwsRequest(me, { - options: options, - url: me._directoryUrls.newOrder, - protected: { kid: options._kid }, - payload: Enc.strToBuf(payload) - }).then(function(resp) { - var location = resp.headers.location; - var setAuths; - var validAuths = []; - var auths = []; - if (me.debug) { - console.debug('[ordered]', location); - } // the account id url - if (me.debug) { - console.debug(resp); - } - options._authorizations = resp.body.authorizations; - options._order = location; - options._finalize = resp.body.finalize; - //if (me.debug) console.debug('[DEBUG] finalize:', options._finalize); return; - - if (!options._authorizations) { - return Promise.reject( - new Error( - "[acme-v2.js] authorizations were not fetched for '" + - options.domains.join() + - "':\n" + - JSON.stringify(resp.body) - ) - ); - } - if (me.debug) { - console.debug('[acme-v2] POST newOrder has authorizations'); - } - setAuths = options._authorizations.slice(0); - - function setNext() { - var authUrl = setAuths.shift(); - if (!authUrl) { - return; - } - - return ACME._getChallenges(me, options, authUrl).then(function( - results - ) { - // var domain = options.domains[i]; // results.identifier.value - - // If it's already valid, we're golden it regardless - if ( - results.challenges.some(function(ch) { - return 'valid' === ch.status; - }) - ) { - return setNext(); - } - - var challenge = ACME._chooseChallenge(options, results); - if (!challenge) { - // For example, wildcards require dns-01 and, if we don't have that, we have to bail - return Promise.reject( - new Error( - "Server didn't offer any challenge we can handle for '" + - options.domains.join() + - "'." - ) - ); - } - - return ACME._challengeToAuth( - me, - options, - results, - challenge, - false - ).then(function(auth) { - auths.push(auth); - return ACME._setChallenge(me, options, auth).then( - setNext - ); - }); - }); - } - - function checkNext() { - var auth = auths.shift(); - if (!auth) { - return; - } - - if (!me._canUse[auth.type] || me.skipChallengeTest) { - // not so much "valid" as "not invalid" - // but in this case we can't confirm either way - validAuths.push(auth); - return Promise.resolve(); - } - - return ACME.challengeTests[auth.type](me, auth) - .then(function() { - validAuths.push(auth); }) - .then(checkNext); - } + .map(function(hostname) { + return { type: 'dns', value: hostname }; + }) + //, "notBefore": "2016-01-01T00:00:00Z" + //, "notAfter": "2016-01-08T00:00:00Z" + }; - function challengeNext() { - var auth = validAuths.shift(); - if (!auth) { - return; + var payload = JSON.stringify(certOrder); + if (me.debug) { + console.debug('\n[DEBUG] newOrder\n'); + } + return ACME._jwsRequest(me, { + options: options, + url: me._directoryUrls.newOrder, + protected: { kid: options._kid }, + payload: Enc.strToBuf(payload) + }).then(function(resp) { + var location = resp.headers.location; + var setAuths; + var validAuths = []; + var auths = []; + if (me.debug) { + console.debug('[ordered]', location); + } // the account id url + if (me.debug) { + console.debug(resp); } - return ACME._postChallenge(me, options, auth).then( - challengeNext - ); - } + options._authorizations = resp.body.authorizations; + options._order = location; + options._finalize = resp.body.finalize; + //if (me.debug) console.debug('[DEBUG] finalize:', options._finalize); return; - // 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(checkNext) - .then(challengeNext) - .then(function() { + if (!options._authorizations) { + return Promise.reject( + new Error( + "[acme-v2.js] authorizations were not fetched for '" + + options.domains.join() + + "':\n" + + JSON.stringify(resp.body) + ) + ); + } + if (me.debug) { + console.debug('[acme-v2] POST newOrder has authorizations'); + } + setAuths = options._authorizations.slice(0); + + var accountKeyThumb; + function setThumbnail() { + return ACME._importKeypair(me, options.accountKeypair).then( + function(pair) { + return me.Keypairs.thumbprint({ + jwk: pair.public + }).then(function(_thumb) { + accountKeyThumb = _thumb; + }); + } + ); + } + + function setNext() { + var authUrl = setAuths.shift(); + if (!authUrl) { + return; + } + + return ACME._getChallenges(me, options, authUrl).then( + function(results) { + // var domain = options.domains[i]; // results.identifier.value + + // If it's already valid, we're golden it regardless + if ( + results.challenges.some(function(ch) { + return 'valid' === ch.status; + }) + ) { + return setNext(); + } + + var challenge = ACME._chooseChallenge( + options, + results + ); + if (!challenge) { + // For example, wildcards require dns-01 and, if we don't have that, we have to bail + return Promise.reject( + new Error( + "Server didn't offer any challenge we can handle for '" + + options.domains.join() + + "'." + ) + ); + } + + return ACME._challengeToAuth( + me, + options, + accountKeyThumb, + results, + challenge, + false + ).then(function(auth) { + console.log('ADD DUBIOUS AUTH'); + auths.push(auth); + return ACME._setChallenge( + me, + options, + auth + ).then(setNext); + }); + } + ); + } + + function waitAll() { + // TODO take the max wait of all challenge plugins and wait that long, or 1000ms + var DELAY = me.setChallengeWait || 7000; + if (true || me.debug) { + console.debug( + '\n[DEBUG] waitChallengeDelay %s\n', + DELAY + ); + } + return ACME._wait(DELAY); + } + + function checkNext() { + console.log('CONSUME DUBIOUS AUTH', auths.length); + var auth = auths.shift(); + if (!auth) { + return; + } + + if (!me._canUse[auth.type] || me.skipChallengeTest) { + // not so much "valid" as "not invalid" + // but in this case we can't confirm either way + validAuths.push(auth); + console.log('ADD VALID AUTH (skip)', validAuths.length); + return checkNext(); + } + + return ACME.challengeTests[auth.type](me, auth) + .then(function() { + console.log('ADD VALID AUTH'); + validAuths.push(auth); + }) + .then(checkNext); + } + + function presentNext() { + console.log('CONSUME VALID AUTH', validAuths.length); + var auth = validAuths.shift(); + if (!auth) { + return; + } + return ACME._postChallenge(me, options, auth).then( + presentNext + ); + } + + function finalizeOrder() { if (me.debug) { console.debug('[getCertificate] next.then'); } - var validatedDomains = body.identifiers.map(function( + var validatedDomains = certOrder.identifiers.map(function( ident ) { return ident.value; }); return ACME._finalizeOrder(me, options, validatedDomains); - }) - .then(function(order) { + } + + function retrieveCerts(order) { if (me.debug) { console.debug('acme-v2: order was finalized'); } @@ -1141,10 +1232,22 @@ ACME._getCertificate = function(me, options) { } return certs; }); - }); + } + + // First we set each and 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 setThumbnail() + .then(setNext) + .then(waitAll) + .then(checkNext) + .then(presentNext) + .then(finalizeOrder) + .then(retrieveCerts); + }); }); - }); }; + ACME._generateCsrWeb64 = function(me, options, validatedDomains) { var csr; if (options.csr) { @@ -1153,6 +1256,7 @@ ACME._generateCsrWeb64 = function(me, options, validatedDomains) { if ('string' !== typeof csr) { csr = Enc.bufToUrlBase64(csr); } + // TODO PEM.parseBlock() // nix PEM headers, if any if ('-' === csr[0]) { csr = csr @@ -1168,15 +1272,13 @@ ACME._generateCsrWeb64 = function(me, options, validatedDomains) { me, options.serverKeypair || options.domainKeypair ).then(function(pair) { - return me - .CSR({ - jwk: pair.private, - domains: validatedDomains, - encoding: 'der' - }) - .then(function(der) { - return Enc.bufToUrlBase64(der); - }); + return me.CSR.csr({ + jwk: pair.private, + domains: validatedDomains, + encoding: 'der' + }).then(function(der) { + return Enc.bufToUrlBase64(der); + }); }); }; @@ -1276,6 +1378,7 @@ ACME._jwsRequest = function(me, bigopts) { bigopts.protected.kid = bigopts.options._kid; } } + // this will shasum the thumbnail the 2nd time return me.Keypairs.signJws({ jwk: bigopts.options.accountKeypair.privateKeyJwk, protected: bigopts.protected, @@ -1291,6 +1394,7 @@ ACME._jwsRequest = function(me, bigopts) { }); }); }; + // Handle some ACME-specific defaults ACME._request = function(me, opts) { if (!opts.headers) { @@ -1430,24 +1534,99 @@ ACME._http01 = function(me, auth) { ACME._removeChallenge = function(me, options, auth) { var challengers = options.challenges || {}; var removeChallenge = - (challengers[auth.type] && challengers[auth.type].remove) || - options.removeChallenge; + challengers[auth.type] && challengers[auth.type].remove; if (1 === removeChallenge.length) { - removeChallenge(auth).then(function() {}, function() {}); + return Promise.resolve(removeChallenge(auth)).then( + function() {}, + function() {} + ); } else if (2 === removeChallenge.length) { removeChallenge(auth, function(err) { return err; }); } else { - 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; - } - removeChallenge(auth.request.identifier, auth.token, function() {}); + throw new Error( + "Bad function signature for '" + auth.type + "' challenge.remove()" + ); } }; + +ACME._depInit = function(me, presenter) { + if ('function' !== typeof presenter.init) { + return Promise.resolve(null); + } + return ACME._wrapCb( + me, + presenter, + 'init', + { type: '*', request: me.request }, + 'null' + ); +}; + +ACME._getZones = function(me, presenter, dnsHosts) { + if ('function' !== typeof presenter.zones) { + presenter.zones = function() { + return Promise.resolve([]); + }; + } + var challenge = { + type: 'dns-01', + dnsHosts: dnsHosts, + request: me.request + }; + // back/forwards-compat + challenge.challenge = challenge; + return ACME._wrapCb( + me, + presenter, + 'zones', + challenge, + 'an array of zone names' + ); +}; + +ACME._wrapCb = function(me, options, _name, args, _desc) { + return new Promise(function(resolve, reject) { + if (options[_name].length <= 1) { + return Promise.resolve(options[_name](args)) + .then(resolve) + .catch(reject); + } else if (2 === options[_name].length) { + options[_name](args, function(err, results) { + if (err) { + reject(err); + } else { + resolve(results); + } + }); + } else { + throw new Error( + 'options.' + _name + ' should accept opts and Promise ' + _desc + ); + } + }); +}; + +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]; +} diff --git a/lib/csr.js b/lib/csr.js index 966506e..a75494d 100644 --- a/lib/csr.js +++ b/lib/csr.js @@ -5,18 +5,19 @@ 'use strict'; /*global Promise*/ -var ASN1 = require('./asn1/parser.js'); // DER, actually +var ASN1 = require('./asn1/packer.js'); // DER, actually var Asn1 = ASN1.Any; var BitStr = ASN1.BitStr; var UInt = ASN1.UInt; -var Asn1Parser = require('./asn1/packer.js'); // DER, actually +var Asn1Parser = require('./asn1/parser.js'); var Enc = require('omnibuffer'); var PEM = require('./pem.js'); var X509 = require('./x509.js'); var Keypairs = require('./keypairs'); // TODO find a way that the prior node-ish way of `module.exports = function () {}` isn't broken -var CSR = (exports.CSR = function(opts) { +var CSR = module.exports; +CSR.csr = function(opts) { // We're using a Promise here to be compatible with the browser version // which will probably use the webcrypto API for some of the conversions return CSR._prepare(opts).then(function(opts) { @@ -24,11 +25,10 @@ var CSR = (exports.CSR = function(opts) { return CSR._encode(opts, bytes); }); }); -}); +}; CSR._prepare = function(opts) { return Promise.resolve().then(function() { - var Keypairs; opts = JSON.parse(JSON.stringify(opts)); // We do a bit of extra error checking for user convenience @@ -66,16 +66,6 @@ CSR._prepare = function(opts) { throw new Error('You must pass options.key as a JSON web key'); } - Keypairs = exports.Keypairs; - if (!exports.Keypairs) { - throw new Error( - 'Keypairs.js is an optional dependency for PEM-to-JWK.\n' + - "Install it if you'd like to use it:\n" + - '\tnpm install --save rasha\n' + - 'Otherwise supply a jwk as the private key.' - ); - } - return Keypairs.import({ pem: opts.pem || opts.key }).then(function( pair ) { @@ -119,7 +109,7 @@ CSR._sign = function csrEcSig(jwk, request) { // Took some tips from https://gist.github.com/codermapuche/da4f96cdb6d5ff53b7ebc156ec46a10a // TODO will have to convert web ECDSA signatures to PEM ECDSA signatures (but RSA should be the same) // TODO have a consistent non-private way to sign - return Keypairs._sign( + return Keypairs.sign( { jwk: jwk, format: 'x509' }, Enc.hexToBuf(request) ).then(function(sig) { diff --git a/lib/keypairs.js b/lib/keypairs.js index 4037a0e..6bd5c8a 100644 --- a/lib/keypairs.js +++ b/lib/keypairs.js @@ -76,12 +76,13 @@ Keypairs.neuter = function(opts) { }; Keypairs.thumbprint = function(opts) { + //console.log('[debug]', new Error('NOT_ERROR').stack); return Promise.resolve().then(function() { if (/EC/i.test(opts.jwk.kty)) { - console.log('[debug] EC thumbprint'); + console.log('[debug] EC thumbprint'); return Eckles.thumbprint(opts); } else { - console.log('[debug] RSA thumbprint'); + console.log('[debug] RSA thumbprint'); return Rasha.thumbprint(opts); } }); @@ -121,6 +122,7 @@ Keypairs.publish = function(opts) { // JWT a.k.a. JWS with Claims using Compact Serialization Keypairs.signJwt = function(opts) { + console.log('[debug] signJwt'); return Keypairs.thumbprint({ jwk: opts.jwk }).then(function(thumb) { var header = opts.header || {}; var claims = JSON.parse(JSON.stringify(opts.claims || {})); @@ -255,6 +257,9 @@ Keypairs.signJws = function(opts) { }); }; +// TODO expose consistently +Keypairs.sign = native._sign; + Keypairs._getBits = function(opts) { if (opts.alg) { return opts.alg.replace(/[a-z\-]/gi, ''); diff --git a/lib/node/keypairs.js b/lib/node/keypairs.js index 5528453..cf24a36 100644 --- a/lib/node/keypairs.js +++ b/lib/node/keypairs.js @@ -15,7 +15,7 @@ Keypairs._sign = function(opts, payload) { .update(payload) .sign(pem); - if ('EC' === opts.jwk.kty) { + if ('EC' === opts.jwk.kty && !/x509|asn1/i.test(opts.format)) { // ECDSA JWT signatures differ from "normal" ECDSA signatures // https://tools.ietf.org/html/rfc7518#section-3.4 binsig = Keypairs._ecdsaAsn1SigToJoseSig(binsig); diff --git a/tests/index.js b/tests/index.js index eed4c3a..315f5bf 100644 --- a/tests/index.js +++ b/tests/index.js @@ -1,18 +1,39 @@ 'use strict'; +require('dotenv').config(); + var ACME = require('../'); var Keypairs = require('../lib/keypairs.js'); -var acme = ACME.create({}); +var acme = ACME.create({ debug: true }); + +// TODO exec npm install --save-dev CHALLENGE_MODULE var config = { env: process.env.ENV, email: process.env.SUBSCRIBER_EMAIL, - domain: process.env.BASE_DOMAIN + domain: process.env.BASE_DOMAIN, + challengeType: process.env.CHALLENGE_TYPE, + challengeModule: process.env.CHALLENGE_MODULE, + challengeOptions: JSON.parse(process.env.CHALLENGE_OPTIONS) }; config.debug = !/^PROD/i.test(config.env); +config.challenger = require('acme-' + + config.challengeType + + '-' + + config.challengeModule).create(config.challengeOptions); +if (!config.challengeType || !config.domain) { + console.error( + new Error('Missing config variables. Check you .env and the docs') + .message + ); + console.error(config); + process.exit(1); +} + +var challenges = {}; +challenges[config.challengeType] = config.challenger; async function happyPath() { - var domains = randomDomains(); var agreed = false; var metadata = await acme.init( 'https://acme-staging-v02.api.letsencrypt.org/directory' @@ -66,8 +87,31 @@ async function happyPath() { if (config.debug) { console.info('Server Key Created'); console.info(JSON.stringify(serverKeypair, null, 2)); - console.info(''); console.info(); + console.info(); + } + + var domains = randomDomains(); + if (config.debug) { + console.info('Get certificates for random domains:'); + console.info(domains); + } + var results = await acme.certificates.create({ + account: account, + accountKeypair: { privateKeyJwk: accountKeypair.private }, + serverKeypair: { privateKeyJwk: serverKeypair.private }, + domains: domains, + challenges: challenges, // must be implemented + skipDryRun: true + }); + + if (config.debug) { + console.info('Got SSL Certificate:'); + console.info(results.expires); + console.info(results.cert); + console.info(results.chain); + console.info(''); + console.info(''); } }