From 2b4b714126fe4d7f7ca6095952898a54cc3d6ae8 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 1 Apr 2019 01:56:41 -0600 Subject: [PATCH] initial commit --- .gitignore | 61 ++++++++++ LICENSE | 41 +++++++ README.md | 107 +++++++++++++++++ index.js | 299 ++++++++++++++++++++++++++++++++++++++++++++++ package-lock.json | 26 ++++ package.json | 31 +++++ 6 files changed, 565 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 index.js create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b730098 --- /dev/null +++ b/.gitignore @@ -0,0 +1,61 @@ +# ---> Node +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Typescript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..91aab7d --- /dev/null +++ b/LICENSE @@ -0,0 +1,41 @@ +Copyright 2019 AJ ONeal + +This is open source software; you can redistribute it and/or modify it under the +terms of either: + + a) the "MIT License" + b) the "Apache-2.0 License" + +MIT License + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + +Apache-2.0 License Summary + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c042b64 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# le-store-fs + +A greenlock keypair and certificate storage strategy with wildcard support (simpler successor to le-store-certbot). + +# Usage + +```js +var greenlock = require('greenlock'); +var gl = greenlock.create({ + configDir: '~/.config/acme' +, store: require('le-store-fs') +, approveDomains: approveDomains +, ... +}); +``` + +# File System + +The default file system layout mirrors that of le-store-certbot in order to make transitioning effortless, +in most situations: + +``` +acme +├── accounts +│   └── acme-staging-v02.api.letsencrypt.org +│   └── directory +│   └── sites@example.com.json +└── live + ├── example.com + │   ├── bundle.pem + │   ├── cert.pem + │   ├── chain.pem + │   ├── fullchain.pem + │   └── privkey.pem + └── www.example.com + ├── bundle.pem + ├── cert.pem + ├── chain.pem + ├── fullchain.pem + └── privkey.pem +``` + +# Wildcards & AltNames + +Working with wildcards and multiple altnames requires greenlock >= v2.7. + +To do so you must set `opts.subject` and `opts.domains` 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`. + +## Simple Example + +```js +function approveDomains(opts, certs, cb) { + // foo.example.com => *.example.com + var wild = '*.' + opts.domain.split('.').slice(1).join('.'); + if ('*.example.com' !== wild) { cb(new Error(opts.domain + " is not allowed")); } + + opts.subject = '*.example.com'; + opts.domains = ['*.example.com']; + + cb({ options: opts, certs: certs }); +} +``` + +## Realistic Example + +```js +function approveDomains(opts, certs, cb) { + var related = getRelated(opts.domain); + if (!related) { cb(new Error(opts.domain + " is not allowed")); }; + + opts.subject = related.subject; + opts.domains = related.domains; + + cb({ options: opts, certs: certs }); +} +``` + +```js +function getRelated(domain) { + var related; + var wild = '*.' + domain.split('.').slice(1).join('.'); + if (Object.keys(allAllowedDomains).some(function (k) { + return allAllowedDomains[k].some(function (name) { + if (domain === name || wild === name) { + related = { subject: k, altnames: allAllowedDomains[k] }; + return true; + } + }); + })) { + return related; + } +} +``` + +```js +var allAllowedDomains = { + 'example.com': ['example.com', '*.example.com'] +, 'example.net': ['example.net', '*.example.net'] +} +``` diff --git a/index.js b/index.js new file mode 100644 index 0000000..6c04747 --- /dev/null +++ b/index.js @@ -0,0 +1,299 @@ +'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 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"); + +// 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". +module.exports.create = function (config) { + + // This file has been laid out in the order that options are used and calls are made + // greenlock.approveDomains) + // greenlock.store.certificates.checkAsync() + // greenlock.store.accounts.checkAsync() + // greenlock.store.accounts.setKeypairAsync() + // greenlock.store.accounts.setAsync() + // greenlock.store.certificates.checkKeypairAsync() + // 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. + store.options = mergeOptions(config); + + // getOptions(): + // This must be implemented for backwards compatibility. That is all. + store.getOptions = function () { return store.options; }; + + // set and check account keypairs and account data + store.accounts = {}; + // set and check domain keypairs and domain certificates + store.certificates = {}; + + // certificates.checkAsync({ subject, ... }): + // + // The first check is that a certificate looked for by domain name. + // If that lookup succeeds, then nothing else needs to happen. Otherwise accounts.checkAsync will happen next. + // What should happen here is a lookup in a database (or filesystem). Generally the pattern will be to see if the + // domain is an exact match for a single-subject (single domain) or multi-subject (many domains via SANS/AltName) + // and then stripping the first part of the domain to see if there's a wildcard match. If you're clever you could + // also do these checks in parallel, but this only happens at startup and before renewal, so you don't have to get + // unless you want to for fun. + // The only input you need to be concerned with is opts.subject (which falls back to opts.domains[0] if not set). + // However, this is called after `approveDomains)`, so any options that you set there will be available here too, + // as well as any other config you might need to access from other modules, if you're doing something special. + // + // On Success: Promise.resolve({ ... }) - the pem or jwk for the certificate + // 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.certificates.checkAsync = function (opts) { + // { domain, ... } + console.log('certificates.checkAsync for', opts.domain, opts.subject, opts.domains); + console.log(opts); + console.log(new Error("just for the stack trace:").stack); + + // Just 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 Promise.reject(new Error("You want an error? You got it!")); } + if (opts.exampleReturnNull) { return Promise.resolve(null); } + if (opts.exampleReturnCerts) { return Promise.resolve(opts.exampleReturnCerts); } + + 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([ + readFileAsync(privkeyPath, 'ascii') // 0 + , readFileAsync(certPath, 'ascii') // 1 + , readFileAsync(chainPath, 'ascii') // 2 + ]).then(function (all) { + return { + privkey: all[0] + , cert: all[1] + , chain: all[2] + // When using a database, these should be retrieved + // (as is they'll be read via cert-info) + //, subject: certinfo.subject + //, altnames: certinfo.altnames + //, issuedAt: certinfo.issuedAt // a.k.a. NotBefore + //, expiresAt: certinfo.expiresAt // a.k.a. NotAfter + }; + }).catch(function (err) { + if ('ENOENT' === err.code) { return null; } + throw err; + }); + }; + + // accounts.checkAsync({ accountId, email, [...] }): // Optional + // + // This is where you promise an account corresponding to the given the email and ID. All instance options + // (i.e. 'options' above, merged with other "override" or per-use options, such as from 'approveDomains)') + // are also available. You can ignore them unless your implementation is using them in some way. + // You should error if the account cannot be found (otherwise an unexpected error will be thrown) + // Although you can supply a 'check' thunk (node-style callback) here, it's going to be converted to a proper + // promise, so just go ahead and use that from the get-go. + // + // 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); + // 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 + return Promise.resolve(null); + }; + + // accounts.checkKeypairAsync({ email, ... }): + // + // 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 Promise.reject(new Error("'account.id' should have been set in approveDomains()")); } + + return readFileAsync(path.join(opts.accountsDir, sanitizeFilename(id) + '.json'), 'utf8').then(function (blob) { + // keypair is an opaque object that should be treated as blob + return JSON.parse(blob); + }).catch(function (err) { + if ('ENOENT' === err.code) { return null; } + throw err; + }); + }; + + // accounts.setKeypairAsync({ keypair, email, ... }): + // + // The keypair details (RSA, ECDSA, etc) are chosen either by the greenlock defaults, global user defaults, + // or whatever you set in approveDomains) + // + // 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) { + var id = opts.account.id || 'single-user'; + console.log('accounts.setKeypairAsync for', id); + keypair = opts.keypair || keypair; + if (!opts.account.id) { return Promise.reject(new Error("'account.id' should have been set in approveDomains()")); } + return mkdirpAsync(opts.accountsDir).then(function () { + // keypair is an opaque object that should be treated as blob + return writeFileAsync(path.join(opts.accountsDir, sanitizeFilename(id) + '.json'), JSON.stringify(keypair), 'utf8'); + }); + }; + + // accounts.setAsync({ account, keypair, email, ... }): + // + // The account details, from ACME, if everything is successful. + // + // 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) { + receipt = opts.receipt || receipt; + console.log('account.setAsync:', receipt); + return Promise.resolve(null); + }; + + // certificates.checkKeypairAsync({ subject, ... }): + // + // Same rules as above apply, except for the private key of the certificate, not the public certificate itself. + store.certificates.checkKeypairAsync = function (opts) { + console.log('certificates.checkKeypairAsync:'); + var liveDir = opts.liveDir || path.join(opts.configDir, 'live', opts.subject); + var privkeyPath = opts.privkeyPath || opts.domainKeyPath || path.join(liveDir, 'privkey.pem'); + return readFileAsync(privkeyPath, 'ascii').then(function (key) { + // keypair is normally an opaque object, but here it's a pem for the filesystem + return { privateKeyPem: key }; + }).catch(function (err) { + if ('ENOENT' === err.code) { return null; } + throw err; + }); + }; + + // certificates.setKeypairAsync({ domain, keypair, ... }): + // + // Same as accounts.setKeypairAsync, but by domains rather than email / accountId + store.certificates.setKeypairAsync = function (opts, keypair) { + keypair = opts.keypair || keypair; + 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(path.dirname(privkeyPath)).then(function () { + return writeFileAsync(privkeyPath, keypair.privateKeyPem, 'ascii').then(function () { + return null; + }); + }); + }; + + // certificates.setAsync({ domain, certs, ... }): + // + // 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); + var pems = { + privkey: opts.pems.privkey + , cert: opts.pems.cert + , chain: opts.pems.chain + }; + 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(certPath)).then(function () { + return mkdirpAsync(path.dirname(chainPath)).then(function () { + return mkdirpAsync(path.dirname(fullchainPath)).then(function () { + return mkdirpAsync(path.dirname(bundlePath)).then(function () { + return PromiseA.all([ + sfs.writeFileAsync(certPath, pems.cert, 'ascii') + , sfs.writeFileAsync(chainPath, pems.chain, 'ascii') + // Most platforms need these two + , sfs.writeFileAsync(fullchainPath, [ pems.cert, pems.chain ].join('\n'), 'ascii') + //, sfs.writeFileAsync(privkeyPath, pems.privkey, 'ascii') + // HAProxy needs "bundle.pem" aka "combined.pem" + , sfs.writeFileAsync(bundlePath, [ pems.privkey, pems.cert, pems.chain ].join('\n'), 'ascii') + ]); + }); + }); + }); + }).then(function () { + return null; + }); + }; + + return store; +}; + +var defaults = { + configDir: path.join(os.homedir(), 'acme', 'etc') + +, accountsDir: path.join(':configDir', 'accounts', ':serverDir') +, serverDirGet: function (copy) { + return (copy.server || '').replace('https://', '').replace(/(\/)$/, '').replace(/\//g, path.sep); + } +, privkeyPath: path.join(':configDir', 'live', ':hostname', 'privkey.pem') +, fullchainPath: path.join(':configDir', 'live', ':hostname', 'fullchain.pem') +, certPath: path.join(':configDir', 'live', ':hostname', 'cert.pem') +, chainPath: path.join(':configDir', 'live', ':hostname', 'chain.pem') +, bundlePath: path.join(':configDir', 'live', ':hostname', 'bundle.pem') +}; + +function mergeOptions(configs) { + if (!configs.domainKeyPath) { + configs.domainKeyPath = configs.privkeyPath || defaults.privkeyPath; + } + + Object.keys(defaults).forEach(function (key) { + if (!configs[key]) { + configs[key] = defaults[key]; + } + }); + + return configs; +} + +function sanitizeFilename(id) { + return id.replace(/(\.\.)|\\|\//g, '_').replace(/[^!-~]/g, '_'); +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e0b831e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,26 @@ +{ + "name": "le-store-json", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "requires": { + "minimist": "0.0.8" + } + }, + "safe-replace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-replace/-/safe-replace-1.1.0.tgz", + "integrity": "sha512-9/V2E0CDsKs9DWOOwJH7jYpSl9S3N05uyevNjvsnDauBqRowBPOyot1fIvV5N2IuZAbYyvrTXrYFVG0RZInfFw==" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..be5df16 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "le-store-fs", + "version": "0.9.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", + "directories": { + "test": "tests" + }, + "scripts": { + "test": "node tests" + }, + "repository": { + "type": "git", + "url": "https://git.coolaj86.com/coolaj86/le-store-fs.js.git" + }, + "keywords": [ + "greenlock", + "json", + "keypairs", + "certificates", + "store", + "database" + ], + "author": "AJ ONeal (https://coolaj86.com/)", + "license": "MPL-2.0", + "dependencies": { + "mkdirp": "^0.5.1", + "safe-replace": "^1.1.0" + } +}