From 0efa94eeb0c9231c412e25925b0f747eb98edab6 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Thu, 24 Oct 2019 11:39:25 -0600 Subject: [PATCH] update API and tests --- acme.js | 263 +++++++++--------- tests/compute-authorization-response.js | 111 ++++++++ ...lchain-formats.js => format-pem-chains.js} | 85 +++--- tests/generate-cert-key.js | 22 +- tests/index.js | 245 ---------------- tests/issue-certificates.js | 253 +++++++++++++++++ utils.js | 27 +- 7 files changed, 570 insertions(+), 436 deletions(-) create mode 100644 tests/compute-authorization-response.js rename tests/{fullchain-formats.js => format-pem-chains.js} (50%) delete mode 100644 tests/index.js create mode 100644 tests/issue-certificates.js diff --git a/acme.js b/acme.js index 49999cf..13382f2 100644 --- a/acme.js +++ b/acme.js @@ -179,17 +179,17 @@ ACME._testChallengeOptions = function() { ACME._thumber = function(me, options, thumb) { var thumbPromise; - return function() { + return function(key) { if (thumb) { return Promise.resolve(thumb); } if (thumbPromise) { return thumbPromise; } - thumbPromise = U._importKeypair( - me, - options.accountKey || options.accountKeypair - ).then(function(pair) { + if (!key) { + key = options.accountKey || options.accountKeypair; + } + thumbPromise = U._importKeypair(null, key).then(function(pair) { return Keypairs.thumbprint({ jwk: pair.public }); @@ -266,7 +266,14 @@ ACME._dryRun = function(me, realOptions) { type: ch.type //challenge: ch }); - noopts.challenges[ch.type].remove({ challenge: ch }); + 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); + }); }); } @@ -310,95 +317,117 @@ ACME._computeAuths = function(me, options, thumb, request, dryrun) { ); } - var getThumbprint = ACME._thumber(me, options, thumb); + var getThumbprint = ACME._thumber(null, options, thumb); - return getThumbprint().then(function(thumb) { - return Promise.all( - request.challenges.map(function(challenge) { - // Don't do extra work for challenges that we can't satisfy - if (!options._presenterTypes.includes(challenge.type)) { - return null; - } + return Promise.all( + request.challenges.map(function(challenge) { + // Don't do extra work for challenges that we can't satisfy + if (!options._presenterTypes.includes(challenge.type)) { + return null; + } - var auth = {}; + var auth = {}; - // straight copy from the new order response - // { identifier, status, expires, challenges, wildcard } - Object.keys(request).forEach(function(key) { - auth[key] = request[key]; + // 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) { + // 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( + options.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); + }); +}; - // 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]; - }); +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(null, opts, thumb); + var dnsPrefix = opts.dnsPrefix || ACME.challengePrefixes['dns-01']; - // 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 getThumbprint(accountKey).then(function(thumb) { + var resp = {}; + resp.thumbprint = thumb; + // keyAuthorization = token + '.' + base64url(JWK_Thumbprint(accountKey)) + resp.keyAuthorization = auth.token + '.' + thumb; - auth.thumbprint = thumb; - // keyAuthorization = token + '.' + base64url(JWK_Thumbprint(accountKey)) - auth.keyAuthorization = challenge.token + '.' + auth.thumbprint; + 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 ('http-01' === auth.type) { - // 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; - return auth; - } + if ('dns-01' !== auth.type) { + return resp; + } - if ('dns-01' !== auth.type) { - return auth; - } - - var zone = pluckZone( - options.zonenames || [], - auth.identifier.value - ); - - // 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.dnsHost = - dnsPrefix + '.' + auth.hostname.replace('*.', ''); - - auth.dnsAuthorization = hash64; - auth.keyAuthorizationDigest = hash64; - - if (zone) { - auth.dnsZone = zone; - auth.dnsPrefix = auth.dnsHost - .replace(newZoneRegExp(zone), '') - .replace(/\.$/, ''); - } - - 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, resp.keyAuthorization) + .then(function(hash) { + return Enc.bufToUrlBase64(Uint8Array.from(hash)); }) - ).then(function(auths) { - return auths.filter(Boolean); - }); + .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; + }); }); }; @@ -583,6 +612,7 @@ 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; @@ -618,6 +648,7 @@ ACME._setChallenges = function(me, options, order) { ); } auths.push(selected); + placed.push(selected); ACME._notify(me, options, 'challenge_select', { // API-locked altname: ACME._untame( @@ -651,10 +682,13 @@ ACME._setChallenges = function(me, options, order) { function waitAll() { //#console.debug('\n[DEBUG] waitChallengeDelay %s\n', DELAY); if (!DNS_DELAY || DNS_DELAY <= 0) { - console.warn( - 'the given dns-01 challenge did not specify `propagationDelay`' - ); - console.warn('the default of 5000ms will be used'); + if (!ACME._propagationDelayWarning) { + console.warn( + 'warn: the given dns-01 challenge did not specify `propagationDelay`' + ); + console.warn('warn: the default of 5000ms will be used'); + ACME._propagationDelayWarning = true; + } DNS_DELAY = 5000; } return ACME._wait(DNS_DELAY); @@ -683,7 +717,22 @@ ACME._setChallenges = function(me, options, order) { // is so that we don't poison our own DNS cache with misses. return setNext() .then(waitAll) - .then(checkNext); + .then(checkNext) + .catch(function(err) { + if (!options.debug) { + placed.forEach(function(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); + }); + }); + } + throw err; + }); }; ACME._normalizePresenters = function(me, options, presenters) { @@ -1283,40 +1332,6 @@ ACME._prnd = function(n) { ACME._toHex = function(pair) { return parseInt(pair, 10).toString(16); }; -ACME._removeChallenge = function(me, options, auth) { - var challengers = options.challenges || {}; - var ch = auth.challenge; - var removeChallenge = challengers[ch.type] && challengers[ch.type].remove; - if (!removeChallenge) { - throw new Error('challenge plugin is missing remove()'); - } - - // TODO normalize, warn, and just use promises - if (1 === removeChallenge.length) { - return Promise.resolve(removeChallenge(auth)).then( - function() {}, - function(e) { - console.error('Error during remove challenge:'); - console.error(e); - } - ); - } else if (2 === removeChallenge.length) { - return new Promise(function(resolve) { - removeChallenge(auth, function(err) { - resolve(); - if (err) { - console.error('Error during remove challenge:'); - console.error(err); - } - return err; - }); - }); - } else { - throw new Error( - "Bad function signature for '" + auth.type + "' challenge.remove()" - ); - } -}; ACME._depInit = function(me, presenter) { if ('function' !== typeof presenter.init) { diff --git a/tests/compute-authorization-response.js b/tests/compute-authorization-response.js new file mode 100644 index 0000000..cace3ee --- /dev/null +++ b/tests/compute-authorization-response.js @@ -0,0 +1,111 @@ +'use strict'; + +var ACME = require('../'); +var accountKey = require('../fixtures/account.jwk.json').private; + +var authorization = { + identifier: { + type: 'dns', + value: 'example.com' + }, + status: 'pending', + expires: '2018-04-25T00:23:57Z', + challenges: [ + { + type: 'dns-01', + status: 'pending', + url: + 'https://acme-staging-v02.api.letsencrypt.org/acme/challenge/cMkwXI8pIeKN04Ynfem8ErHK3GeqAPdSt2x6q7PvVGU/118755342', + token: 'LZdlUiZ-kWPs6q5WTmQFYQHZKpz9szn2vxEUu0XhyyM' + }, + { + type: 'http-01', + status: 'pending', + url: + 'https://acme-staging-v02.api.letsencrypt.org/acme/challenge/cMkwXI8pIeKN04Ynfem8ErHK3GeqAPdSt2x6q7PvVGU/118755343', + token: '1S4zBG5YVhwSBaIY4ksI_KNMRrSmH0DZfNM9v7PYjDU' + } + ] +}; +var expectedChallengeUrl = + 'http://example.com/.well-known/acme-challenge/1S4zBG5YVhwSBaIY4ksI_KNMRrSmH0DZfNM9v7PYjDU'; +var expectedKeyAuth = + '1S4zBG5YVhwSBaIY4ksI_KNMRrSmH0DZfNM9v7PYjDU.UuuZa_56jCM2douUq1riGyRphPtRvCPkxtkg0bP-pNs'; +var expectedKeyAuthDigest = 'iQiMcQUDiAeD0TJV1RHJuGnI5D2-PuSpxKz9JqUaZ2M'; +var expectedDnsHost = '_test-challenge.example.com'; + +async function main() { + console.info('\n[Test] computing challenge authorizatin responses'); + var challenges = authorization.challenges.slice(0); + + function next() { + var ch = challenges.shift(); + if (!ch) { + return null; + } + + var hostname = authorization.identifier.value; + return ACME.computeChallenge({ + accountKey: accountKey, + hostname: hostname, + challenge: ch, + dnsPrefix: '_test-challenge' + }) + .then(function(auth) { + if ('dns-01' === ch.type) { + if (auth.keyAuthorizationDigest !== expectedKeyAuthDigest) { + console.error('[keyAuthorizationDigest]'); + console.error(auth.keyAuthorizationDigest); + console.error(expectedKeyAuthDigest); + throw new Error('bad keyAuthDigest'); + } + if (auth.dnsHost !== expectedDnsHost) { + console.error('[dnsHost]'); + console.error(auth.dnsHost); + console.error(expectedDnsHost); + throw new Error('bad dnsHost'); + } + } else if ('http-01' === ch.type) { + if (auth.challengeUrl !== expectedChallengeUrl) { + console.error('[challengeUrl]'); + console.error(auth.challengeUrl); + console.error(expectedChallengeUrl); + throw new Error('bad challengeUrl'); + } + if (auth.challengeUrl !== expectedChallengeUrl) { + console.error('[keyAuthorization]'); + console.error(auth.keyAuthorization); + console.error(expectedKeyAuth); + throw new Error('bad keyAuth'); + } + } else { + throw new Error('bad authorization inputs'); + } + console.info('PASS', hostname, ch.type); + return next(); + }) + .catch(function(err) { + err.message = + 'Error computing ' + + ch.type + + ' for ' + + hostname + + ':' + + err.message; + throw err; + }); + } + + return next(); +} + +module.exports = function() { + return main(authorization) + .then(function() { + console.info('PASS'); + }) + .catch(function(err) { + console.error(err.stack); + process.exit(1); + }); +}; diff --git a/tests/fullchain-formats.js b/tests/format-pem-chains.js similarity index 50% rename from tests/fullchain-formats.js rename to tests/format-pem-chains.js index b265b11..c037560 100644 --- a/tests/fullchain-formats.js +++ b/tests/format-pem-chains.js @@ -35,55 +35,46 @@ var tests = [ '----\nxxxx\nyyyy\n----\r\n----\nxxxx\ryyyy\n----\n' ]; -function formatPemChain(str) { - return ( - str - .trim() - .replace(/[\r\n]+/g, '\n') - .replace(/\-\n\-/g, '-\n\n-') + '\n' - ); -} -function splitPemChain(str) { - return str - .trim() - .split(/[\r\n]{2,}/g) - .map(function(str) { - return str + '\n'; - }); -} +var ACME = require('../'); -tests.forEach(function(str) { - var actual = formatPemChain(str); - if (expected !== actual) { - console.error('input: ', JSON.stringify(str)); - console.error('expected:', JSON.stringify(expected)); - console.error('actual: ', JSON.stringify(actual)); - throw new Error('did not pass'); +module.exports = function() { + console.info('\n[Test] can split and format PEM chain properly'); + + tests.forEach(function(str) { + var actual = ACME.formatPemChain(str); + if (expected !== actual) { + console.error('input: ', JSON.stringify(str)); + console.error('expected:', JSON.stringify(expected)); + console.error('actual: ', JSON.stringify(actual)); + throw new Error('did not pass'); + } + }); + + if ( + '----\nxxxx\nyyyy\n----\n' !== + ACME.formatPemChain('\n\n----\r\nxxxx\r\nyyyy\r\n----\n\n') + ) { + throw new Error('Not proper for single cert in chain'); } -}); -if ( - '----\nxxxx\nyyyy\n----\n' !== - formatPemChain('\n\n----\r\nxxxx\r\nyyyy\r\n----\n\n') -) { - throw new Error('Not proper for single cert in chain'); -} - -if ( - '--B--\nxxxx\nyyyy\n--E--\n\n--B--\nxxxx\nyyyy\n--E--\n\n--B--\nxxxx\nyyyy\n--E--\n' !== - formatPemChain( - '\n\n\n--B--\nxxxx\nyyyy\n--E--\n\n\n\n--B--\nxxxx\nyyyy\n--E--\n\n\n--B--\nxxxx\nyyyy\n--E--\n\n\n' - ) -) { - throw new Error('Not proper for three certs in chain'); -} - -splitPemChain( - '--B--\nxxxx\nyyyy\n--E--\n\n--B--\nxxxx\nyyyy\n--E--\n\n--B--\nxxxx\nyyyy\n--E--\n' -).forEach(function(str) { - if ('--B--\nxxxx\nyyyy\n--E--\n' !== str) { - throw new Error('bad thingy'); + if ( + '--B--\nxxxx\nyyyy\n--E--\n\n--B--\nxxxx\nyyyy\n--E--\n\n--B--\nxxxx\nyyyy\n--E--\n' !== + ACME.formatPemChain( + '\n\n\n--B--\nxxxx\nyyyy\n--E--\n\n\n\n--B--\nxxxx\nyyyy\n--E--\n\n\n--B--\nxxxx\nyyyy\n--E--\n\n\n' + ) + ) { + throw new Error('Not proper for three certs in chain'); } -}); -console.info('PASS'); + ACME.splitPemChain( + '--B--\nxxxx\nyyyy\n--E--\n\n--B--\nxxxx\nyyyy\n--E--\n\n--B--\nxxxx\nyyyy\n--E--\n' + ).forEach(function(str) { + if ('--B--\nxxxx\nyyyy\n--E--\n' !== str) { + throw new Error('bad thingy'); + } + }); + + console.info('PASS'); + + return Promise.resolve(); +}; diff --git a/tests/generate-cert-key.js b/tests/generate-cert-key.js index 70e89c9..caa6d88 100644 --- a/tests/generate-cert-key.js +++ b/tests/generate-cert-key.js @@ -1,15 +1,27 @@ 'use strict'; -async function run() { +module.exports = async function() { + console.log('[Test] can generate, export, and import key'); var Keypairs = require('@root/keypairs'); var certKeypair = await Keypairs.generate({ kty: 'RSA' }); - console.log(certKeypair); + //console.log(certKeypair); var pem = await Keypairs.export({ jwk: certKeypair.private, encoding: 'pem' }); - console.log(pem); -} + var jwk = await Keypairs.import({ + pem: pem + }); + ['kty', 'd', 'n', 'e'].forEach(function(k) { + if (!jwk[k] || jwk[k] !== certKeypair.private[k]) { + throw new Error('bad export/import'); + } + }); + //console.log(pem); + console.log('PASS'); +}; -run(); +if (require.main === module) { + module.exports(); +} diff --git a/tests/index.js b/tests/index.js deleted file mode 100644 index 15631e0..0000000 --- a/tests/index.js +++ /dev/null @@ -1,245 +0,0 @@ -'use strict'; - -require('dotenv').config(); - -var CSR = require('@root/csr'); -var Enc = require('@root/encoding/base64'); -var PEM = require('@root/pem'); -var punycode = require('punycode'); -var ACME = require('../acme.js'); -var Keypairs = require('@root/keypairs'); - -// TODO exec npm install --save-dev CHALLENGE_MODULE -if (!process.env.CHALLENGE_OPTIONS) { - console.error( - 'Please create a .env in the format of examples/example.env to run the tests' - ); - process.exit(1); -} - -var config = { - env: process.env.ENV, - email: process.env.SUBSCRIBER_EMAIL, - domain: process.env.BASE_DOMAIN, - challengeType: process.env.CHALLENGE_TYPE, - challengeModule: process.env.CHALLENGE_PLUGIN, - challengeOptions: JSON.parse(process.env.CHALLENGE_OPTIONS) -}; -config.debug = !/^PROD/i.test(config.env); -var pluginPrefix = 'acme-' + config.challengeType + '-'; -var pluginName = config.challengeModule; -var plugin; - -var acme = ACME.create({ - // debug: true - maintainerEmail: config.email, - notify: function(ev, params) { - console.info( - ev, - params.subject || params.altname || params.domain, - params.status - ); - if ('error' === ev) { - console.error(params); - console.error(params.error); - } - } -}); - -function badPlugin(err) { - if ('MODULE_NOT_FOUND' !== err.code) { - console.error(err); - return; - } - console.error("Couldn't find '" + pluginName + "'. Is it installed?"); - console.error("\tnpm install --save-dev '" + pluginName + "'"); -} -try { - plugin = require(pluginName); -} catch (err) { - if ( - 'MODULE_NOT_FOUND' !== err.code || - 0 === pluginName.indexOf(pluginPrefix) - ) { - badPlugin(err); - process.exit(1); - } - try { - pluginName = pluginPrefix + pluginName; - plugin = require(pluginName); - } catch (e) { - badPlugin(e); - process.exit(1); - } -} - -config.challenger = plugin.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(accKty, srvKty, rnd) { - var agreed = false; - var metadata = await acme.init( - 'https://acme-staging-v02.api.letsencrypt.org/directory' - ); - - // Ready to use, show page - if (config.debug) { - console.info('ACME.js initialized'); - console.info(metadata); - console.info(); - console.info(); - } - - var accountKeypair = await Keypairs.generate({ kty: accKty }); - var accountKey = accountKeypair.private; - if (config.debug) { - console.info('Account Key Created'); - console.info(JSON.stringify(accountKey, null, 2)); - console.info(); - console.info(); - } - - var account = await acme.accounts.create({ - agreeToTerms: agree, - // TODO detect jwk/pem/der? - accountKey: accountKey, - subscriberEmail: config.email - }); - - // TODO top-level agree - function agree(tos) { - if (config.debug) { - console.info('Agreeing to Terms of Service:'); - console.info(tos); - console.info(); - console.info(); - } - agreed = true; - return Promise.resolve(tos); - } - if (config.debug) { - console.info('New Subscriber Account'); - console.info(JSON.stringify(account, null, 2)); - console.info(); - console.info(); - } - if (!agreed) { - throw new Error('Failed to ask the user to agree to terms'); - } - - var certKeypair = await Keypairs.generate({ kty: srvKty }); - var pem = await Keypairs.export({ - jwk: certKeypair.private, - encoding: 'pem' - }); - if (config.debug) { - console.info('Server Key Created'); - console.info('privkey.jwk.json'); - console.info(JSON.stringify(certKeypair, null, 2)); - // This should be saved as `privkey.pem` - console.info(); - console.info('privkey.' + srvKty.toLowerCase() + '.pem:'); - console.info(pem); - console.info(); - } - - // 'subject' should be first in list - var domains = randomDomains(rnd); - if (config.debug) { - console.info('Get certificates for random domains:'); - console.info( - domains - .map(function(puny) { - var uni = punycode.toUnicode(puny); - if (puny !== uni) { - return puny + ' (' + uni + ')'; - } - return puny; - }) - .join('\n') - ); - console.info(); - } - - // Create CSR - var csrDer = await CSR.csr({ - jwk: certKeypair.private, - domains: domains, - encoding: 'der' - }); - var csr = Enc.bufToUrlBase64(csrDer); - var csrPem = PEM.packBlock({ - type: 'CERTIFICATE REQUEST', - bytes: csrDer /* { jwk: jwk, domains: opts.domains } */ - }); - if (config.debug) { - console.info('Certificate Signing Request'); - console.info(csrPem); - console.info(); - } - - var results = await acme.certificates.create({ - account: account, - accountKey: accountKey, - csr: csr, - domains: domains, - challenges: challenges, // must be implemented - customerEmail: null - }); - - if (config.debug) { - console.info('Got SSL Certificate:'); - console.info(Object.keys(results)); - console.info(results.expires); - console.info(results.cert); - console.info(results.chain); - console.info(); - console.info(); - } -} - -// Try EC + RSA -var rnd = random(); -happyPath('EC', 'RSA', rnd) - .then(function() { - // Now try RSA + EC - rnd = random(); - return happyPath('RSA', 'EC', rnd).then(function() { - console.info('success'); - }); - }) - .catch(function(err) { - console.error('Error:'); - console.error(err.stack); - }); - -function randomDomains(rnd) { - return ['foo-acmejs', 'bar-acmejs', '*.baz-acmejs', 'baz-acmejs'].map( - function(pre) { - return punycode.toASCII(pre + '-' + rnd + '.' + config.domain); - } - ); -} - -function random() { - return ( - parseInt( - Math.random() - .toString() - .slice(2, 99), - 10 - ) - .toString(16) - .slice(0, 4) + '例' - ); -} diff --git a/tests/issue-certificates.js b/tests/issue-certificates.js new file mode 100644 index 0000000..8a63a58 --- /dev/null +++ b/tests/issue-certificates.js @@ -0,0 +1,253 @@ +'use strict'; + +require('dotenv').config(); + +var CSR = require('@root/csr'); +var Enc = require('@root/encoding/base64'); +var PEM = require('@root/pem'); +var punycode = require('punycode'); +var ACME = require('../acme.js'); +var Keypairs = require('@root/keypairs'); + +// TODO exec npm install --save-dev CHALLENGE_MODULE +if (!process.env.CHALLENGE_OPTIONS) { + console.error( + 'Please create a .env in the format of examples/example.env to run the tests' + ); + process.exit(1); +} + +var config = { + env: process.env.ENV, + email: process.env.SUBSCRIBER_EMAIL, + domain: process.env.BASE_DOMAIN, + challengeType: process.env.CHALLENGE_TYPE, + challengeModule: process.env.CHALLENGE_PLUGIN, + challengeOptions: JSON.parse(process.env.CHALLENGE_OPTIONS) +}; +//config.debug = !/^PROD/i.test(config.env); +var pluginPrefix = 'acme-' + config.challengeType + '-'; +var pluginName = config.challengeModule; +var plugin; + +module.exports = function() { + console.info('\n[Test] end-to-end issue certificates'); + + var acme = ACME.create({ + // debug: true + maintainerEmail: config.email, + notify: function(ev, params) { + console.info( + '\t' + ev, + params.subject || params.altname || params.domain || '', + params.status || '' + ); + if ('error' === ev) { + console.error(params.action || params.type || ''); + console.error(params); + } + } + }); + + function badPlugin(err) { + if ('MODULE_NOT_FOUND' !== err.code) { + console.error(err); + return; + } + console.error("Couldn't find '" + pluginName + "'. Is it installed?"); + console.error("\tnpm install --save-dev '" + pluginName + "'"); + } + try { + plugin = require(pluginName); + } catch (err) { + if ( + 'MODULE_NOT_FOUND' !== err.code || + 0 === pluginName.indexOf(pluginPrefix) + ) { + badPlugin(err); + process.exit(1); + } + try { + pluginName = pluginPrefix + pluginName; + plugin = require(pluginName); + } catch (e) { + badPlugin(e); + process.exit(1); + } + } + + config.challenger = plugin.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(accKty, srvKty, rnd) { + var agreed = false; + var metadata = await acme.init( + 'https://acme-staging-v02.api.letsencrypt.org/directory' + ); + + // Ready to use, show page + if (config.debug) { + console.info('ACME.js initialized'); + console.info(metadata); + console.info(); + console.info(); + } + + var accountKeypair = await Keypairs.generate({ kty: accKty }); + var accountKey = accountKeypair.private; + if (config.debug) { + console.info('Account Key Created'); + console.info(JSON.stringify(accountKey, null, 2)); + console.info(); + console.info(); + } + + var account = await acme.accounts.create({ + agreeToTerms: agree, + // TODO detect jwk/pem/der? + accountKey: accountKey, + subscriberEmail: config.email + }); + + // TODO top-level agree + function agree(tos) { + if (config.debug) { + console.info('Agreeing to Terms of Service:'); + console.info(tos); + console.info(); + console.info(); + } + agreed = true; + return Promise.resolve(tos); + } + if (config.debug) { + console.info('New Subscriber Account'); + console.info(JSON.stringify(account, null, 2)); + console.info(); + console.info(); + } + if (!agreed) { + throw new Error('Failed to ask the user to agree to terms'); + } + + var certKeypair = await Keypairs.generate({ kty: srvKty }); + var pem = await Keypairs.export({ + jwk: certKeypair.private, + encoding: 'pem' + }); + if (config.debug) { + console.info('Server Key Created'); + console.info('privkey.jwk.json'); + console.info(JSON.stringify(certKeypair, null, 2)); + // This should be saved as `privkey.pem` + console.info(); + console.info('privkey.' + srvKty.toLowerCase() + '.pem:'); + console.info(pem); + console.info(); + } + + // 'subject' should be first in list + var domains = randomDomains(rnd); + if (config.debug) { + console.info('Get certificates for random domains:'); + console.info( + domains + .map(function(puny) { + var uni = punycode.toUnicode(puny); + if (puny !== uni) { + return puny + ' (' + uni + ')'; + } + return puny; + }) + .join('\n') + ); + console.info(); + } + + // Create CSR + var csrDer = await CSR.csr({ + jwk: certKeypair.private, + domains: domains, + encoding: 'der' + }); + var csr = Enc.bufToUrlBase64(csrDer); + var csrPem = PEM.packBlock({ + type: 'CERTIFICATE REQUEST', + bytes: csrDer /* { jwk: jwk, domains: opts.domains } */ + }); + if (config.debug) { + console.info('Certificate Signing Request'); + console.info(csrPem); + console.info(); + } + + var results = await acme.certificates.create({ + account: account, + accountKey: accountKey, + csr: csr, + domains: domains, + challenges: challenges, // must be implemented + customerEmail: null + }); + + if (config.debug) { + console.info('Got SSL Certificate:'); + console.info(Object.keys(results)); + console.info(results.expires); + console.info(results.cert); + console.info(results.chain); + console.info(); + console.info(); + } + } + + // Try EC + RSA + var rnd = random(); + happyPath('EC', 'RSA', rnd) + .then(function() { + console.info('PASS: ECDSA account key with RSA server key'); + // Now try RSA + EC + rnd = random(); + return happyPath('RSA', 'EC', rnd).then(function() { + console.info('PASS: RSA account key with ECDSA server key'); + }); + }) + .then(function() { + console.info('PASS'); + }) + .catch(function(err) { + console.error('Error:'); + console.error(err.stack); + }); + + function randomDomains(rnd) { + return ['foo-acmejs', 'bar-acmejs', '*.baz-acmejs', 'baz-acmejs'].map( + function(pre) { + return punycode.toASCII(pre + '-' + rnd + '.' + config.domain); + } + ); + } + + function random() { + return ( + parseInt( + Math.random() + .toString() + .slice(2, 99), + 10 + ) + .toString(16) + .slice(0, 4) + '例' + ); + } +}; diff --git a/utils.js b/utils.js index 46859dd..b1b9147 100644 --- a/utils.js +++ b/utils.js @@ -122,29 +122,26 @@ U._setNonce = function(me, nonce) { me._nonces.unshift({ nonce: nonce, createdAt: Date.now() }); }; -U._importKeypair = function(me, kp) { - var jwk = kp.privateKeyJwk; - if (kp.kty) { - jwk = kp; - kp = {}; - } - var pub; +U._importKeypair = function(me, key) { var p; - if (jwk) { + var pub; + + if (key && key.kty) { // nix the browser jwk extras - jwk.key_ops = undefined; - jwk.ext = undefined; - pub = Keypairs.neuter({ jwk: jwk }); + key.key_ops = undefined; + key.ext = undefined; + pub = Keypairs.neuter({ jwk: key }); p = Promise.resolve({ - private: jwk, + private: key, public: pub }); + } else if ('string' === typeof key) { + p = Keypairs.import({ pem: key }); } else { - p = Keypairs.import({ pem: kp.privateKeyPem }); + throw new Error('no private key given'); } + return p.then(function(pair) { - kp.privateKeyJwk = pair.private; - kp.publicKeyJwk = pair.public; if (pair.public.kid) { pair = JSON.parse(JSON.stringify(pair)); delete pair.public.kid;