From 879b278d5fe0aa354b3e5579fdfb4d68c5efa036 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 8 Apr 2019 00:14:28 -0600 Subject: [PATCH] wip: bring up to date with latest v3 --- README.md | 29 ++-- index.js | 377 +++++++++++++++++++++++++++++---------------------- package.json | 4 +- 3 files changed, 235 insertions(+), 175 deletions(-) diff --git a/README.md b/README.md index c83d2d3..f1a059e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,15 @@ -# le-store-fs +# [greenlock-store-fs](https://git.coolaj86.com/coolaj86/greenlock-store-fs.js) -A greenlock keypair and certificate storage strategy with wildcard support (simpler successor to le-store-certbot). +| A [Root](https://rootprojects.org) project | + +A keypair and certificate storage strategy for Greenlock v2.7+ (and v3). +The (much simpler) successor to le-store-certbot. + +Works with all ACME (Let's Encrypt) SSL certificate sytles: +* [x] single domains +* [x] multiple domains (SANs, AltNames) +* [x] wildcards +* [x] private / localhost domains # Usage @@ -8,7 +17,7 @@ A greenlock keypair and certificate storage strategy with wildcard support (simp var greenlock = require('greenlock'); var gl = greenlock.create({ configDir: '~/.config/acme' -, store: require('le-store-fs') +, store: require('greenlock-store-fs') , approveDomains: approveDomains , ... }); @@ -42,16 +51,17 @@ acme # Wildcards & AltNames -Working with wildcards and multiple altnames requires greenlock >= v2.7. +Working with wildcards and multiple altnames requires greenlock >= v2.7 (or v3). -To do so you must set `opts.subject` and `opts.domains` within the `approvedomains()` callback. +To do so you must return `{ subject: '...', altnames: ['...', ...] }` within the `approveDomains()` callback. `subject` refers to "the subject of the ssl certificate" as opposed to `domain` which indicates "the domain servername used in the current request". For single-domain certificates they're always the same, but for multiple-domain certificates `subject` must be the name no matter what `domain` is receiving a request. `subject` is used as part of the name of the file storage path where the certificate will be saved (or retrieved). -`domains` should be the list of "altnames" on the certificate, which should include the `subject`. +`altnames` should be the list of SubjectAlternativeNames (SANs) on the certificate. +The subject and the first altname must be an exact match: `subject === altnames[0]`. ## Simple Example @@ -61,14 +71,13 @@ function approveDomains(opts) { // foo.example.com => *.example.com var wild = '*.' + opts.domain.split('.').slice(1).join('.'); + if ('example.com' !== opts.domain && '*.example.com' !== wild) { cb(new Error(opts.domain + " is not allowed")); } - opts.subject = 'example.com'; - opts.domains = [ 'example.com', '*.example.com' ]; - - return Promise.resolve(opts); + var result = { subject: 'example.com', altnames: [ 'example.com', '*.example.com' ] }; + return Promise.resolve(result); } ``` diff --git a/index.js b/index.js index cdb0fa7..aca7159 100644 --- a/index.js +++ b/index.js @@ -1,250 +1,276 @@ 'use strict'; -/*global Promise*/ -var PromiseA; -var util = require('util'); -if (!util.promisify) { - try { - PromiseA = require('bluebird'); - util.promisify = PromiseA.promisify; - } catch(e) { - console.error("Your version of node is missing Promise. Please run `npm install --save bluebird` in your project to fix"); - process.exit(10); - } -} -if ('undefined' !== typeof Promise) { PromiseA = Promise; } +var os = require("os"); var fs = require('fs'); var path = require('path'); -var readFileAsync = util.promisify(fs.readFile); -var writeFileAsync = util.promisify(fs.writeFile); var sfs = require('safe-replace'); -var mkdirpAsync = util.promisify(require('mkdirp')); -var os = require("os"); +var PromiseA = getPromise(); +var readFileAsync = PromiseA.promisify(fs.readFile); +var writeFileAsync = PromiseA.promisify(fs.writeFile); +// TODO replace with zero-depenency version +var mkdirpAsync = PromiseA.promisify(require('mkdirp')); -// create(): -// Your storage plugin may take special options, or it may not. -// If it does, document to your users that they must call create() with those options. -// If you user does not call create(), greenlock will call it for you with the options it has. -// It's kind of stupid, but it's done this way so that it can be more convenient for users to not repeat shared options -// (such as the config directory), but sometimes configs would clash. I hate having ambiguity, so I may change this in -// a future version, but it's very much an issue of "looks cleaner" vs "behaves cleaner". + +// How Storage Works in Greenlock: High-Level Call Stack +// +// nested === skipped if parent succeeds (or has cached result) +// +// tls.SNICallback() // TLS connection with SNI kicks of the request +// +// greenlock.approveDomains(opts) // Greenlokc does some housekeeping, checks for a cert in +// // an internal cash, and only asks you to approve new +// // certificate // registration if it doesn't find anything. +// // In `opts` you'll receive `domain` and a few other things. +// // You should return { subject: '...', altnames: ['...'] } +// // Anything returned by approveDomains() will be received +// // by all plugins at all stages +// +// greenlock.store.certificates.check() // Certificate checking happens after approval for several +// // reasons, including preventing duplicate registrations +// // but most importantly because you can dynamically swap the +// // storage plugin right from approveDomains(). +// greenlock.store.certificates.checkKeypair() // Check for a keypair associated with the domain +// +// greenlock.store.accounts.check() // Optional. If you need it, look at other Greenlock docs +// +// greenlock.store.accounts.checkKeypair() // Check storage for registered account key +// (opts.generateKeypair||RSA.generateKeypair)() // Generates a new keypair +// greenlock.core.accounts.register() // Registers the keypair as an ACME account +// greenlock.store.accounts.setKeypair() // Saves the keypair of the registered account +// greenlock.store.accounts.set() // Optional. Saves superfluous ACME account metadata +// +// greenlock.core.certificates.register() // Begin certificate registration process & housekeeping +// (opts.generateKeypair||RSA.generateKeypair)() // Generates a new certificate keypair +// greenlock.acme.certificates.register() // Performs the ACME challenge processes +// greenlock.store.certificates.setKeypair() // Saves the keypair for the valid certificate +// greenlock.store.certificates.set() // Saves the valid certificate + + +//////////////////////////////////////////// +// Recap of the high-level overview above // +//////////////////////////////////////////// +// +// None of this ever gets called except if there's not a cert already cached. +// That only happens on service boot, and about every 75 days for each cert's renewal. +// +// Therefore, none of this needs to be fast, fancy, or clever +// +// For any type of customization, whatever is set in `approveDomains()` is available everywhere else. + + + +// Either your user calls create with specific options, or greenlock calls it for you with a big options blob module.exports.create = function (config) { - - // This file has been laid out in the order that options are used and calls are made - // SNICallback() // le-sni-auto has a cache - // greenlock.approveDomains() - // // you get opts.domain passed to you from SNI - // // you should set opts.subject as the cert "id" domain - // // you should set opts.domains as all domains on the cert - // // you should set opts.account.id, otherwise opts.email will be used - // greenlock.store.certificates.checkAsync() // on success -> SNI cache, on fail -> checkAccount - // greenlock.store.accounts.checkAsync() // optional (you can always return null) - // greenlock.store.accounts.checkKeypairAsync() - // greenlock.core.RSA.generateKeypair() // TODO double check name - // greenlock.core.accounts.register() // TODO double check name - // greenlock.store.accounts.setKeypairAsync() // TODO make sure this only happens on generate - // greenlock.store.accounts.setAsync() // optional - // greenlock.store.certificates.checkKeypairAsync() - // greenlock.core.RSA.generateKeypair() // TODO double check name - // greenlock.core.certificates.register() // TODO double check name - // greenlock.store.certificates.setKeypairAsync() - // greenlock.store.certificates.setAsync() - - // store // Bear in mind that the only time any of this gets called is on first access after startup, new registration, and // renewal - so none of this needs to be particularly fast. It may need to be memory efficient, however - if you have // more than 10,000 domains, for example. - var store = {}; - // options: - // - // If your module requires options (i.e. file paths or database urls) you should check what you get from create() - // and copy over the things you'll use into this options object. You should also merge in any defaults for options - // that have not been set. This object should not be circular, should not be changed after it is set, and should - // contain every property that you can use, using falsey JSON-able values like 0, null, false, or '' for "unset" - // values. - // See the note on create() above. + // basic setup + var store = { accounts: {}, certificates: {} }; + + // For you store.options should probably start empty and get a minimal set of options copied from `config` above. + // Example: + //store.options = {}; + //store.options.databaseUrl = config.databaseUrl; + + // In the case of greenlock-store-fs there's a bunch of legacy stuff that goes on, so we just clobber it all on. + // Don't be like greenlock-store-fs (see note above). store.options = mergeOptions(config); - // set and check account keypairs and account data - store.accounts = {}; - // set and check domain keypairs and domain certificates - store.certificates = {}; - // certificates.checkAsync({ subject, ... }): + + + + + // Certificates.check // - // The first check is that a certificate looked for by its subject (primary domain name). - // If that lookup succeeds, then nothing else needs to happen. Otherwise accounts.checkAsync will happen next. - // The only input you need to be concerned with is opts.subject (which falls back to opts.domains[0] if not set). - // And since this is called after `approveDomains()`, any options that you set there will be available here too. - store.certificates.checkAsync = function (opts) { - // { certificate.id, subject, domains, ... } + // Use certificate.id, or subject, if id hasn't been set, to find a certificate. + // Return an object with string PEMs for cert and chain (or null, not undefined) + store.certificates.check = function (opts) { + // { certificate.id, subject, ... } var id = opts.certificate && opts.certificate.id || opts.subject; - //console.log('certificates.checkAsync for', opts.domain, opts.subject, opts.domains); + //console.log('certificates.check for', opts.certificate, opts.subject); //console.log(opts); - // Just to show that any options set in approveDomains() will be available here + // For advanced use cases: + // This just goes to show that any options set in approveDomains() will be available here // (the same is true for all of the hooks in this file) if (opts.exampleThrowError) { return PromiseA.reject(new Error("You want an error? You got it!")); } if (opts.exampleReturnNull) { return PromiseA.resolve(null); } if (opts.exampleReturnCerts) { return PromiseA.resolve(opts.exampleReturnCerts); } + + // Ignore this first bit, it's just file system template / compatibility stuff var liveDir = opts.liveDir || path.join(opts.configDir, 'live', opts.subject); - // TODO this shouldn't be necessary here (we should get it from checkKeypairAsync) var privkeyPath = opts.privkeyPath || opts.domainKeyPath || path.join(liveDir, 'privkey.pem'); var certPath = opts.certPath || path.join(liveDir, 'cert.pem'); var chainPath = opts.chainPath || path.join(liveDir, 'chain.pem'); - return PromiseA.all([ - // all other PEM files are arrangements of these three - readFileAsync(tameWild(privkeyPath, id), 'ascii') // 0 - , readFileAsync(tameWild(certPath, id), 'ascii') // 1 - , readFileAsync(tameWild(chainPath, id), 'ascii') // 2 + readFileAsync(tameWild(privkeyPath, id), 'ascii') // 0 // all other PEM types are just + , readFileAsync(tameWild(certPath, id), 'ascii') // 1 // some arrangement of these 3 + , readFileAsync(tameWild(chainPath, id), 'ascii') // 2 // (bundle, combined, fullchain, etc) ]).then(function (all) { - // Success + + //////////////////////// + // PAY ATTENTION HERE // + //////////////////////// + // This is all you have to return: cert, chain return { - privkey: all[0] - , cert: all[1] - , chain: all[2] - // When using a database, these should be retrieved too + cert: all[1] // string PEM. the bare cert, half of the concatonated fullchain.pem you need + , chain: all[2] // string PEM. the bare chain, the second half of the fullchain.pem + , privkey: all[0] // string PEM. optional, allows checkKeypair to be skipped + + // These can be useful to store in your database, + // but otherwise they're easy to derive from the cert. // (when not available they'll be generated from cert-info) - //, subject: certinfo.subject - //, altnames: certinfo.altnames - //, issuedAt: certinfo.issuedAt // a.k.a. NotBefore - //, expiresAt: certinfo.expiresAt // a.k.a. NotAfter + //, subject: certinfo.subject // string domain name + //, altnames: certinfo.altnames // array of domain name strings + //, issuedAt: certinfo.issuedAt // number in ms (a.k.a. NotBefore) + //, expiresAt: certinfo.expiresAt // number in ms (a.k.a. NotAfter) }; }).catch(function (err) { - // Non-success + // Treat non-exceptional failures as null returns (not undefined) if ('ENOENT' === err.code) { return null; } - // Failure - throw err; + throw err; // True exceptions should be thrown }); }; - // accounts.checkAsync({ accountId, email, [...] }): // Optional - // - // This is where you promise an account corresponding to the given the email and ID. All options set in - // approveDomains() are also available. You can ignore them unless your implementation is using them in some way. - // - // Since accounts are based on public key, the act of creating a new account or returning an existing account - // are the same in regards to the API and so we don't really need to store the account id or retrieve it. - // This method only needs to be implemented if you need it for your own purposes - // - // On Success: Promise.resolve({ id, keypair, ... }) - an id and, for backwards compatibility, the abstract keypair - // On Failure: Promise.resolve(null) - do not return undefined, do not throw, do not reject - // On Error: Promise.reject(new Error("something descriptive for the user")) - store.accounts.checkAsync = function (/*opts*/) { - //var id = opts.account.id || 'single-user'; - //console.log('accounts.checkAsync for', id); - return PromiseA.resolve(null); - }; - // accounts.checkKeypairAsync({ email, ... }): + + // Implement if you need the ACME account metadata elsewhere in the chain of events + //store.accounts.check = function (opts) { + // console.log('accounts.check for', opts.account, opts.email); + // return PromiseA.resolve(null); + //}; + + + + // Accounts.checkKeypair // - // Same rules as above apply, except for the private key of the account, not the account object itself. - // - // On Success: Promise.resolve({ ... }) - the abstract object representing the keypair - // On Failure: Promise.resolve(null) - do not return undefined, do not throw, do not reject - // On Error: Promise.reject(new Error("something descriptive for the user")) - store.accounts.checkKeypairAsync = function (opts) { - var id = opts.account.id || 'single-user'; - //console.log('accounts.checkKeypairAsync for', id); - if (!opts.account.id) { return PromiseA.reject(new Error("'account.id' should have been set in approveDomains()")); } + // Use account.id, or email, if id hasn't been set, to find an account keypair. + // Return an object with string privateKeyPem and/or object privateKeyJwk (or null, not undefined) + store.accounts.checkKeypair = function (opts) { + var id = opts.account.id || opts.email || 'single-user'; + //console.log('accounts.checkKeypair for', id); var pathname = path.join(tameWild(opts.accountsDir, opts.subject), sanitizeFilename(id) + '.json'); return readFileAsync(tameWild(pathname, opts.subject), 'utf8').then(function (blob) { - // keypair is an opaque object that should be treated as blob - return JSON.parse(blob); + // keypair can treated as an opaque object and just passed along, + // but just to show you what it is... + var keypair = JSON.parse(blob); + return { + privateKeyPem: keypair.privateKeyPem // string PEM private key + , privateKeyJwk: keypair.privateKeyJwk // object JWK private key + }; }).catch(function (err) { if ('ENOENT' === err.code) { return null; } throw err; }); }; - // accounts.setKeypairAsync({ keypair, email, ... }): + + + // Accounts.setKeypair({ account, email, keypair, ... }): // - // The keypair details (RSA, ECDSA, etc) are chosen either by the greenlock defaults, global user defaults, - // or whatever you set in approveDomains(). This is called *after* the account is successfully created. - // - // On Success: Promise.resolve(null) - just knowing the operation is successful will do - // On Error: Promise.reject(new Error("something descriptive for the user")) - store.accounts.setKeypairAsync = function (opts, keypair) { + // Use account.id (or email if no id is present) to save an account keypair + // Return null (not undefined) on success, or throw on error + store.accounts.setKeypair = function (opts) { + //console.log('accounts.setKeypair for', opts.account, opts.email, opts.keypair); var id = opts.account.id || 'single-user'; - //console.log('accounts.setKeypairAsync for', id); - keypair = opts.keypair || keypair; - if (!opts.account.id) { return PromiseA.reject(new Error("'account.id' should have been set in approveDomains()")); } + + // you can just treat the keypair as opaque and save and retrieve it as JSON + var keyblob = JSON.stringify({ + privateKeyPem: opts.keypair.privateKeyPem // string PEM + , privateKeyJwk: opts.keypair.privateKeyJwk // object JWK + }); + + // Ignore. + // Just implementation specific details here. return mkdirpAsync(tameWild(opts.accountsDir, opts.subject)).then(function () { - // keypair is an opaque object that should be treated as blob var pathname = tameWild(path.join(opts.accountsDir, sanitizeFilename(id) + '.json'), opts.subject); - return writeFileAsync(tameWild(pathname, opts.subject), JSON.stringify(keypair), 'utf8'); + return writeFileAsync(tameWild(pathname, opts.subject), keyblob, 'utf8'); + }).then(function () { + // This is your job: return null, not undefined + return null; }); }; - // accounts.setAsync({ account, keypair, email, ... }): - // - // The account details, from ACME, if everything is successful. Unless you need to do something with those account - // details, this implementation can remain empty. - // - // On Success: Promise.resolve(null||{ id }) - do not return undefined, do not throw, do not reject - // On Error: Promise.reject(new Error("something descriptive for the user")) - store.accounts.setAsync = function (/*opts*/) { - //receipt = opts.receipt || receipt; - //console.log('account.setAsync:', receipt); - return PromiseA.resolve(null); - }; - // certificates.checkKeypairAsync({ subject, ... }): + + // Implement if you need the ACME account metadata elsewhere in the chain of events + //store.accounts.set = function (opts) { + // console.log('account.set:', opts.account, opts.email, opts.receipt); + // return PromiseA.resolve(null); + //}; + + + + // Certificates.checkKeypair // - // Same rules as certificates.checkAsync apply, except for the private key of the certificate, not the public - // certificate itself (similar to accounts.checkKeyPairAsync, but for certs). - store.certificates.checkKeypairAsync = function (opts) { - //console.log('certificates.checkKeypairAsync:'); + // Use certificate.kid, certificate.id, or subject to find a certificate keypair + // Return an object with string privateKeyPem and/or object privateKeyJwk (or null, not undefined) + store.certificates.checkKeypair = function (opts) { + //console.log('certificates.checkKeypair:', opts.certificate, opts.subject); + + // Ignore this. It's just special stuff for file system compat with the old le-store-certbot var liveDir = opts.liveDir || path.join(opts.configDir, 'live', opts.subject); var privkeyPath = opts.privkeyPath || opts.domainKeyPath || path.join(liveDir, 'privkey.pem'); - return readFileAsync(tameWild(privkeyPath, opts.subject), 'ascii').then(function (key) { - // keypair is normally an opaque object, but here it's a pem for the filesystem - return { privateKeyPem: key }; + //////////////////////// + // PAY ATTENTION HERE // + //////////////////////// + return { + privateKeyPem: key // In this case we only saved privateKeyPem, so we only return it + //privateKeyJwk: null // (but it's fine, just different encodings of the same thing) + }; }).catch(function (err) { if ('ENOENT' === err.code) { return null; } throw err; }); }; - // certificates.setKeypairAsync({ domain, keypair, ... }): + + + // Certificates.setKeypair({ certificate, subject, keypair, ... }): // - // Same as accounts.setKeypairAsync, but by domains rather than email / accountId - store.certificates.setKeypairAsync = function (opts, keypair) { - keypair = opts.keypair || keypair; + // Use certificate.kid (or certificate.id or subject if no kid is present) to find a certificate keypair + // Return null (not undefined) on success, or throw on error + store.certificates.setKeypair = function (opts) { + var keypair = opts.keypair || keypair; + + // Ignore. + // Just specific implementation details. var liveDir = opts.liveDir || path.join(opts.configDir, 'live', opts.subject); var privkeyPath = opts.privkeyPath || opts.domainKeyPath || path.join(liveDir, 'privkey.pem'); - - // keypair is normally an opaque object, but here it's a PEM for the FS return mkdirpAsync(tameWild(path.dirname(privkeyPath), opts.subject)).then(function () { + // keypair is normally an opaque object, but here it's a PEM for the FS (for things like Apache and Nginx) return writeFileAsync(tameWild(privkeyPath, opts.subject), keypair.privateKeyPem, 'ascii').then(function () { return null; }); }); }; - // certificates.setAsync({ domain, certs, ... }): + + + // Certificates.set({ subject, pems, ... }): // - // This is where certificates are set, as well as certinfo - store.certificates.setAsync = function (opts) { - //console.log('certificates.setAsync:'); - //console.log(opts.domain, '<=', opts.subject); + // Use certificate.id (or subject if no ki is present) to save a certificate + // Return null (not undefined) on success, or throw on error + store.certificates.set = function (opts) { + //console.log('certificates.set:', opts.subject, opts.pems); var pems = { - privkey: opts.pems.privkey - , cert: opts.pems.cert - , chain: opts.pems.chain + cert: opts.pems.cert // string PEM the first half of the concatonated fullchain.pem cert + , chain: opts.pems.chain // string PEM the second half (yes, you need this too) + , privkey: opts.pems.privkey // Ignore. string PEM, useful if you have to create bundle.pem }; + + // Ignore + // Just implementation specific details (writing lots of combinatons of files) var liveDir = opts.liveDir || path.join(opts.configDir, 'live', opts.subject); var certPath = opts.certPath || path.join(liveDir, 'cert.pem'); var fullchainPath = opts.fullchainPath || path.join(liveDir, 'fullchain.pem'); var chainPath = opts.chainPath || path.join(liveDir, 'chain.pem'); - //var privkeyPath = opts.privkeyPath || opts.domainKeyPath || path.join(liveDir, 'privkey.pem'); var bundlePath = opts.bundlePath || path.join(liveDir, 'bundle.pem'); - return mkdirpAsync(path.dirname(tameWild(certPath, opts.subject))).then(function () { return mkdirpAsync(path.dirname(tameWild(chainPath, opts.subject))).then(function () { return mkdirpAsync(path.dirname(tameWild(fullchainPath, opts.subject))).then(function () { @@ -263,13 +289,21 @@ module.exports.create = function (config) { }); }); }).then(function () { + // That's your job: return null return null; }); }; + + return store; }; +/////////////////////////////////////////////////////////////////////////////// +// Ignore // +/////////////////////////////////////////////////////////////////////////////// +// +// Everything below this line is just implementation specific var defaults = { configDir: path.join(os.homedir(), 'acme', 'etc') @@ -307,3 +341,20 @@ function tameWild(path, wild) { var tame = wild.replace(/\*/g, '_'); return path.replace(wild, tame); } + +function getPromise() { + var util = require('util'); + var PromiseA; + if (util.promisify && global.Promise) { + PromiseA = global.Promise; + PromiseA.promisify = util.promisify; + } else { + try { + PromiseA = require('bluebird'); + } catch(e) { + console.error("Your version of node is missing Promise. Please run `npm install --save bluebird` in your project to fix"); + process.exit(10); + } + } + return PromiseA; +} diff --git a/package.json b/package.json index 9c66b94..6fb7747 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "le-store-fs", - "version": "1.0.3", + "name": "greenlock-store-fs", + "version": "3.0.0", "description": "A file-based certificate store for greenlock that supports wildcards.", "homepage": "https://git.coolaj86.com/coolaj86/le-store-fs.js", "main": "index.js",