From fe5a48764b961bdb035263fa39be9b1e85822261 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Thu, 5 Apr 2018 01:31:57 -0600 Subject: [PATCH] yay for backwards compat tested and working --- README.md | 42 ++- compat.js | 53 +++ node.js | 860 +++++++++++++++++++++++++----------------------- test.cb.js | 75 +++++ test.compat.js | 57 ++++ test.js | 95 +++--- test.promise.js | 84 +++++ 7 files changed, 786 insertions(+), 480 deletions(-) create mode 100644 compat.js create mode 100644 test.cb.js create mode 100644 test.compat.js create mode 100644 test.promise.js diff --git a/README.md b/README.md index c57a4df..a5202be 100644 --- a/README.md +++ b/README.md @@ -31,27 +31,52 @@ Todo * export http and dns challenge tests * support ECDSA keys +## Let's Encrypt Directory URLs + +``` +# Production URL +https://acme-v02.api.letsencrypt.org/directory +``` + +``` +# Staging URL +https://acme-staging-v02.api.letsencrypt.org/directory +``` + ## API ``` -var ACME = require('acme-v2.js').ACME.create({ +var ACME = require('acme-v2').ACME.create({ RSA: require('rsa-compat').RSA + + // other overrides +, request: require('request') +, promisify: require('util').promisify + + // used for constructing user-agent +, os: require('os') +, process: require('process') + + // used for overriding the default user-agent +, userAgent: 'My custom UA String' +, getUserAgentString: function (deps) { return 'My custom UA String'; } }); ``` ```javascript // Accounts -ACME.registerNewAccount(options, cb) // returns "regr" registration data +ACME.accounts.create(options) // returns Promise registration data { email: '' // valid email (server checks MX records) , accountKeypair: { // privateKeyPem or privateKeyJwt privateKeyPem: '' } - , agreeToTerms: fn (tosUrl, cb) {} // must specify agree=tosUrl to continue (or falsey to end) + , agreeToTerms: fn (tosUrl) {} // returns Promise with tosUrl } + // Registration -ACME.getCertificate(options, cb) // returns (err, pems={ privkey (key), cert, chain (ca) }) +ACME.certificates.create(options) // returns Promise { newAuthzUrl: '' // specify acmeUrls.newAuthz , newCertUrl: '' // specify acmeUrls.newCert @@ -64,20 +89,19 @@ ACME.getCertificate(options, cb) // returns (err, pems={ privkey (key } , domains: [ 'example.com' ] - , setChallenge: fn (hostname, key, val, cb) - , removeChallenge: fn (hostname, key, cb) + , setChallenge: fn (hostname, key, val) // return Promise + , removeChallenge: fn (hostname, key) // return Promise } + // Discovery URLs -ACME.getAcmeUrls(acmeDiscoveryUrl, cb) // returns (err, acmeUrls={newReg,newAuthz,newCert,revokeCert}) +ACME.init(acmeDirectoryUrl) // returns Promise ``` Helpers & Stuff ```javascript // Constants -ACME.productionServerUrl // https://acme-v02.api.letsencrypt.org/directory -ACME.stagingServerUrl // https://acme-staging-v02.api.letsencrypt.org/directory ACME.acmeChallengePrefix // /.well-known/acme-challenge/ ``` diff --git a/compat.js b/compat.js new file mode 100644 index 0000000..b9f6b62 --- /dev/null +++ b/compat.js @@ -0,0 +1,53 @@ +'use strict'; + +var ACME2 = require('./').ACME; + +function resolveFn(cb) { + return function (val) { + // nextTick to get out of Promise chain + process.nextTick(function () { cb(null, val); }); + }; +} +function rejectFn(cb) { + return function (err) { + console.log('reject something or other:'); + console.log(err.stack); + // nextTick to get out of Promise chain + process.nextTick(function () { cb(err); }); + }; +} + +function create(deps) { + deps.LeCore = {}; + var acme2 = ACME2.create(deps); + acme2.registerNewAccount = function (options, cb) { + acme2.accounts.create(options).then(resolveFn(cb), rejectFn(cb)); + }; + acme2.getCertificate = function (options, cb) { + acme2.certificates.create(options).then(resolveFn(cb), rejectFn(cb)); + }; + acme2.getAcmeUrls = function (options, cb) { + acme2.init(options).then(resolveFn(cb), rejectFn(cb)); + }; + acme2.stagingServerUrl = module.exports.defaults.stagingServerUrl; + acme2.productionServerUrl = module.exports.defaults.productionServerUrl; + return acme2; +} + +module.exports.ACME = { }; +module.exports.defaults = { + productionServerUrl: 'https://acme-v02.api.letsencrypt.org/directory' +, stagingServerUrl: 'https://acme-staging-v02.api.letsencrypt.org/directory' +, knownEndpoints: [ 'keyChange', 'meta', 'newAccount', 'newNonce', 'newOrder', 'revokeCert' ] +, challengeTypes: [ 'http-01', 'dns-01' ] +, challengeType: 'http-01' +, keyType: 'rsa' // ecdsa +, keySize: 2048 // 256 +}; +Object.keys(module.exports.defaults).forEach(function (key) { + module.exports.ACME[key] = module.exports.defaults[key]; +}); +Object.keys(ACME2).forEach(function (key) { + module.exports.ACME[key] = ACME2[key]; + module.exports.ACME.create = create; +}); diff --git a/node.js b/node.js index f1c01e4..67544db 100644 --- a/node.js +++ b/node.js @@ -6,463 +6,487 @@ 'use strict'; /* globals Promise */ -var defaults = { - productionServerUrl: 'https://acme-v02.api.letsencrypt.org/directory' -, stagingServerUrl: 'https://acme-staging-v02.api.letsencrypt.org/directory' -, acmeChallengePrefix: '/.well-known/acme-challenge/' -, knownEndpoints: [ 'keyChange', 'meta', 'newAccount', 'newNonce', 'newOrder', 'revokeCert' ] -, challengeType: 'http-01' // dns-01 -, keyType: 'rsa' // ecdsa -, keySize: 2048 // 256 -}; +var ACME = module.exports.ACME = {}; -function create(deps) { - if (!deps) { deps = {}; } - deps.LeCore = {}; - deps.pkg = deps.pkg || require('./package.json'); - deps.os = deps.os || require('os'); - deps.process = deps.process || require('process'); +ACME.acmeChallengePrefix = '/.well-known/acme-challenge/'; +ACME._getUserAgentString = function (deps) { var uaDefaults = { pkg: "Greenlock/" + deps.pkg.version - , os: " (" + deps.os.type() + "; " + deps.process.arch + " " + deps.os.platform() + " " + deps.os.release() + ")" - , node: " Node.js/" + deps.process.version + , os: "(" + deps.os.type() + "; " + deps.process.arch + " " + deps.os.platform() + " " + deps.os.release() + ")" + , node: "Node.js/" + deps.process.version , user: '' }; - //var currentUAProps; - function getUaString() { - var userAgent = ''; + var userAgent = []; - //Object.keys(currentUAProps) - Object.keys(uaDefaults).forEach(function (key) { - userAgent += uaDefaults[key]; - //userAgent += currentUAProps[key]; - }); - - return userAgent.trim(); - } - - function getRequest(opts) { - if (!opts) { opts = {}; } - - return deps.request.defaults({ - headers: { - 'User-Agent': opts.userAgent || getUaString() - } - }); - } - - var RSA = deps.RSA || require('rsa-compat').RSA; - deps.request = deps.request || require('request'); - deps.promisify = deps.promisify || require('util').promisify; - - var directoryUrl = deps.directoryUrl || defaults.stagingServerUrl; - var request = deps.promisify(getRequest({})); - - var acme2 = { - getAcmeUrls: function (_directoryUrl) { - var me = this; - return request({ url: _directoryUrl || directoryUrl, json: true }).then(function (resp) { - me._directoryUrls = resp.body; - me._tos = me._directoryUrls.meta.termsOfService; - return me._directoryUrls; - }); + //Object.keys(currentUAProps) + Object.keys(uaDefaults).forEach(function (key) { + if (uaDefaults[key]) { + userAgent.push(uaDefaults[key]); } - , getNonce: function () { - var me = this; - if (me._nonce) { return new Promise(function (resolve) { resolve(me._nonce); return; }); } - return request({ method: 'HEAD', url: me._directoryUrls.newNonce }).then(function (resp) { - me._nonce = resp.toJSON().headers['replay-nonce']; - return me._nonce; - }); - } - // ACME RFC Section 7.3 Account Creation - /* - { - "protected": base64url({ - "alg": "ES256", - "jwk": {...}, - "nonce": "6S8IqOGY7eL2lsGoTZYifg", - "url": "https://example.com/acme/new-account" - }), - "payload": base64url({ - "termsOfServiceAgreed": true, - "onlyReturnExisting": false, - "contact": [ - "mailto:cert-admin@example.com", - "mailto:admin@example.com" - ] - }), - "signature": "RZPOnYoPs1PhjszF...-nh6X1qtOFPB519I" - } - */ - , registerNewAccount: function (options) { - var me = this; + }); - console.log('[acme-v2] registerNewAccount'); + return userAgent.join(' ').trim(); +}; +ACME._directory = function (me) { + return me._request({ url: me.directoryUrl, json: true }); +}; +ACME._getNonce = function (me) { + if (me._nonce) { return new Promise(function (resolve) { resolve(me._nonce); return; }); } + return me._request({ method: 'HEAD', url: me._directoryUrls.newNonce }).then(function (resp) { + me._nonce = resp.toJSON().headers['replay-nonce']; + return me._nonce; + }); +}; +// ACME RFC Section 7.3 Account Creation +/* + { + "protected": base64url({ + "alg": "ES256", + "jwk": {...}, + "nonce": "6S8IqOGY7eL2lsGoTZYifg", + "url": "https://example.com/acme/new-account" + }), + "payload": base64url({ + "termsOfServiceAgreed": true, + "onlyReturnExisting": false, + "contact": [ + "mailto:cert-admin@example.com", + "mailto:admin@example.com" + ] + }), + "signature": "RZPOnYoPs1PhjszF...-nh6X1qtOFPB519I" + } +*/ +ACME._registerAccount = function (me, options) { + console.log('[acme-v2] accounts.create'); - return me.getNonce().then(function () { - return new Promise(function (resolve, reject) { + return ACME._getNonce(me).then(function () { + return new Promise(function (resolve, reject) { - function agree(err, tosUrl) { - if (err) { reject(err); return; } - if (me._tos !== tosUrl) { - err = new Error("You must agree to the ToS at '" + me._tos + "'"); - err.code = "E_AGREE_TOS"; - reject(err); - return; - } - - var jwk = RSA.exportPublicJwk(options.accountKeypair); - var body = { - termsOfServiceAgreed: tosUrl === me._tos - , onlyReturnExisting: false - , contact: [ 'mailto:' + options.email ] - }; - if (options.externalAccount) { - body.externalAccountBinding = RSA.signJws( - options.externalAccount.secret - , undefined - , { alg: "HS256" - , kid: options.externalAccount.id - , url: me._directoryUrls.newAccount - } - , new Buffer(JSON.stringify(jwk)) - ); - } - var payload = JSON.stringify(body); - var jws = RSA.signJws( - options.accountKeypair - , undefined - , { nonce: me._nonce - , alg: 'RS256' - , url: me._directoryUrls.newAccount - , jwk: jwk - } - , new Buffer(payload) - ); - - console.log('[acme-v2] registerNewAccount JSON body:'); - delete jws.header; - console.log(jws); - me._nonce = null; - return request({ - method: 'POST' - , url: me._directoryUrls.newAccount - , headers: { 'Content-Type': 'application/jose+json' } - , json: jws - }).then(function (resp) { - me._nonce = resp.toJSON().headers['replay-nonce']; - var location = resp.toJSON().headers.location; - console.log('[DEBUG] new account location:'); // the account id url - console.log(location); // the account id url - console.log(resp.toJSON()); - me._kid = location; - return resp.body; - }).then(resolve); - } - - console.log('[acme-v2] agreeToTerms'); - options.agreeToTerms(me._tos, agree); - }); - }); - } - /* - 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" - } - */ - , _getChallenges: function (options, auth) { - console.log('\n[DEBUG] getChallenges\n'); - return request({ method: 'GET', url: auth, json: true }).then(function (resp) { - return resp.body; - }); - } - // https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-7.5.1 - , _postChallenge: function (options, identifier, ch) { - var me = this; - - var body = { }; - - var payload = JSON.stringify(body); - - var thumbprint = RSA.thumbprint(options.accountKeypair); - var keyAuthorization = ch.token + '.' + thumbprint; - // keyAuthorization = token || '.' || base64url(JWK_Thumbprint(accountKey)) - // /.well-known/acme-challenge/:token - - return new Promise(function (resolve, reject) { - if (options.setupChallenge) { - options.setupChallenge( - { identifier: identifier - , hostname: identifier.value - , type: ch.type - , token: ch.token - , thumbprint: thumbprint - , keyAuthorization: keyAuthorization - , dnsAuthorization: RSA.utils.toWebsafeBase64( - require('crypto').createHash('sha256').update(keyAuthorization).digest('base64') - ) - } - , testChallenge - ); - } else { - options.setChallenge(identifier.value, ch.token, keyAuthorization, testChallenge); - } - - function testChallenge(err) { - if (err) { reject(err); return; } - - // TODO put check dns / http checks here? - // http-01: GET https://example.org/.well-known/acme-challenge/{{token}} => {{keyAuth}} - // dns-01: TXT _acme-challenge.example.org. => "{{urlSafeBase64(sha256(keyAuth))}}" - - function wait(ms) { - return new Promise(function (resolve) { - setTimeout(resolve, (ms || 1100)); - }); - } - - function pollStatus() { - console.log('\n[DEBUG] statusChallenge\n'); - return request({ method: 'GET', url: ch.url, json: true }).then(function (resp) { - console.error('poll: resp.body:'); - console.error(resp.body); - - if ('pending' === resp.body.status) { - console.log('poll: again'); - return wait().then(pollStatus); - } - - if ('valid' === resp.body.status) { - console.log('poll: valid'); - try { - if (options.teardownChallenge) { - options.teardownChallenge( - { identifier: identifier - , type: ch.type - , token: ch.token - } - , function () {} - ); - } else { - options.removeChallenge(identifier.value, ch.token, function () {}); - } - } catch(e) {} - return resp.body; - } - - if (!resp.body.status) { - console.error("[acme-v2] (y) bad challenge state:"); - } - else if ('invalid' === resp.body.status) { - console.error("[acme-v2] (x) invalid challenge state:"); - } - else { - console.error("[acme-v2] (z) bad challenge state:"); - } - - return Promise.reject(new Error("[acme-v2] bad challenge state")); - }); - } - - console.log('\n[DEBUG] postChallenge\n'); - //console.log('\n[DEBUG] stop to fix things\n'); return; - - function post() { - var jws = RSA.signJws( - options.accountKeypair - , undefined - , { nonce: me._nonce, alg: 'RS256', url: ch.url, kid: me._kid } - , new Buffer(payload) - ); - me._nonce = null; - return request({ - method: 'POST' - , url: ch.url - , headers: { 'Content-Type': 'application/jose+json' } - , json: jws - }).then(function (resp) { - me._nonce = resp.toJSON().headers['replay-nonce']; - console.log('respond to challenge: resp.body:'); - console.log(resp.body); - return wait().then(pollStatus).then(resolve, reject); - }); - } - - return wait(20 * 1000).then(post); - } - }); - } - , _finalizeOrder: function (options, validatedDomains) { - console.log('finalizeOrder:'); - var me = this; - - var csr = RSA.generateCsrWeb64(options.domainKeypair, validatedDomains); - var body = { csr: csr }; - var payload = JSON.stringify(body); - - function wait(ms) { - return new Promise(function (resolve) { - setTimeout(resolve, (ms || 1100)); - }); - } - - function pollCert() { - var jws = RSA.signJws( - options.accountKeypair - , undefined - , { nonce: me._nonce, alg: 'RS256', url: me._finalize, kid: me._kid } - , new Buffer(payload) - ); - - console.log('finalize:', me._finalize); - me._nonce = null; - return request({ - method: 'POST' - , url: me._finalize - , headers: { 'Content-Type': 'application/jose+json' } - , json: jws - }).then(function (resp) { - me._nonce = resp.toJSON().headers['replay-nonce']; - - console.log('order finalized: resp.body:'); - console.log(resp.body); - - if ('processing' === resp.body.status) { - return wait().then(pollCert); - } - - if ('valid' === resp.body.status) { - me._expires = resp.body.expires; - me._certificate = resp.body.certificate; - - return resp.body; - } - - if ('invalid' === resp.body.status) { - console.error('cannot finalize: badness'); - return; - } - - console.error('(x) cannot finalize: badness'); + function agree(tosUrl) { + var err; + if (me._tos !== tosUrl) { + err = new Error("You must agree to the ToS at '" + me._tos + "'"); + err.code = "E_AGREE_TOS"; + reject(err); return; - }); - } - - return pollCert(); - } - , _getCertificate: function () { - var me = this; - return request({ method: 'GET', url: me._certificate, json: true }).then(function (resp) { - console.log('Certificate:'); - console.log(resp.body); - return resp.body; - }); - } - , getCertificate: function (options, cb) { - console.log('[acme-v2] DEBUG get cert 1'); - var me = this; - - if (!options.challengeTypes) { - if (!options.challengeType) { - cb(new Error("challenge type must be specified")); - return Promise.reject(new Error("challenge type must be specified")); } - options.challengeTypes = [ options.challengeType ]; - } - console.log('[acme-v2] getCertificate'); - return me.getNonce().then(function () { + var jwk = me.RSA.exportPublicJwk(options.accountKeypair); var body = { - identifiers: options.domains.map(function (hostname) { - return { type: "dns" , value: hostname }; - }) - //, "notBefore": "2016-01-01T00:00:00Z" - //, "notAfter": "2016-01-08T00:00:00Z" + termsOfServiceAgreed: tosUrl === me._tos + , onlyReturnExisting: false + , contact: [ 'mailto:' + options.email ] }; - + if (options.externalAccount) { + body.externalAccountBinding = me.RSA.signJws( + options.externalAccount.secret + , undefined + , { alg: "HS256" + , kid: options.externalAccount.id + , url: me._directoryUrls.newAccount + } + , new Buffer(JSON.stringify(jwk)) + ); + } var payload = JSON.stringify(body); - var jws = RSA.signJws( + var jws = me.RSA.signJws( options.accountKeypair , undefined - , { nonce: me._nonce, alg: 'RS256', url: me._directoryUrls.newOrder, kid: me._kid } + , { nonce: me._nonce + , alg: 'RS256' + , url: me._directoryUrls.newAccount + , jwk: jwk + } , new Buffer(payload) ); - console.log('\n[DEBUG] newOrder\n'); + console.log('[acme-v2] accounts.create JSON body:'); + delete jws.header; + console.log(jws); me._nonce = null; - return request({ + return me._request({ method: 'POST' - , url: me._directoryUrls.newOrder + , url: me._directoryUrls.newAccount , headers: { 'Content-Type': 'application/jose+json' } , json: jws }).then(function (resp) { me._nonce = resp.toJSON().headers['replay-nonce']; var location = resp.toJSON().headers.location; + console.log('[DEBUG] new account location:'); // the account id url console.log(location); // the account id url console.log(resp.toJSON()); - me._authorizations = resp.body.authorizations; - me._order = location; - me._finalize = resp.body.finalize; - //console.log('[DEBUG] finalize:', me._finalize); return; + me._kid = location; + return resp.body; + }).then(resolve, reject); + } - //return resp.body; - return Promise.all(me._authorizations.map(function (authUrl) { - return me._getChallenges(options, authUrl).then(function (results) { - // var domain = options.domains[i]; // results.identifier.value - var chType = options.challengeTypes.filter(function (chType) { - return results.challenges.some(function (ch) { - return ch.type === chType; - }); - })[0]; - var challenge = results.challenges.filter(function (ch) { - if (chType === ch.type) { - return ch; - } - })[0]; + console.log('[acme-v2] agreeToTerms'); + if (1 === options.agreeToTerms.length) { + return options.agreeToTerms(me._tos).then(agree, reject); + } + else if (2 === options.agreeToTerms.length) { + return options.agreeToTerms(me._tos, function (err, tosUrl) { + if (!err) { agree(tosUrl); return; } + reject(err); + }); + } + else { + reject(new Error('agreeToTerms has incorrect function signature.' + + ' Should be fn(tos) { return Promise; }')); + } + }); + }); +}; +/* + POST /acme/new-order HTTP/1.1 + Host: example.com + Content-Type: application/jose+json - if (!challenge) { - return Promise.reject(new Error("Server didn't offer any challenge we can handle.")); + { + "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._getChallenges = function (me, options, auth) { + console.log('\n[DEBUG] getChallenges\n'); + return me._request({ method: 'GET', url: auth, json: true }).then(function (resp) { + return resp.body; + }); +}; +ACME._wait = function wait(ms) { + return new Promise(function (resolve) { + setTimeout(resolve, (ms || 1100)); + }); +}; +// https://tools.ietf.org/html/draft-ietf-acme-acme-10#section-7.5.1 +ACME._postChallenge = function (me, options, identifier, ch) { + var body = { }; + + var payload = JSON.stringify(body); + + var thumbprint = me.RSA.thumbprint(options.accountKeypair); + var keyAuthorization = ch.token + '.' + thumbprint; + // keyAuthorization = token || '.' || base64url(JWK_Thumbprint(accountKey)) + // /.well-known/acme-challenge/:token + + return new Promise(function (resolve, reject) { + function failChallenge(err) { + if (err) { reject(err); return; } + testChallenge(); + } + + function testChallenge() { + // TODO put check dns / http checks here? + // http-01: GET https://example.org/.well-known/acme-challenge/{{token}} => {{keyAuth}} + // dns-01: TXT _acme-challenge.example.org. => "{{urlSafeBase64(sha256(keyAuth))}}" + + function pollStatus() { + console.log('\n[DEBUG] statusChallenge\n'); + return me._request({ method: 'GET', url: ch.url, json: true }).then(function (resp) { + console.error('poll: resp.body:'); + console.error(resp.body); + + if ('pending' === resp.body.status) { + console.log('poll: again'); + return ACME._wait(1 * 1000).then(pollStatus); + } + + if ('valid' === resp.body.status) { + console.log('poll: valid'); + try { + if (1 === options.removeChallenge.length) { + options.removeChallenge( + { identifier: identifier + , type: ch.type + , token: ch.token + } + ).then(function () {}, function () {}); + } else if (2 === options.removeChallenge.length) { + options.removeChallenge( + { identifier: identifier + , type: ch.type + , token: ch.token + } + , function (err) { return err; } + ); + } else { + options.removeChallenge(identifier.value, ch.token, function () {}); } + } catch(e) {} + return resp.body; + } - return me._postChallenge(options, results.identifier, challenge); - }); - })).then(function () { - var validatedDomains = body.identifiers.map(function (ident) { - return ident.value; - }); + if (!resp.body.status) { + console.error("[acme-v2] (y) bad challenge state:"); + } + else if ('invalid' === resp.body.status) { + console.error("[acme-v2] (x) invalid challenge state:"); + } + else { + console.error("[acme-v2] (z) bad challenge state:"); + } - return me._finalizeOrder(options, validatedDomains); - }).then(function () { - return me._getCertificate().then(function (result) { cb(null, result); return result; }, cb); - }); + return Promise.reject(new Error("[acme-v2] bad challenge state")); + }); + } + + console.log('\n[DEBUG] postChallenge\n'); + //console.log('\n[DEBUG] stop to fix things\n'); return; + + function post() { + var jws = me.RSA.signJws( + options.accountKeypair + , undefined + , { nonce: me._nonce, alg: 'RS256', url: ch.url, kid: me._kid } + , new Buffer(payload) + ); + me._nonce = null; + return me._request({ + method: 'POST' + , url: ch.url + , headers: { 'Content-Type': 'application/jose+json' } + , json: jws + }).then(function (resp) { + me._nonce = resp.toJSON().headers['replay-nonce']; + console.log('respond to challenge: resp.body:'); + console.log(resp.body); + return ACME._wait(1 * 1000).then(pollStatus).then(resolve, reject); + }); + } + + return ACME._wait(1 * 1000).then(post); + } + + try { + if (1 === options.setChallenge.length) { + options.setChallenge( + { identifier: identifier + , hostname: identifier.value + , type: ch.type + , token: ch.token + , thumbprint: thumbprint + , keyAuthorization: keyAuthorization + , dnsAuthorization: me.RSA.utils.toWebsafeBase64( + require('crypto').createHash('sha256').update(keyAuthorization).digest('base64') + ) + } + ).then(testChallenge, reject); + } else if (2 === options.setChallenge.length) { + options.setChallenge( + { identifier: identifier + , hostname: identifier.value + , type: ch.type + , token: ch.token + , thumbprint: thumbprint + , keyAuthorization: keyAuthorization + , dnsAuthorization: me.RSA.utils.toWebsafeBase64( + require('crypto').createHash('sha256').update(keyAuthorization).digest('base64') + ) + } + , failChallenge + ); + } else { + options.setChallenge(identifier.value, ch.token, keyAuthorization, failChallenge); + } + } catch(e) { + reject(e); + } + }); +}; +ACME._finalizeOrder = function (me, options, validatedDomains) { + console.log('finalizeOrder:'); + var csr = me.RSA.generateCsrWeb64(options.domainKeypair, validatedDomains); + var body = { csr: csr }; + var payload = JSON.stringify(body); + + function pollCert() { + var jws = me.RSA.signJws( + options.accountKeypair + , undefined + , { nonce: me._nonce, alg: 'RS256', url: me._finalize, kid: me._kid } + , new Buffer(payload) + ); + + console.log('finalize:', me._finalize); + me._nonce = null; + return me._request({ + method: 'POST' + , url: me._finalize + , headers: { 'Content-Type': 'application/jose+json' } + , json: jws + }).then(function (resp) { + me._nonce = resp.toJSON().headers['replay-nonce']; + + console.log('order finalized: resp.body:'); + console.log(resp.body); + + if ('processing' === resp.body.status) { + return ACME._wait().then(pollCert); + } + + if ('valid' === resp.body.status) { + me._expires = resp.body.expires; + me._certificate = resp.body.certificate; + + return resp.body; + } + + if ('invalid' === resp.body.status) { + console.error('cannot finalize: badness'); + return; + } + + console.error('(x) cannot finalize: badness'); + return; + }); + } + + return pollCert(); +}; +ACME._getCertificate = function (me, options) { + console.log('[acme-v2] DEBUG get cert 1'); + + if (!options.challengeTypes) { + if (!options.challengeType) { + return Promise.reject(new Error("challenge type must be specified")); + } + options.challengeTypes = [ options.challengeType ]; + } + + console.log('[acme-v2] certificates.create'); + return ACME._getNonce(me).then(function () { + var body = { + identifiers: options.domains.map(function (hostname) { + return { type: "dns" , value: hostname }; + }) + //, "notBefore": "2016-01-01T00:00:00Z" + //, "notAfter": "2016-01-08T00:00:00Z" + }; + + var payload = JSON.stringify(body); + var jws = me.RSA.signJws( + options.accountKeypair + , undefined + , { nonce: me._nonce, alg: 'RS256', url: me._directoryUrls.newOrder, kid: me._kid } + , new Buffer(payload) + ); + + console.log('\n[DEBUG] newOrder\n'); + me._nonce = null; + return me._request({ + method: 'POST' + , url: me._directoryUrls.newOrder + , headers: { 'Content-Type': 'application/jose+json' } + , json: jws + }).then(function (resp) { + me._nonce = resp.toJSON().headers['replay-nonce']; + var location = resp.toJSON().headers.location; + console.log(location); // the account id url + console.log(resp.toJSON()); + me._authorizations = resp.body.authorizations; + me._order = location; + me._finalize = resp.body.finalize; + //console.log('[DEBUG] finalize:', me._finalize); return; + + //return resp.body; + return Promise.all(me._authorizations.map(function (authUrl, i) { + console.log("Authorizations map #" + i); + return ACME._getChallenges(me, options, authUrl).then(function (results) { + // var domain = options.domains[i]; // results.identifier.value + var chType = options.challengeTypes.filter(function (chType) { + return results.challenges.some(function (ch) { + return ch.type === chType; + }); + })[0]; + + var challenge = results.challenges.filter(function (ch) { + if (chType === ch.type) { + return ch; + } + })[0]; + + if (!challenge) { + return Promise.reject(new Error("Server didn't offer any challenge we can handle.")); + } + + return ACME._postChallenge(me, options, results.identifier, challenge); + }); + })).then(function () { + var validatedDomains = body.identifiers.map(function (ident) { + return ident.value; + }); + + return ACME._finalizeOrder(me, options, validatedDomains); + }).then(function () { + return me._request({ method: 'GET', url: me._certificate, json: true }).then(function (resp) { + console.log('Certificate:'); + console.log(resp.body); + return resp.body; }); }); + }); + }); +}; + +ACME.create = function create(me) { + if (!me) { me = {}; } + me.acmeChallengePrefix = ACME.acmeChallengePrefix; + me.RSA = me.RSA || require('rsa-compat').RSA; + me.request = me.request || require('request'); + me.promisify = me.promisify || require('util').promisify; + + + if ('function' !== typeof me.getUserAgentString) { + me.pkg = me.pkg || require('./package.json'); + me.os = me.os || require('os'); + me.process = me.process || require('process'); + me.userAgent = ACME._getUserAgentString(me); + } + + function getRequest(opts) { + if (!opts) { opts = {}; } + + return me.request.defaults({ + headers: { + 'User-Agent': opts.userAgent || me.userAgent || me.getUserAgentString(me) + } + }); + } + + if ('function' !== typeof me._request) { + me._request = me.promisify(getRequest({})); + } + + me.init = function (_directoryUrl) { + me.directoryUrl = me.directoryUrl || _directoryUrl; + return ACME._directory(me).then(function (resp) { + me._directoryUrls = resp.body; + me._tos = me._directoryUrls.meta.termsOfService; + return me._directoryUrls; + }); + }; + me.accounts = { + create: function (options) { + return ACME._registerAccount(me, options); } }; - return acme2; -} - -module.exports.ACME = { - create: create + me.certificates = { + create: function (options) { + return ACME._getCertificate(me, options); + } + }; + return me; }; -Object.keys(defaults).forEach(function (key) { - module.exports.ACME[key] = defaults[key]; -}); diff --git a/test.cb.js b/test.cb.js new file mode 100644 index 0000000..7cccb73 --- /dev/null +++ b/test.cb.js @@ -0,0 +1,75 @@ +'use strict'; + +module.exports.run = function run(web, chType, email) { + var RSA = require('rsa-compat').RSA; + var directoryUrl = 'https://acme-staging-v02.api.letsencrypt.org/directory'; + var acme2 = require('./compat').ACME.create({ RSA: RSA }); + // [ 'test.ppl.family' ] 'coolaj86@gmail.com''http-01' + console.log(web, chType, email); + return; + acme2.init(directoryUrl).then(function (body) { + console.log(body); + return; + + var options = { + agreeToTerms: function (tosUrl, agree) { + agree(null, tosUrl); + } + , setChallenge: function (opts, cb) { + + console.log(""); + console.log('identifier:'); + console.log(opts.identifier); + console.log('hostname:'); + console.log(opts.hostname); + console.log('type:'); + console.log(opts.type); + console.log('token:'); + console.log(opts.token); + console.log('thumbprint:'); + console.log(opts.thumbprint); + console.log('keyAuthorization:'); + console.log(opts.keyAuthorization); + console.log('dnsAuthorization:'); + console.log(opts.dnsAuthorization); + console.log(""); + + console.log("Put the string '" + opts.keyAuthorization + "' into a file at '" + opts.hostname + "/" + opts.token + "'"); + console.log("\nThen hit the 'any' key to continue (must be specifically the 'any' key)..."); + + function onAny() { + process.stdin.pause(); + process.stdin.removeEventListener('data', onAny); + process.stdin.setRawMode(false); + cb(); + } + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.on('data', onAny); + } + , removeChallenge: function (opts, cb) { + // hostname, key + console.log('[DEBUG] remove challenge', hostname, key); + setTimeout(cb, 1 * 1000); + } + , challengeType: chType + , email: email + , accountKeypair: RSA.import({ privateKeyPem: require('fs').readFileSync(__dirname + '/account.privkey.pem') }) + , domainKeypair: RSA.import({ privateKeyPem: require('fs').readFileSync(__dirname + '/privkey.pem') }) + , domains: web + }; + + acme2.registerNewAccount(options).then(function (account) { + console.log('account:'); + console.log(account); + + acme2.getCertificate(options, function (fullchainPem) { + console.log('[acme-v2] A fullchain.pem:'); + console.log(fullchainPem); + }).then(function (fullchainPem) { + console.log('[acme-v2] B fullchain.pem:'); + console.log(fullchainPem); + }); + }); + }); +}; diff --git a/test.compat.js b/test.compat.js new file mode 100644 index 0000000..b4ec70e --- /dev/null +++ b/test.compat.js @@ -0,0 +1,57 @@ +'use strict'; + +var RSA = require('rsa-compat').RSA; + +module.exports.run = function (web, chType, email) { + console.log('[DEBUG] run', web, chType, email); + + var acme2 = require('./compat.js').ACME.create({ RSA: RSA }); + acme2.getAcmeUrls(acme2.stagingServerUrl, function (err, body) { + if (err) { console.log('err 1'); throw err; } + console.log(body); + + var options = { + agreeToTerms: function (tosUrl, agree) { + agree(null, tosUrl); + } + , setChallenge: function (hostname, token, val, cb) { + console.log("Put the string '" + val + "' into a file at '" + hostname + "/" + acme2.acmeChallengePrefix + "/" + token + "'"); + console.log("echo '" + val + "' > '" + hostname + "/" + acme2.acmeChallengePrefix + "/" + token + "'"); + console.log("\nThen hit the 'any' key to continue (must be specifically the 'any' key)..."); + + function onAny() { + console.log("'any' key was hit"); + process.stdin.pause(); + process.stdin.removeListener('data', onAny); + process.stdin.setRawMode(false); + cb(); + } + + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.on('data', onAny); + } + , removeChallenge: function (hostname, key, cb) { + console.log('[DEBUG] remove challenge', hostname, key); + setTimeout(cb, 1 * 1000); + } + , challengeType: chType + , email: email + , accountKeypair: RSA.import({ privateKeyPem: require('fs').readFileSync(__dirname + '/account.privkey.pem') }) + , domainKeypair: RSA.import({ privateKeyPem: require('fs').readFileSync(__dirname + '/privkey.pem') }) + , domains: web + }; + + acme2.registerNewAccount(options, function (err, account) { + if (err) { console.log('err 2'); throw err; } + console.log('account:'); + console.log(account); + + acme2.getCertificate(options, function (err, fullchainPem) { + if (err) { console.log('err 3'); throw err; } + console.log('[acme-v2] A fullchain.pem:'); + console.log(fullchainPem); + }); + }); + }); +}; diff --git a/test.js b/test.js index 7c1f373..6a6772b 100644 --- a/test.js +++ b/test.js @@ -1,56 +1,45 @@ 'use strict'; -var RSA = require('rsa-compat').RSA; -var acme2 = require('./').ACME.create({ RSA: RSA }); - -acme2.getAcmeUrls(acme2.stagingServerUrl).then(function (body) { - console.log(body); - - var options = { - agreeToTerms: function (tosUrl, agree) { - agree(null, tosUrl); - } - /* - , setupChallenge: function (opts) { - console.log('type:'); - console.log(ch.type); - console.log('ch.token:'); - console.log(ch.token); - console.log('thumbprint:'); - console.log(thumbprint); - console.log('keyAuthorization:'); - console.log(keyAuthorization); - console.log('dnsAuthorization:'); - console.log(dnsAuthorization); - } - */ - // teardownChallenge - , setChallenge: function (hostname, key, val, cb) { - console.log('[DEBUG] set challenge', hostname, key, val); - console.log("You have 20 seconds to put the string '" + val + "' into a file at '" + hostname + "/" + key + "'"); - setTimeout(cb, 20 * 1000); - } - , removeChallenge: function (hostname, key, cb) { - console.log('[DEBUG] remove challenge', hostname, key); - setTimeout(cb, 1 * 1000); - } - , challengeType: 'http-01' - , email: 'coolaj86@gmail.com' - , accountKeypair: RSA.import({ privateKeyPem: require('fs').readFileSync(__dirname + '/account.privkey.pem') }) - , domainKeypair: RSA.import({ privateKeyPem: require('fs').readFileSync(__dirname + '/privkey.pem') }) - , domains: [ 'test.ppl.family' ] - }; - - acme2.registerNewAccount(options).then(function (account) { - console.log('account:'); - console.log(account); - - acme2.getCertificate(options, function (fullchainPem) { - console.log('[acme-v2] A fullchain.pem:'); - console.log(fullchainPem); - }).then(function (fullchainPem) { - console.log('[acme-v2] B fullchain.pem:'); - console.log(fullchainPem); - }); - }); +var readline = require('readline'); +var rl = readline.createInterface({ + input: process.stdin, + output: process.stdout }); + +function getWeb() { + rl.question('What web address(es) would you like to get certificates for? (ex: example.com,*.example.com) ', function (web) { + web = (web||'').trim().split(/,/g); + if (!web[0]) { getWeb(); return; } + + if (web.some(function (w) { return '*' === w[0]; })) { + console.log('Wildcard domains must use dns-01'); + getEmail(web, 'dns-01'); + } else { + getChallengeType(web); + } + }); +} + +function getChallengeType(web) { + rl.question('What challenge will you be testing today? http-01 or dns-01? [http-01] ', function (chType) { + chType = (chType||'').trim(); + if (!chType) { chType = 'http-01'; } + + getEmail(web, chType); + }); +} + +function getEmail(web, chType) { + rl.question('What email should we use? (optional) ', function (email) { + email = (email||'').trim(); + if (!email) { email = null; } + + rl.close(); + console.log("[DEBUG] rl blah blah"); + require('./test.compat.js').run(web, chType, email); + //require('./test.cb.js').run(web, chType, email); + //require('./test.promise.js').run(web, chType, email); + }); +} + +getWeb(); diff --git a/test.promise.js b/test.promise.js new file mode 100644 index 0000000..4da5392 --- /dev/null +++ b/test.promise.js @@ -0,0 +1,84 @@ +'use strict'; + +/* global Promise */ + +module.exports.run = function run(web, chType, email) { + var RSA = require('rsa-compat').RSA; + var directoryUrl = 'https://acme-staging-v02.api.letsencrypt.org/directory'; + var acme2 = require('./compat').ACME.create({ RSA: RSA }); + // [ 'test.ppl.family' ] 'coolaj86@gmail.com''http-01' + console.log(web, chType, email); + return; + acme2.init(directoryUrl).then(function (body) { + console.log(body); + return; + + var options = { + agreeToTerms: function (tosUrl, agree) { + agree(null, tosUrl); + } + , setChallenge: function (opts) { + + console.log(""); + console.log('identifier:'); + console.log(opts.identifier); + console.log('hostname:'); + console.log(opts.hostname); + console.log('type:'); + console.log(opts.type); + console.log('token:'); + console.log(opts.token); + console.log('thumbprint:'); + console.log(opts.thumbprint); + console.log('keyAuthorization:'); + console.log(opts.keyAuthorization); + console.log('dnsAuthorization:'); + console.log(opts.dnsAuthorization); + console.log(""); + + console.log("Put the string '" + opts.keyAuthorization + "' into a file at '" + opts.hostname + "/" + opts.token + "'"); + console.log("\nThen hit the 'any' key to continue (must be specifically the 'any' key)..."); + + return new Promise(function (resolve) { + function onAny() { + process.stdin.pause(); + process.stdin.removeEventListener('data', onAny); + process.stdin.setRawMode(false); + + resolve(); + } + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.on('data', onAny); + }); + } + , removeChallenge: function (opts) { + // hostname, key + console.log('[DEBUG] remove challenge', opts.hostname, opts.keyAuthorization); + console.log("Remove the file '" + opts.hostname + "/" + opts.token + "'"); + + return new Promise(function (resolve) { + setTimeout(resolve, 1 * 1000); + }); + } + , challengeType: chType + , email: email + , accountKeypair: RSA.import({ privateKeyPem: require('fs').readFileSync(__dirname + '/account.privkey.pem') }) + , domainKeypair: RSA.import({ privateKeyPem: require('fs').readFileSync(__dirname + '/privkey.pem') }) + , domains: web + }; + + acme2.registerNewAccount(options).then(function (account) { + console.log('account:'); + console.log(account); + + acme2.getCertificate(options, function (fullchainPem) { + console.log('[acme-v2] A fullchain.pem:'); + console.log(fullchainPem); + }).then(function (fullchainPem) { + console.log('[acme-v2] B fullchain.pem:'); + console.log(fullchainPem); + }); + }); + }); +};