merge ursa/forge branch (no python)

This commit is contained in:
AJ ONeal 2015-12-16 04:55:35 -08:00
commit fdfd7cb6c2
14 changed files with 699 additions and 253 deletions

143
README.md
View File

@ -24,40 +24,23 @@ Install
npm install --save letsencrypt 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 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 ```javascript
var config = require('./examples/config-minimal'); var config = require('./examples/config-minimal');
config.le.webrootPath = __dirname + '/tests/acme-challenge'; 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({ le.register({
agreeTos: true agreeTos: true
, domains: ['example.com'] // CHANGE TO YOUR DOMAIN , domains: ['example.com'] // CHANGE TO YOUR DOMAIN
, email: 'user@email.com' // CHANGE TO YOUR EMAIL , email: 'user@email.com' // CHANGE TO YOUR EMAIL
, standalone: true
}, function (err) { }, function (err) {
if (err) { if (err) {
console.error('[Error]: node-letsencrypt/examples/standalone'); 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` * `webrootPath`
* `configDir` * `configDir`
* `workDir` (python backend only)
* `logsDir` (python backend only)
API API
=== ===
```javascript ```javascript
LetsEncrypt.create(backend, bkDefaults, handlers) // wraps a given "backend" (the python client) LetsEncrypt.init(leConfig, handlers) // wraps a given
LetsEncrypt.stagingServer // string of staging server for testing 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.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.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.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.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.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.registrationFailureCallback(err, args, certInfo, cb) // called when registration fails (not implemented yet)
``` ```
### `LetsEncrypt.create(backend, bkDefaults, handlers)` ### `LetsEncrypt.create(backend, leConfig, handlers)`
#### backend #### leConfig
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: '<fullchain.pem>', key: '<privkey.pem>', 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
The arguments passed here (typically `webpathRoot`, `configDir`, etc) will be merged with 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 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 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* #### handlers *optional*
@ -434,20 +392,6 @@ Checks in-memory cache of certificates for `args.domains` and calls then calls `
Not yet implemented 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: 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 (it's expected that the client will follow the same conventions as
the python client, but it's not necessary) 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 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 LICENSE
======= =======

View File

@ -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;
};

View File

@ -11,11 +11,11 @@ config.le.server = LE.stagingServer;
// //
// Manual Registration // Manual Registration
// //
var le = LE.create(config.backend, config.le); var le = LE.create(config.le);
le.register({ le.register({
agreeTos: true agreeTos: true
, domains: ['example.com'] // CHANGE TO YOUR DOMAIN , domains: [process.argv[3] || 'example.com'] // CHANGE TO YOUR DOMAIN
, email: 'user@example.com' // CHANGE TO YOUR EMAIL , email: process.argv[2] || 'user@example.com' // CHANGE TO YOUR EMAIL
}, function (err) { }, function (err) {
if (err) { if (err) {
console.error('[Error]: node-letsencrypt/examples/standalone'); console.error('[Error]: node-letsencrypt/examples/standalone');

View File

@ -28,13 +28,9 @@ var bkDefaults = {
// backend-specific // backend-specific
, logsDir: path.join(__dirname, '..', 'tests', 'letsencrypt.logs') , logsDir: path.join(__dirname, '..', 'tests', 'letsencrypt.logs')
, workDir: path.join(__dirname, '..', 'tests', 'letsencrypt.work') , 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(bkDefaults, {
var le = LE.create(LEP, bkDefaults, {
/* /*
setChallenge: function (hostnames, key, value, cb) { setChallenge: function (hostnames, key, value, cb) {
// the python backend needs fs.watch implemented // the python backend needs fs.watch implemented

View File

@ -2,8 +2,6 @@
var path = require('path'); var path = require('path');
var binpath = require('os').homedir() + '/.local/share/letsencrypt/bin/letsencrypt';
var config = { var config = {
plainPort: 80 plainPort: 80
@ -21,12 +19,8 @@ var config = {
// these are specific to the python client and won't be needed with the purejs library // these are specific to the python client and won't be needed with the purejs library
, logsDir: path.join(__dirname, '..', 'tests', 'letsencrypt.logs') , logsDir: path.join(__dirname, '..', 'tests', 'letsencrypt.logs')
, workDir: path.join(__dirname, '..', 'tests', 'letsencrypt.work') , 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; module.exports = config;

View File

@ -7,7 +7,7 @@ var config = require('./config-minimal');
config.le.webrootPath = __dirname + '/../tests/acme-challenge'; config.le.webrootPath = __dirname + '/../tests/acme-challenge';
config.le.server = LE.stagingServer; config.le.server = LE.stagingServer;
var le = LE.create(config.backend, config.le, { var le = LE.create(config.le, {
sniRegisterCallback: function (args, expiredCert, cb) { sniRegisterCallback: function (args, expiredCert, cb) {
// In theory you should never get an expired certificate because // In theory you should never get an expired certificate because
// the certificates automatically renew in the background starting // the certificates automatically renew in the background starting

View File

@ -24,15 +24,8 @@ var bkDefaults = {
, privkeyTpl: '/live/:hostname/privkey.pem' , privkeyTpl: '/live/:hostname/privkey.pem'
, configDir: path.join(__dirname, '..', 'tests', 'letsencrypt.config') , configDir: path.join(__dirname, '..', 'tests', 'letsencrypt.config')
, server: LE.stagingServer , 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, { var le = LE.create(LEP, bkDefaults, {
sniRegisterCallback: function (args, certInfo, cb) { sniRegisterCallback: function (args, certInfo, cb) {
var allowedDomains = conf.domains; // require('../tests/config').allowedDomains; var allowedDomains = conf.domains; // require('../tests/config').allowedDomains;

54
examples/ursa.js Normal file
View File

@ -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());
});

175
index.js
View File

@ -5,15 +5,20 @@
var PromiseA = require('bluebird'); var PromiseA = require('bluebird');
var crypto = require('crypto'); var crypto = require('crypto');
var tls = require('tls'); var tls = require('tls');
var path = require('path'); var leCore = require('./lib/letiny-core');
var LE = module.exports; 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"; // backwards compat
LE.stagingServer = "https://acme-staging.api.letsencrypt.org/directory"; LE.liveServer = leCore.productionServerUrl;
LE.configDir = "/etc/letsencrypt/"; LE.knownUrls = leCore.knownEndpoints;
LE.logsDir = "/var/log/letsencrypt/";
LE.workDir = "/var/lib/letsencrypt/";
LE.merge = function merge(defaults, args) { LE.merge = function merge(defaults, args) {
var copy = {}; var copy = {};
@ -28,19 +33,48 @@ LE.merge = function merge(defaults, args) {
return copy; return copy;
}; };
LE.create = function (backend, defaults, handlers) { LE.cacheCertInfo = function (args, certInfo, ipc, handlers) {
if ('function' === typeof backend.create) { // TODO IPC via process and worker to guarantee no races
backend.create(defaults, handlers); // rather than just "really good odds"
}
else if ('string' === typeof backend) { var hostname = args.domains[0];
// TODO I'll probably regret this var now = Date.now();
// I don't like dynamic requires because they cause build / minification issues.
backend = require(path.join('backends', backend)).create(defaults, handlers); // Stagger randomly by plus 0% to 25% to prevent all caches expiring at once
} var rnd1 = (crypto.randomBytes(1)[0] / 255);
else { var memorizeFor = Math.floor(handlers.memorizeFor + ((handlers.memorizeFor / 4) * rnd1));
// ignore // Stagger randomly to renew between n and 2n days before renewal is due
// this backend was created the v1.0.0 way // 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) { handlers = {}; }
if (!handlers.lifetime) { handlers.lifetime = 90 * 24 * 60 * 60 * 1000; } if (!handlers.lifetime) { handlers.lifetime = 90 * 24 * 60 * 60 * 1000; }
if (!handlers.renewWithin) { handlers.renewWithin = 3 * 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); 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); backend = PromiseA.promisifyAll(backend);
var utils = require('./utils');
var utils = require('./utils');
//var attempts = {}; // should exist in master process only //var attempts = {}; // should exist in master process only
var ipc = {}; // in-process cache var ipc = {}; // in-process cache
var le; var le;
@ -104,7 +181,8 @@ LE.create = function (backend, defaults, handlers) {
} }
le = { le = {
validate: function (hostnames, cb) { backend: backend
, validate: function (hostnames, cb) {
// TODO check dns, etc // TODO check dns, etc
if ((!hostnames.length && hostnames.every(le.isValidDomain))) { if ((!hostnames.length && hostnames.every(le.isValidDomain))) {
cb(new Error("node-letsencrypt: invalid hostnames: " + hostnames.join(','))); cb(new Error("node-letsencrypt: invalid hostnames: " + hostnames.join(',')));
@ -127,17 +205,25 @@ LE.create = function (backend, defaults, handlers) {
cb(null, true); cb(null, true);
} }
, middleware: function () { , middleware: function () {
//console.log('[DEBUG] webrootPath', defaults.webrootPath); var prefix = leCore.acmeChallengePrefix;
var serveStatic = require('serve-static')(defaults.webrootPath, { dotfiles: 'allow' });
var prefix = '/.well-known/acme-challenge/';
return function (req, res, next) { return function (req, res, next) {
if (0 !== req.url.indexOf(prefix)) { if (0 !== req.url.indexOf(prefix)) {
//console.log('[LE middleware]: pass');
next(); next();
return; 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 , SNICallback: sniCallback
@ -159,9 +245,9 @@ LE.create = function (backend, defaults, handlers) {
return; return;
} }
console.log("[NLE]: begin registration"); //console.log("[NLE]: begin registration");
return backend.registerAsync(copy).then(function () { return backend.registerAsync(copy).then(function () {
console.log("[NLE]: end registration"); //console.log("[NLE]: end registration");
// calls fetch because fetch calls cacheCertInfo // calls fetch because fetch calls cacheCertInfo
return le.fetch(args, cb); return le.fetch(args, cb);
}, cb); }, cb);
@ -231,6 +317,10 @@ LE.create = function (backend, defaults, handlers) {
le._fetchHelper(args, cb); le._fetchHelper(args, cb);
} }
, register: function (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 // this may be run in a cluster environment
// in that case it should NOT check the cache // in that case it should NOT check the cache
// but ensure that it has the most fresh copy // but ensure that it has the most fresh copy
@ -284,36 +374,3 @@ LE.create = function (backend, defaults, handlers) {
return le; 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];
};

30
lib/common.js Normal file
View File

@ -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;
});
};

76
lib/crypto-utils-ursa.js Normal file
View File

@ -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;

40
lib/default-handlers.js Normal file
View File

@ -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);
};

352
lib/letiny-core.js Normal file
View File

@ -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 = <function obtain_cert at 0x7f46af0f02a8>
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;
};

View File

@ -34,16 +34,17 @@
"localhost.daplie.com-certificates": "^1.1.2" "localhost.daplie.com-certificates": "^1.1.2"
}, },
"optionalDependencies": { "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" "ursa": "^0.9.1"
}, },
"dependencies": { "dependencies": {
"bluebird": "^3.0.6", "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" "serve-static": "^1.10.0"
} }
} }