diff --git a/README.md b/README.md index 5a2b54e..11e414e 100644 --- a/README.md +++ b/README.md @@ -24,40 +24,23 @@ Install npm install --save letsencrypt ``` -Right now this uses [`letsencrypt-python`](https://github.com/Daplie/node-letsencrypt-python), -but it's built to be able to use a node-only javascript version (in progress). - -```bash -# install the python client (takes 2 minutes normally, 20 on a raspberry pi) -git clone https://github.com/letsencrypt/letsencrypt -pushd letsencrypt - -./letsencrypt-auto -``` - -### Great News: - -The pure node `ursa` and `forge` branches are almost complete (and completely compatible with the official client directory and file structure)! `ursa` will be fast and work on Raspberry Pi. `forge` will be slow, but it will work on Windows. - -* https://github.com/Daplie/node-letsencrypt/tree/ursa - -Ping [@coolaj86](https://coolaj86.com) if you'd like to help. - Usage ===== -Here's a simple snippet: +See [letsencrypt-cli](https://github.com/Daplie/node-letsencrypt-cli) +and [letsencrypt-express](https://github.com/Daplie/letsencrypt-express) ```javascript var config = require('./examples/config-minimal'); config.le.webrootPath = __dirname + '/tests/acme-challenge'; -var le = require('letsencrypt').create(config.backend, config.le); +var le = require('letsencrypt').create(config.le); le.register({ agreeTos: true , domains: ['example.com'] // CHANGE TO YOUR DOMAIN , email: 'user@email.com' // CHANGE TO YOUR EMAIL +, standalone: true }, function (err) { if (err) { console.error('[Error]: node-letsencrypt/examples/standalone'); @@ -265,52 +248,27 @@ and then make sure to set all of of the following to a directory that your user * `webrootPath` * `configDir` -* `workDir` (python backend only) -* `logsDir` (python backend only) API === ```javascript -LetsEncrypt.create(backend, bkDefaults, handlers) // wraps a given "backend" (the python client) -LetsEncrypt.stagingServer // string of staging server for testing +LetsEncrypt.init(leConfig, handlers) // wraps a given +LetsEncrypt.create(backend, leConfig, handlers) // wraps a given "backend" (the python or node client) +LetsEncrypt.stagingServer // string of staging server for testing -le.middleware() // middleware for serving webrootPath to /.well-known/acme-challenge -le.sniCallback(hostname, function (err, tlsContext) {}) // uses fetch (below) and formats for https.SNICallback -le.register({ domains, email, agreeTos, ... }, cb) // registers or renews certs for a domain -le.fetch({domains, email, agreeTos, ... }, cb) // fetches certs from in-memory cache, occasionally refreshes from disk -le.validate(domains, cb) // do some sanity checks before attempting to register -le.registrationFailureCallback(err, args, certInfo, cb) // called when registration fails (not implemented yet) +le.middleware() // middleware for serving webrootPath to /.well-known/acme-challenge +le.sniCallback(hostname, function (err, tlsContext) {}) // uses fetch (below) and formats for https.SNICallback +le.register({ domains, email, agreeTos, ... }, cb) // registers or renews certs for a domain +le.fetch({domains, email, agreeTos, ... }, cb) // fetches certs from in-memory cache, occasionally refreshes from disk +le.validate(domains, cb) // do some sanity checks before attempting to register +le.registrationFailureCallback(err, args, certInfo, cb) // called when registration fails (not implemented yet) ``` -### `LetsEncrypt.create(backend, bkDefaults, handlers)` +### `LetsEncrypt.create(backend, leConfig, handlers)` -#### backend - -Currently only `letsencrypt-python` is supported, but we plan to work on -native javascript support in February or so (when ECDSA keys are available). - -If you'd like to help with that, see **how to write a backend** below and also -look at the wrapper `backend-python.js`. - -**Example**: -```javascript -{ fetch: function (args, cb) { - // cb(err) when there is an actual error (db, fs, etc) - // cb(null, null) when the certificate was NOT available on disk - // cb(null, { cert: '', key: '', renewedAt: 0, duration: 0 }) cert + meta - } -, register: function (args, setChallenge, cb) { - // setChallenge(hostnames, key, value, cb) when a challenge needs to be set - // cb(err) when there is an error - // cb(null, null) when the registration is successful, but fetch still needs to be called - // cb(null, cert /*see above*/) if registration can easily return the same as fetch - } -} -``` - -#### bkDefaults +#### leConfig The arguments passed here (typically `webpathRoot`, `configDir`, etc) will be merged with any `args` (typically `domains`, `email`, and `agreeTos`) and passed to the backend whenever @@ -328,7 +286,7 @@ Typically the backend wrapper will already merge any necessary backend-specific ``` Note: `webrootPath` can be set as a default, semi-locally with `webrootPathTpl`, or per -registration as `webrootPath` (which overwrites `defaults.webrootPath`). +registration as `webrootPath` (which overwrites `leConfig.webrootPath`). #### handlers *optional* @@ -434,20 +392,6 @@ Checks in-memory cache of certificates for `args.domains` and calls then calls ` Not yet implemented -Backends --------- - -* [`letsencrypt-python`](https://github.com/Daplie/node-letsencrypt-python) (complete) -* [`letiny`](https://github.com/Daplie/node-letiny) (in progress) - -#### How to write a backend - -A backend must implement (or be wrapped to implement) this API: - -* `fetch(hostname, cb)` will cb(err, certs) with certs from disk (or null or error) -* `register(args, challengeCb, done)` will register and or renew a cert - * args = `{ domains, email, agreeTos }` MUST check that agreeTos === true - * challengeCb = `function (challenge, cb) { }` handle challenge as needed, call cb() This is what `args` looks like: @@ -468,61 +412,12 @@ This is what the implementation should look like: (it's expected that the client will follow the same conventions as the python client, but it's not necessary) -```javascript -return { - fetch: function (args, cb) { - // NOTE: should return an error if args.domains cannot be satisfied with a single cert - // (usually example.com and www.example.com will be handled on the same cert, for example) - if (errorHappens) { - // return an error if there is an actual error (db, etc) - cb(err); - return; - } - // return null if there is no error, nor a certificate - else if (!cert) { - cb(null, null); - return; - } - - // NOTE: if the certificate is available but expired it should be - // returned and the calling application will decide to renew when - // it is convenient - - // NOTE: the application should handle caching, not the library - - // return the cert with metadata - cb(null, { - cert: "/*contcatonated certs in pem format: cert + intermediate*/" - , key: "/*private keypair in pem format*/" - , renewedAt: new Date() // fs.stat cert.pem should also work - , duration: 90 * 24 * 60 * 60 * 1000 // assumes 90-days unless specified - }); - } -, register: function (args, challengeCallback, completeCallback) { - // **MUST** reject if args.agreeTos is not true - - // once you're ready for the caller to know the challenge - if (challengeCallback) { - challengeCallback(challenge, function () { - continueRegistration(); - }) - } else { - continueRegistration(); - } - - function continueRegistration() { - // it is not necessary to to return the certificates here - // the client will call fetch() when it needs them - completeCallback(err); - } - } -}; -``` - Change History ============== -v1.0.0 Thar be dragons +* v1.1.0 Added letiny-core, removed node-letsencrypt-python +* v1.0.2 Works with node-letsencrypt-python +* v1.0.0 Thar be dragons LICENSE ======= diff --git a/backends/python.js b/backends/python.js deleted file mode 100644 index 4fc1bc9..0000000 --- a/backends/python.js +++ /dev/null @@ -1,42 +0,0 @@ -'use strict'; - -var PromiseA = require('bluebird'); -var fs = PromiseA.promisifyAll(require('fs')); - -module.exports.create = function (defaults, opts) { - defaults.webroot = true; - defaults.renewByDefault = true; - defaults.text = true; - - var leBinPath = defaults.pythonClientPath; - var LEP = require('letsencrypt-python'); - var lep = PromiseA.promisifyAll(LEP.create(leBinPath, opts)); - var wrapped = { - registerAsync: function (args) { - return lep.registerAsync('certonly', args); - } - , fetchAsync: function (args) { - var hostname = args.domains[0]; - var crtpath = defaults.configDir + defaults.fullchainTpl.replace(/:hostname/, hostname); - var privpath = defaults.configDir + defaults.privkeyTpl.replace(/:hostname/, hostname); - - return PromiseA.all([ - fs.readFileAsync(privpath, 'ascii') - , fs.readFileAsync(crtpath, 'ascii') - // stat the file, not the link - , fs.statAsync(crtpath) - ]).then(function (arr) { - return { - key: arr[0] // privkey.pem - , cert: arr[1] // fullchain.pem - // TODO parse centificate for lifetime / expiresAt - , issuedAt: arr[2].mtime.valueOf() - }; - }, function () { - return null; - }); - } - }; - - return wrapped; -}; diff --git a/examples/commandline-minimal.js b/examples/commandline-minimal.js index 48dd923..61d3cb8 100644 --- a/examples/commandline-minimal.js +++ b/examples/commandline-minimal.js @@ -11,11 +11,11 @@ config.le.server = LE.stagingServer; // // Manual Registration // -var le = LE.create(config.backend, config.le); +var le = LE.create(config.le); le.register({ agreeTos: true -, domains: ['example.com'] // CHANGE TO YOUR DOMAIN -, email: 'user@example.com' // CHANGE TO YOUR EMAIL +, domains: [process.argv[3] || 'example.com'] // CHANGE TO YOUR DOMAIN +, email: process.argv[2] || 'user@example.com' // CHANGE TO YOUR EMAIL }, function (err) { if (err) { console.error('[Error]: node-letsencrypt/examples/standalone'); diff --git a/examples/commandline.js b/examples/commandline.js index 3ee6925..47604d0 100644 --- a/examples/commandline.js +++ b/examples/commandline.js @@ -28,13 +28,9 @@ var bkDefaults = { // backend-specific , logsDir: path.join(__dirname, '..', 'tests', 'letsencrypt.logs') , workDir: path.join(__dirname, '..', 'tests', 'letsencrypt.work') -, text: true -, pythonClientPath: require('os').homedir() + '/.local/share/letsencrypt/bin/letsencrypt' }; -var LEP = require('../backends/python'); - -var le = LE.create(LEP, bkDefaults, { +var le = LE.create(bkDefaults, { /* setChallenge: function (hostnames, key, value, cb) { // the python backend needs fs.watch implemented diff --git a/examples/config-minimal.js b/examples/config-minimal.js index 2a88cb9..454119f 100644 --- a/examples/config-minimal.js +++ b/examples/config-minimal.js @@ -2,8 +2,6 @@ var path = require('path'); -var binpath = require('os').homedir() + '/.local/share/letsencrypt/bin/letsencrypt'; - var config = { plainPort: 80 @@ -21,12 +19,8 @@ var config = { // these are specific to the python client and won't be needed with the purejs library , logsDir: path.join(__dirname, '..', 'tests', 'letsencrypt.logs') , workDir: path.join(__dirname, '..', 'tests', 'letsencrypt.work') - , pythonClientPath: binpath } }; -//config.backend = require('letsencrypt/backends/python').create(binpath, config.le); -config.backend = require('../backends/python'); - module.exports = config; diff --git a/examples/express-minimal.js b/examples/express-minimal.js index dde1717..4e3cdba 100644 --- a/examples/express-minimal.js +++ b/examples/express-minimal.js @@ -7,7 +7,7 @@ var config = require('./config-minimal'); config.le.webrootPath = __dirname + '/../tests/acme-challenge'; config.le.server = LE.stagingServer; -var le = LE.create(config.backend, config.le, { +var le = LE.create(config.le, { sniRegisterCallback: function (args, expiredCert, cb) { // In theory you should never get an expired certificate because // the certificates automatically renew in the background starting diff --git a/examples/express.js b/examples/express.js index 81ade0e..356d583 100644 --- a/examples/express.js +++ b/examples/express.js @@ -24,15 +24,8 @@ var bkDefaults = { , privkeyTpl: '/live/:hostname/privkey.pem' , configDir: path.join(__dirname, '..', 'tests', 'letsencrypt.config') , server: LE.stagingServer - -// python-specific -, logsDir: path.join(__dirname, '..', 'tests', 'letsencrypt.logs') -, workDir: path.join(__dirname, '..', 'tests', 'letsencrypt.work') -, pythonClientPath: require('os').homedir() + '/.local/share/letsencrypt/bin/letsencrypt' }; -var LEP = require('../backends/python'); - var le = LE.create(LEP, bkDefaults, { sniRegisterCallback: function (args, certInfo, cb) { var allowedDomains = conf.domains; // require('../tests/config').allowedDomains; diff --git a/examples/ursa.js b/examples/ursa.js new file mode 100644 index 0000000..519e8cd --- /dev/null +++ b/examples/ursa.js @@ -0,0 +1,54 @@ +'use strict'; + +var LE = require('../'); +var config = require('./config-minimal'); + +// Note: you should make this special dir in your product and leave it empty +config.le.webrootPath = __dirname + '/../tests/acme-challenge'; +config.le.server = LE.stagingServer; + + +// +// Manual Registration +// +var le = LE.create(config.le); +le.backend.registerAsync({ + agreeTos: true +, domains: ['example.com'] // CHANGE TO YOUR DOMAIN +, email: 'user@example.com' // CHANGE TO YOUR EMAIL +}, function (err, body) { + if (err) { + console.error('[Error]: node-letsencrypt/examples/ursa'); + console.error(err.stack); + } else { + console.log('success', body); + } + + plainServer.close(); + tlsServer.close(); +}).then(function () {}, function (err) { + console.error(err.stack); +}); + +// +// Express App +// +var app = require('express')(); +app.use('/', le.middleware()); + + +// +// HTTP & HTTPS servers +// (required for domain validation) +// +var plainServer = require('http').createServer(app).listen(config.plainPort, function () { + console.log('Listening http', this.address()); +}); + +var tlsServer = require('https').createServer({ + key: config.tlsKey +, cert: config.tlsCert +, SNICallback: le.sniCallback +}, app).listen(config.tlsPort, function () { + console.log('Listening http', this.address()); +}); diff --git a/index.js b/index.js index 5c6d3c1..3ad21e8 100644 --- a/index.js +++ b/index.js @@ -5,15 +5,20 @@ var PromiseA = require('bluebird'); var crypto = require('crypto'); var tls = require('tls'); -var path = require('path'); +var leCore = require('./lib/letiny-core'); var LE = module.exports; +LE.productionServerUrl = leCore.productionServerUrl; +LE.stagingServer = leCore.stagingServerUrl; +LE.configDir = leCore.configDir; +LE.logsDir = leCore.logsDir; +LE.workDir = leCore.workDir; +LE.acmeChallengPrefix = leCore.acmeChallengPrefix; +LE.knownEndpoints = leCore.knownEndpoints; -LE.liveServer = "https://acme-v01.api.letsencrypt.org/directory"; -LE.stagingServer = "https://acme-staging.api.letsencrypt.org/directory"; -LE.configDir = "/etc/letsencrypt/"; -LE.logsDir = "/var/log/letsencrypt/"; -LE.workDir = "/var/lib/letsencrypt/"; +// backwards compat +LE.liveServer = leCore.productionServerUrl; +LE.knownUrls = leCore.knownEndpoints; LE.merge = function merge(defaults, args) { var copy = {}; @@ -28,19 +33,48 @@ LE.merge = function merge(defaults, args) { return copy; }; -LE.create = function (backend, defaults, handlers) { - if ('function' === typeof backend.create) { - backend.create(defaults, handlers); - } - else if ('string' === typeof backend) { - // TODO I'll probably regret this - // I don't like dynamic requires because they cause build / minification issues. - backend = require(path.join('backends', backend)).create(defaults, handlers); - } - else { - // ignore - // this backend was created the v1.0.0 way +LE.cacheCertInfo = function (args, certInfo, ipc, handlers) { + // TODO IPC via process and worker to guarantee no races + // rather than just "really good odds" + + var hostname = args.domains[0]; + var now = Date.now(); + + // Stagger randomly by plus 0% to 25% to prevent all caches expiring at once + var rnd1 = (crypto.randomBytes(1)[0] / 255); + var memorizeFor = Math.floor(handlers.memorizeFor + ((handlers.memorizeFor / 4) * rnd1)); + // Stagger randomly to renew between n and 2n days before renewal is due + // this *greatly* reduces the risk of multiple cluster processes renewing the same domain at once + var rnd2 = (crypto.randomBytes(1)[0] / 255); + var bestIfUsedBy = certInfo.expiresAt - (handlers.renewWithin + Math.floor(handlers.renewWithin * rnd2)); + // Stagger randomly by plus 0 to 5 min to reduce risk of multiple cluster processes + // renewing at once on boot when the certs have expired + var rnd3 = (crypto.randomBytes(1)[0] / 255); + var renewTimeout = Math.floor((5 * 60 * 1000) * rnd3); + + certInfo.context = tls.createSecureContext({ + key: certInfo.key + , cert: certInfo.cert + //, ciphers // node's defaults are great + }); + certInfo.loadedAt = now; + certInfo.memorizeFor = memorizeFor; + certInfo.bestIfUsedBy = bestIfUsedBy; + certInfo.renewTimeout = renewTimeout; + + ipc[hostname] = certInfo; + return ipc[hostname]; +}; + + // backend, defaults, handlers +LE.create = function (defaults, handlers, backend) { + var d, b, h; + // backwards compat for <= v1.0.2 + if (defaults.registerAsync || defaults.create) { + b = defaults; d = handlers; h = backend; + defaults = d; handlers = h; backend = b; } + if (!backend) { backend = require('./lib/letiny-core'); } if (!handlers) { handlers = {}; } if (!handlers.lifetime) { handlers.lifetime = 90 * 24 * 60 * 60 * 1000; } if (!handlers.renewWithin) { handlers.renewWithin = 3 * 24 * 60 * 60 * 1000; } @@ -51,9 +85,52 @@ LE.create = function (backend, defaults, handlers) { cb(null, null); }; } + if (!handlers.getChallenge) { + if (!defaults.manual && !defaults.webrootPath) { + // GET /.well-known/acme-challenge/{{challengeKey}} should return {{tokenValue}} + throw new Error("handlers.getChallenge or defaults.webrootPath must be set"); + } + handlers.getChallenge = function (hostname, key, done) { + // TODO associate by hostname? + // hmm... I don't think there's a direct way to associate this with + // the request it came from... it's kinda stateless in that way + // but realistically there only needs to be one handler and one + // "directory" for this. It's not that big of a deal. + var defaultos = LE.merge(defaults, {}); + defaultos.domains = [hostname]; + require('./lib/default-handlers').getChallenge(defaultos, key, done); + }; + } + if (!handlers.setChallenge) { + if (!defaults.webrootPath) { + // GET /.well-known/acme-challenge/{{challengeKey}} should return {{tokenValue}} + throw new Error("handlers.setChallenge or defaults.webrootPath must be set"); + } + handlers.setChallenge = require('./lib/default-handlers').setChallenge; + } + if (!handlers.removeChallenge) { + if (!defaults.webrootPath) { + // GET /.well-known/acme-challenge/{{challengeKey}} should return {{tokenValue}} + throw new Error("handlers.removeChallenge or defaults.webrootPath must be set"); + } + handlers.removeChallenge = require('./lib/default-handlers').removeChallenge; + } + if (!handlers.agreeToTerms) { + if (defaults.agreeTos) { + console.warn("[WARN] Agreeing to terms by default is risky business..."); + } + handlers.agreeToTerms = require('./lib/default-handlers').agreeToTerms; + } + if ('function' === typeof backend.create) { + backend = backend.create(defaults, handlers); + } + else { + // ignore + // this backend was created the v1.0.0 way + } backend = PromiseA.promisifyAll(backend); - var utils = require('./utils'); + var utils = require('./utils'); //var attempts = {}; // should exist in master process only var ipc = {}; // in-process cache var le; @@ -104,7 +181,8 @@ LE.create = function (backend, defaults, handlers) { } le = { - validate: function (hostnames, cb) { + backend: backend + , validate: function (hostnames, cb) { // TODO check dns, etc if ((!hostnames.length && hostnames.every(le.isValidDomain))) { cb(new Error("node-letsencrypt: invalid hostnames: " + hostnames.join(','))); @@ -127,17 +205,25 @@ LE.create = function (backend, defaults, handlers) { cb(null, true); } , middleware: function () { - //console.log('[DEBUG] webrootPath', defaults.webrootPath); - var serveStatic = require('serve-static')(defaults.webrootPath, { dotfiles: 'allow' }); - var prefix = '/.well-known/acme-challenge/'; + var prefix = leCore.acmeChallengePrefix; return function (req, res, next) { if (0 !== req.url.indexOf(prefix)) { + //console.log('[LE middleware]: pass'); next(); return; } - serveStatic(req, res, next); + //args.domains = [req.hostname]; + //console.log('[LE middleware]:', req.hostname, req.url, req.url.slice(prefix.length)); + handlers.getChallenge(req.hostname, req.url.slice(prefix.length), function (err, token) { + if (err) { + res.send("Error: These aren't the tokens you're looking for. Move along."); + return; + } + + res.send(token); + }); }; } , SNICallback: sniCallback @@ -159,9 +245,9 @@ LE.create = function (backend, defaults, handlers) { return; } - console.log("[NLE]: begin registration"); + //console.log("[NLE]: begin registration"); return backend.registerAsync(copy).then(function () { - console.log("[NLE]: end registration"); + //console.log("[NLE]: end registration"); // calls fetch because fetch calls cacheCertInfo return le.fetch(args, cb); }, cb); @@ -231,6 +317,10 @@ LE.create = function (backend, defaults, handlers) { le._fetchHelper(args, cb); } , register: function (args, cb) { + if (!Array.isArray(args.domains)) { + cb(new Error('args.domains should be an array of domains')); + return; + } // this may be run in a cluster environment // in that case it should NOT check the cache // but ensure that it has the most fresh copy @@ -284,36 +374,3 @@ LE.create = function (backend, defaults, handlers) { return le; }; - -LE.cacheCertInfo = function (args, certInfo, ipc, handlers) { - // TODO IPC via process and worker to guarantee no races - // rather than just "really good odds" - - var hostname = args.domains[0]; - var now = Date.now(); - - // Stagger randomly by plus 0% to 25% to prevent all caches expiring at once - var rnd1 = (crypto.randomBytes(1)[0] / 255); - var memorizeFor = Math.floor(handlers.memorizeFor + ((handlers.memorizeFor / 4) * rnd1)); - // Stagger randomly to renew between n and 2n days before renewal is due - // this *greatly* reduces the risk of multiple cluster processes renewing the same domain at once - var rnd2 = (crypto.randomBytes(1)[0] / 255); - var bestIfUsedBy = certInfo.expiresAt - (handlers.renewWithin + Math.floor(handlers.renewWithin * rnd2)); - // Stagger randomly by plus 0 to 5 min to reduce risk of multiple cluster processes - // renewing at once on boot when the certs have expired - var rnd3 = (crypto.randomBytes(1)[0] / 255); - var renewTimeout = Math.floor((5 * 60 * 1000) * rnd3); - - certInfo.context = tls.createSecureContext({ - key: certInfo.key - , cert: certInfo.cert - //, ciphers // node's defaults are great - }); - certInfo.loadedAt = now; - certInfo.memorizeFor = memorizeFor; - certInfo.bestIfUsedBy = bestIfUsedBy; - certInfo.renewTimeout = renewTimeout; - - ipc[hostname] = certInfo; - return ipc[hostname]; -}; diff --git a/lib/common.js b/lib/common.js new file mode 100644 index 0000000..f8ccde6 --- /dev/null +++ b/lib/common.js @@ -0,0 +1,30 @@ +'use strict'; + +var fs = require('fs'); +var PromiseA = require('bluebird'); + +module.exports.fetchFromDisk = function (args, defaults) { + var hostname = args.domains[0]; + var crtpath = (args.fullchainPath || defaults.fullchainPath) + || (defaults.configDir + + (args.fullchainTpl || defaults.fullchainTpl || ':hostname/fullchain.pem').replace(/:hostname/, hostname)); + var privpath = (args.privkeyPath || defaults.privkeyPath) + || (defaults.configDir + + (args.privkeyTpl || defaults.privkeyTpl || ':hostname/privkey.pem').replace(/:hostname/, hostname)); + + return PromiseA.all([ + fs.readFileAsync(privpath, 'ascii') + , fs.readFileAsync(crtpath, 'ascii') + // stat the file, not the link + , fs.statAsync(crtpath) + ]).then(function (arr) { + return { + key: arr[0] // privkey.pem + , cert: arr[1] // fullchain.pem + // TODO parse centificate for lifetime / expiresAt + , issuedAt: arr[2].mtime.valueOf() + }; + }, function () { + return null; + }); +}; diff --git a/lib/crypto-utils-ursa.js b/lib/crypto-utils-ursa.js new file mode 100644 index 0000000..f9e9de6 --- /dev/null +++ b/lib/crypto-utils-ursa.js @@ -0,0 +1,76 @@ +'use strict'; + +var crypto = require('crypto'); +var ursa = require('ursa'); +var forge = require('node-forge'); + +function binstr2b64(binstr) { + return new Buffer(binstr, 'binary').toString('base64'); +} + +function toAcmePrivateKey(privkeyPem) { + var forgePrivkey = forge.pki.privateKeyFromPem(privkeyPem); + + return { + kty: "RSA" + , n: binstr2b64(forgePrivkey.n) + , e: binstr2b64(forgePrivkey.e) + , d: binstr2b64(forgePrivkey.d) + , p: binstr2b64(forgePrivkey.p) + , q: binstr2b64(forgePrivkey.q) + , dp: binstr2b64(forgePrivkey.dP) + , dq: binstr2b64(forgePrivkey.dQ) + , qi: binstr2b64(forgePrivkey.qInv) + }; +} + +function generateRsaKeypair(bitlen, exp, cb) { + var keypair = ursa.generatePrivateKey(bitlen /*|| 2048*/, exp /*65537*/); + var pems = { + publicKeyPem: keypair.toPublicPem().toString('ascii') // ascii PEM: ----BEGIN... + , privateKeyPem: keypair.toPrivatePem().toString('ascii') // ascii PEM: ----BEGIN... + }; + + // I would have chosen sha1 or sha2... but whatever + pems.publicKeyMd5 = crypto.createHash('md5').update(pems.publicKeyPem).digest('hex'); + // json { n: ..., e: ..., iq: ..., etc } + pems.privateKeyJwk = toAcmePrivateKey(pems.privateKeyPem); + pems.privateKeyJson = pems.privateKeyJwk; + + // TODO thumbprint + + cb(null, pems); +} + +function parseAccountPrivateKey(pkj, cb) { + Object.keys(pkj).forEach(function (key) { + pkj[key] = new Buffer(pkj[key], 'base64'); + }); + + var priv; + + try { + priv = ursa.createPrivateKeyFromComponents( + pkj.n // modulus + , pkj.e // exponent + , pkj.p + , pkj.q + , pkj.dp + , pkj.dq + , pkj.qi + , pkj.d + ); + } catch(e) { + cb(e); + return; + } + + cb(null, { + privateKeyPem: priv.toPrivatePem.toString('ascii') + , publicKeyPem: priv.toPublicPem.toString('ascii') + }); +} + +module.exports.parseAccountPrivateKey = parseAccountPrivateKey; +module.exports.generateRsaKeypair = generateRsaKeypair; +module.exports.toAcmePrivateKey = toAcmePrivateKey; diff --git a/lib/default-handlers.js b/lib/default-handlers.js new file mode 100644 index 0000000..17852a2 --- /dev/null +++ b/lib/default-handlers.js @@ -0,0 +1,40 @@ +'use strict'; + +var fs = require('fs'); +var path = require('path'); + +module.exports.agreeToTerms = function (args, agree) { + agree(null, args.agreeTos); +}; + +module.exports.setChallenge = function (args, challengePath, keyAuthorization, done) { + //var hostname = args.domains[0]; + var mkdirp = require('mkdirp'); + + // TODO should be args.webrootPath + //console.log('args.webrootPath, challengePath'); + //console.log(args.webrootPath, challengePath); + mkdirp(args.webrootPath, function (err) { + if (err) { + done(err); + return; + } + + fs.writeFile(path.join(args.webrootPath, challengePath), keyAuthorization, 'utf8', function (err) { + done(err); + }); + }); +}; + +module.exports.getChallenge = function (args, key, done) { + //var hostname = args.domains[0]; + + //console.log("getting the challenge", args, key); + fs.readFile(path.join(args.webrootPath, key), 'utf8', done); +}; + +module.exports.removeChallenge = function (args, key, done) { + //var hostname = args.domains[0]; + + fs.unlink(path.join(args.webrootPath, key), done); +}; diff --git a/lib/letiny-core.js b/lib/letiny-core.js new file mode 100644 index 0000000..6be57e0 --- /dev/null +++ b/lib/letiny-core.js @@ -0,0 +1,352 @@ +'use strict'; + +var PromiseA = require('bluebird'); +var mkdirpAsync = PromiseA.promisify(require('mkdirp')); +var path = require('path'); +var fs = PromiseA.promisifyAll(require('fs')); +var sfs = require('safe-replace'); + +var LE = require('../'); +var LeCore = PromiseA.promisifyAll(require('letiny-core')); +var leCrypto = PromiseA.promisifyAll(LeCore.leCrypto); + +var fetchFromConfigLiveDir = require('./common').fetchFromDisk; + +var ipc = {}; // in-process cache + +function getAcmeUrls(args) { + var now = Date.now(); + + // TODO check response header on request for cache time + if ((now - ipc.acmeUrlsUpdatedAt) < 10 * 60 * 1000) { + return PromiseA.resolve(ipc.acmeUrls); + } + + return LeCore.getAcmeUrlsAsync(args.server).then(function (data) { + ipc.acmeUrlsUpdatedAt = Date.now(); + ipc.acmeUrls = data; + + return ipc.acmeUrls; + }); +} + +function createAccount(args, handlers) { + var os = require("os"); + var localname = os.hostname(); + + // TODO support ECDSA + // arg.rsaBitLength args.rsaExponent + return leCrypto.generateRsaKeypairAsync(args.rsaBitLength, args.rsaExponent).then(function (pems) { + /* pems = { privateKeyPem, privateKeyJwk, publicKeyPem, publicKeyMd5 } */ + + return LeCore.registerNewAccountAsync({ + email: args.email + , newRegUrl: args._acmeUrls.newReg + , agreeToTerms: function (tosUrl, agree) { + // args.email = email; // already there + args.tosUrl = tosUrl; + handlers.agreeToTerms(args, agree); + } + , accountPrivateKeyPem: pems.privateKeyPem + + , debug: args.debug || handlers.debug + }).then(function (body) { + var accountDir = path.join(args.accountsDir, pems.publicKeyMd5); + + return mkdirpAsync(accountDir).then(function () { + + var isoDate = new Date().toISOString(); + var accountMeta = { + creation_host: localname + , creation_dt: isoDate + }; + + return PromiseA.all([ + // meta.json {"creation_host": "ns1.redirect-www.org", "creation_dt": "2015-12-11T04:14:38Z"} + fs.writeFileAsync(path.join(accountDir, 'meta.json'), JSON.stringify(accountMeta), 'utf8') + // private_key.json { "e", "d", "n", "q", "p", "kty", "qi", "dp", "dq" } + , fs.writeFileAsync(path.join(accountDir, 'private_key.json'), JSON.stringify(pems.privateKeyJwk), 'utf8') + // regr.json: + /* + { body: { contact: [ 'mailto:coolaj86@gmail.com' ], + agreement: 'https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf', + key: { e: 'AQAB', kty: 'RSA', n: '...' } }, + uri: 'https://acme-v01.api.letsencrypt.org/acme/reg/71272', + new_authzr_uri: 'https://acme-v01.api.letsencrypt.org/acme/new-authz', + terms_of_service: 'https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf' } + */ + , fs.writeFileAsync(path.join(accountDir, 'regr.json'), JSON.stringify({ body: body }), 'utf8') + ]).then(function () { + return pems; + }); + }); + }); + }); +} + +function getAccount(accountId, args, handlers) { + var accountDir = path.join(args.accountsDir, accountId); + var files = {}; + var configs = ['meta.json', 'private_key.json', 'regr.json']; + + return PromiseA.all(configs.map(function (filename) { + var keyname = filename.slice(0, -5); + + return fs.readFileAsync(path.join(accountDir, filename), 'utf8').then(function (text) { + var data; + + try { + data = JSON.parse(text); + } catch(e) { + files[keyname] = { error: e }; + return; + } + + files[keyname] = data; + }, function (err) { + files[keyname] = { error: err }; + }); + })).then(function () { + + if (!Object.keys(files).every(function (key) { + return !files[key].error; + })) { + // TODO log renewal.conf + console.warn("Account '" + accountId + "' was currupt. No big deal (I think?). Creating a new one..."); + return createAccount(args, handlers); + } + + return leCrypto.parseAccountPrivateKeyAsync(files.private_key).then(function (keypair) { + files.accountId = accountId; // md5sum(publicKeyPem) + files.publicKeyMd5 = accountId; // md5sum(publicKeyPem) + files.publicKeyPem = keypair.publicKeyPem; // ascii PEM: ----BEGIN... + files.privateKeyPem = keypair.privateKeyPem; // ascii PEM: ----BEGIN... + files.privateKeyJson = keypair.private_key; // json { n: ..., e: ..., iq: ..., etc } + + return files; + }); + }); +} + +function getAccountByEmail(args) { + // If we read 10,000 account directories looking for + // just one email address, that could get crazy. + // We should have a folder per email and list + // each account as a file in the folder + // TODO + return PromiseA.resolve(null); +} + +function getCertificateAsync(account, args, defaults, handlers) { + var pyconf = PromiseA.promisifyAll(require('pyconf')); + + return leCrypto.generateRsaKeypairAsync(args.rsaBitLength, args.rsaExponent).then(function (domain) { + return LeCore.getCertificateAsync({ + newAuthzUrl: args._acmeUrls.newAuthz + , newCertUrl: args._acmeUrls.newCert + + , accountPrivateKeyPem: account.privateKeyPem + , domainPrivateKeyPem: domain.privateKeyPem + , domains: args.domains + + , setChallenge: function (domain, key, value, done) { + args.domains = [domain]; + args.webrootPath = args.webrootPath || defaults.webrootPath; + handlers.setChallenge(args, key, value, done); + } + , removeChallenge: function (domain, key, done) { + args.domains = [domain]; + args.webrootPath = args.webrootPath || defaults.webrootPath; + handlers.removeChallenge(args, key, done); + } + }).then(function (result) { + // TODO write pems={ca,cert,key} to disk + var liveDir = path.join(args.configDir, 'live', args.domains[0]); + var certPath = path.join(liveDir, 'cert.pem'); + var fullchainPath = path.join(liveDir, 'fullchain.pem'); + var chainPath = path.join(liveDir, 'chain.pem'); + var privkeyPath = path.join(liveDir, 'privkey.pem'); + + result.fullchain = result.cert + '\n' + result.ca; + + // TODO write to archive first, then write to live + return mkdirpAsync(liveDir).then(function () { + return PromiseA.all([ + sfs.writeFileAsync(certPath, result.cert, 'ascii') + , sfs.writeFileAsync(chainPath, result.chain, 'ascii') + , sfs.writeFileAsync(fullchainPath, result.fullchain, 'ascii') + , sfs.writeFileAsync(privkeyPath, result.key, 'ascii') + ]).then(function () { + // TODO format result licesy + //console.log(liveDir); + //console.log(result); + return { + certPath: certPath + , chainPath: chainPath + , fullchainPath: fullchainPath + , privkeyPath: privkeyPath + }; + }); + }); + }); + }); +} + +function registerWithAcme(args, defaults, handlers) { + var pyconf = PromiseA.promisifyAll(require('pyconf')); + var server = args.server || defaults.server || LeCore.stagingServerUrl; // https://acme-v01.api.letsencrypt.org/directory + var acmeHostname = require('url').parse(server).hostname; + var configDir = args.configDir || defaults.configDir || LE.configDir; + + args.server = server; + args.renewalDir = args.renewalDir || path.join(configDir, 'renewal', args.domains[0] + '.conf'); + args.accountsDir = args.accountsDir || path.join(configDir, 'accounts', acmeHostname, 'directory'); + + return pyconf.readFileAsync(args.renewalDir).then(function (renewal) { + var accountId = renewal.account; + renewal = renewal.account; + + return accountId; + }, function (err) { + if ("ENOENT" === err.code) { + return getAccountByEmail(args, handlers); + } + + return PromiseA.reject(err); + }).then(function (accountId) { + // Note: the ACME urls are always fetched fresh on purpose + return getAcmeUrls(args).then(function (urls) { + args._acmeUrls = urls; + + if (accountId) { + return getAccount(accountId, args, handlers); + } else { + return createAccount(args, handlers); + } + }); + }).then(function (account) { + /* + if (renewal.account !== account) { + // the account has become corrupt, re-register + return; + } + */ + + //console.log(account); + return fetchFromConfigLiveDir(args, defaults).then(function (certs) { + // if nothing, register and save + // if something, check date (don't register unless 30+ days) + // if good, don't bother registering + // (but if we get to the point that we're actually calling + // this function, that shouldn't be the case, right?) + //console.log(certs); + if (!certs) { + // no certs, seems like a good time to get some + return getCertificateAsync(account, args, defaults, handlers); + } + else if (certs.issuedAt > (27 * 24 * 60 * 60 * 1000)) { + // cert is at least 27 days old we can renew that + return getCertificateAsync(account, args, defaults, handlers); + } + else if (args.duplicate) { + // YOLO! I be gettin' fresh certs 'erday! Yo! + return getCertificateAsync(account, args, defaults, handlers); + } + else { + console.warn('[WARN] Ignoring renewal attempt for certificate less than 27 days old. Use args.duplicate to force.'); + // We're happy with what we have + return certs; + } + }); + + /* + cert = /home/aj/node-letsencrypt/tests/letsencrypt.config/live/lds.io/cert.pem + privkey = /home/aj/node-letsencrypt/tests/letsencrypt.config/live/lds.io/privkey.pem + chain = /home/aj/node-letsencrypt/tests/letsencrypt.config/live/lds.io/chain.pem + fullchain = /home/aj/node-letsencrypt/tests/letsencrypt.config/live/lds.io/fullchain.pem + + # Options and defaults used in the renewal process + [renewalparams] + apache_enmod = a2enmod + no_verify_ssl = False + ifaces = None + apache_dismod = a2dismod + register_unsafely_without_email = False + uir = None + installer = none + config_dir = /home/aj/node-letsencrypt/tests/letsencrypt.config + text_mode = True + func = + prepare = False + work_dir = /home/aj/node-letsencrypt/tests/letsencrypt.work + tos = True + init = False + http01_port = 80 + duplicate = False + key_path = None + nginx = False + fullchain_path = /home/aj/node-letsencrypt/chain.pem + email = coolaj86@gmail.com + csr = None + agree_dev_preview = None + redirect = None + verbose_count = -3 + config_file = None + renew_by_default = True + hsts = False + authenticator = webroot + domains = lds.io, + rsa_key_size = 2048 + checkpoints = 1 + manual_test_mode = False + apache = False + cert_path = /home/aj/node-letsencrypt/cert.pem + webroot_path = /home/aj/node-letsencrypt/examples/../tests/acme-challenge, + strict_permissions = False + apache_server_root = /etc/apache2 + account = 1c41c64dfaf10d511db8aef0cc33b27f + manual_public_ip_logging_ok = False + chain_path = /home/aj/node-letsencrypt/chain.pem + standalone = False + manual = False + server = https://acme-staging.api.letsencrypt.org/directory + standalone_supported_challenges = "http-01,tls-sni-01" + webroot = True + apache_init_script = None + user_agent = None + apache_ctl = apache2ctl + apache_le_vhost_ext = -le-ssl.conf + debug = False + tls_sni_01_port = 443 + logs_dir = /home/aj/node-letsencrypt/tests/letsencrypt.logs + configurator = None + [[webroot_map]] + lds.io = /home/aj/node-letsencrypt/examples/../tests/acme-challenge + */ + }); +/* + return fs.readdirAsync(accountsDir, function (nodes) { + return PromiseA.all(nodes.map(function (node) { + var reMd5 = /[a-f0-9]{32}/i; + if (reMd5.test(node)) { + } + })); + }); +*/ +} + +module.exports.create = function (defaults, handlers) { + defaults.server = defaults.server || LE.liveServer; + + var wrapped = { + registerAsync: function (args) { + //require('./common').registerWithAcme(args, defaults, handlers); + return registerWithAcme(args, defaults, handlers); + } + , fetchAsync: function (args) { + return fetchFromConfigLiveDir(args, defaults); + } + }; + + return wrapped; +}; diff --git a/package.json b/package.json index c1c7448..23dcce6 100644 --- a/package.json +++ b/package.json @@ -34,16 +34,17 @@ "localhost.daplie.com-certificates": "^1.1.2" }, "optionalDependencies": { - "letsencrypt-python": "^1.0.3", - "letsencrypt-forge": "file:letsencrypt-forge", - "letsencrypt-ursa": "file:letsencrypt-ursa", - "node-forge": "^0.6.38", - "letiny": "0.0.4-beta", "ursa": "^0.9.1" }, "dependencies": { "bluebird": "^3.0.6", - "safe-replace": "^1.0.0", + "homedir": "^0.6.0", + "letiny-core": "^1.0.1", + "mkdirp": "^0.5.1", + "node-forge": "^0.6.38", + "pyconf": "^1.0.0", + "request": "^2.67.0", + "safe-replace": "^1.0.2", "serve-static": "^1.10.0" } }