commit cbe20c4d0001a5ed5d3f09e939c2ef2729eea5ee Author: AJ ONeal Date: Fri Apr 5 02:26:42 2019 -0600 v1.0.0: a stupid-simple reference implementation for storage strategies 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..3688033 --- /dev/null +++ b/README.md @@ -0,0 +1,245 @@ +# le-store-memory + +An in-memory reference implementation of a Certificate and Keypair storage strategy for Greenlock v2.7+ (and v3) + +# Usage + +```js +var greenlock = require('greenlock'); + +// This in-memory plugin has only one option: 'cache'. +// We could have It's used so that we can peek and poke at the store. +var cache = {}; +var gl = greenlock.create({ + store: require('le-store-memory').create({ cache: cache }) +, approveDomains: approveDomains + ... +}); +``` + +# How to build your own module: + +**TL;DR**: Just take a look the code here, and don't over think it. + +Also, you have the flexibility to get really fancy. _Don't!_ +You probably don't need to (unless you already know that you do). + +In most cases you're just implementing dumb storage. +If all you do is `JSON.stringify()` on `set` (save) and `JSON.parse()` after `check` (get) +and just treat it as a blob with an ID, you'll do just fine. You can always optimize later. + +**Promises** vs **Thunks** ("node callbacks") vs **Synchronous** returns: +You can use whatever style you like best. Everything is promisified under the hood. + +Whenever you have neither a result, nor an error, you must **always return null** (instead of 'undefined'). + +### storage strategy vs approveDomains() + +The _most_ important thing to keep in mind: `approveDomains()` is where all of the implementation-specific logic goes. + +If you're writing a storage strategy (presumably why you're here), it's because you have logic in `approveDomains()` +that isn't supported by existing strategies. That makes it tempting to start thinking about things backwards, letting +your implementation-specific logic creep into your storage strategy. DON'T DO IT. + +Keep in mind that, ultimately, **it takes human decision** / interaction / configuration to add, remove, or +**modify the collection of domains** that are allowed, and how many / which domains are listed on each certificate - +all of which is a _completely_ separate process that lives outside of Greenlock (i.e uploading a site to a new folder). + +The coupling between the method chosen for storage and the method chosen for approval is inherint, but keep it loose. + +Lastly, it would be appropriate to include an example `approveDomains()` with your implementation for reference. + +### 0. approveDomains() is the kick off + +`approveDomains()` is called only when there is no certificate for a given domain in Greenlock's internal cache +and when that certificate is "renewable" (typically 15 days before expiration, which is configurable). + +The user (perhaps you) will have checked in their database (or config file or file system) and retrieved relevant +details (email associated with the domain, related domains that belong as altnames on the certificate, etc). + +Those options will be available to _all_ storage and challenge strategies. In fact, they can even change which +strategy is used (i.e. some users using a Digital Ocean strategy for DNS challenges, others using Route53). + +```js +function approveDomains(opts) { + var info = userDb.getInfo(opts.domain); + if (!info) { throw new Error("ignoring junk request, bad domain"); } + + opts.email = info.certificateOwner; + opts.subject = info.certificateSubject + opts.domains = info.certificateAltnames; + + return opts; // or Promise.resolve(opts); +} +``` + +### 1. Implement `accounts.setKeypair` + +First, you should implement `accounts.setKeypair()`. Just treat it like dumb storage. + +This only gets called after a new account has already been created successfully. +That will only happen when a completely new certificate is going to be issued (not renewal), +and there's no user account already associate with that set of domains. + +```js +store.accounts.setKeypair = function (opts) { + console.log('accounts.setKeypair:', opts); + + var id = opts.account.id || opts.email || 'default'; + var keypair = opts.keypair; + + cache.accountKeypairs[id] = JSON.stringify({ + privateKeyPem: keypair.privateKeyPem + , privateKeyJwk: keypair.privateKeyJwk + }); + + return null; // or Promise.resolve(null); +}; +``` + +### 2. Implement `accounts.checkKeypair` + +Whatever you did above, you just do the opposite instead. Tada! + +```js +store.accounts.checkKeypair = function (opts) { + console.log('accounts.checkKeypair:', opts); + + var id = opts.account.id || opts.email || 'default'; + var keyblob = cache.accountKeypairs[id]; + + if (!keyblob) { return null; } + + return JSON.parse(keyblob); +}; +``` + +### 3. (and 4.) Optionally save ACME account metadata + +You should probably skip this and not worry about it. + +However, if you have a special need for it, or if you want to shave off an ACME API call, +you can save the account `kid` (a misnomer intended to mean "key id", but actually refers +to an arbitrary ACME URL, used to identify the account). + +```js +store.accounts.set = function (opts) { + console.log('accounts.set:', opts); + return null; +}; +``` + +```js +store.accounts.check = function (opts) { + var id = opts.account.id || opts.email || 'default'; + console.log('accounts.check:', opts); + return null; +}; +``` + +If you don't implement these the account key will be used to "recover" the `kid` as necessary. +You don't have to worry though, it doesn't create a duplicate accounts or have any other negative +side affects other than an additional API call as needed. + +### 5. Implement a method to save certificate keypairs + +Each certificate is supposed to have a unique keypair, which **must not** be the same as the account keypair. + +Again, just treat it like a blob in dumb storage and you'll be fine. + +This is the same as `accounts.setKeypair()`, but using a different idea. +You could even use the same data store in most cases because the IDs aren't likely to clash. + +```js +store.certificates.setKeypair = function (opts) { + console.log('certificates.setKeypair:', opts); + + var id = opts.certificate.kid || opts.certificate.id || opts.subject; + var keypair = opts.keypair; + + cache.certificateKeypairs[id] = JSON.stringify({ + privateKeyPem: keypair.privateKeyPem + , privateKeyJwk: keypair.privateKeyJwk + }); + + return null; +}; +``` + +### 6. Implement a method to get certificate keypairs + +You know the drill. Same as `accounts.checkKeypair()`, but a different ID. + +This isn't called until after the certificate retrieval is successful. + +Note: Every account **must have** a unique account key and account keys are +**not allowed** to be used as certificate keys. However, you could use the +same certificate key for all domains on a device (i.e. a server) or an account. + +```js +store.certificates.checkKeypair = function (opts) { + console.log('certificates.checkKeypair:', opts); + + var id = opts.certificate.kid || opts.certificate.id || opts.subject; + var keyblob = cache.certificateKeypairs[id]; + + if (!keyblob) { return null; } + + return JSON.parse(keyblob); +}; +``` + +### 7. Implement a method to save certificates + +Whenever the ACME process completes successfully, you get a shiny new certificate with all of the domains you requested. + +It's a good idea to save them - otherwise you run the risk of running up your rate limit and getting blocked +as your server restarts, respawns, auto-scales, etc. + +```js +store.certificates.set = function (opts) { + console.log('certificates.set:', opts); + + var id = opts.certificate.id || opts.subject; + var pems = opts.pems; + cache.certificates[id] = JSON.stringify({ + cert: pems.cert + , chain: pems.chain + , subject: pems.subject + , altnames: pems.altnames + , issuedAt: pems.issuedAt // a.k.a. NotBefore + , expiresAt: pems.expiresAt // a.k.a. NotAfter + }); + + return null; +}; +``` + +Note that `chain` is likely to be the same for all certificates issued by a service, +but there's no guarantee. The service may rotate which keys do the signing, for example. + +### 8. Implement a method to get certificates + +Lastly, you just need a way to fetch the result of all the work you've done. + +```js +store.certificates.check = function (opts) { + console.log('certificates.check:', opts); + + var id = opts.certificate.id || opts.subject; + var certblob = cache.certificates[id]; + + if (!certblob) { return null; } + + return JSON.parse(certblob); +}; +``` + +# Huzzah! + +There you go - you basically just have 8 setter and getter functions that usually act as dumb storage, +but that you can tweak with custom options if you need to. + +Remember: Keep It Stupid-Simple + +:D diff --git a/index.js b/index.js new file mode 100644 index 0000000..2b8a40a --- /dev/null +++ b/index.js @@ -0,0 +1,144 @@ +'use strict'; + +module.exports.create = function (opts) { + // pass in database url, connection string, filepath, + // or whatever it is you need to get your job done well + + + + // This is our in-memory storage. + // We take it from the outside to make testing the dummy module easier. + var cache = opts.cache || {}; + if (!cache.accounts) { cache.accounts = {}; } + if (!cache.certificates) { cache.certificates = {}; } + // Although we could have two collections of keypairs, + // it's also fine to store both types together. + if (!cache.keypairs) { cache.keypairs = {}; } + + + + var store = {}; + // any options you need per instance + // (probably okay to leave empty) + store.options = {}; + store.accounts = {}; + store.certificates = {}; + + + + // Whenever a new keypair is used to successfully create an account, we need to save its keypair + store.accounts.setKeypair = function (opts) { + console.log('accounts.setKeypair:', opts); + + var id = opts.account.id || opts.email || 'default'; + var keypair = opts.keypair; + + cache.keypairs[id] = JSON.stringify({ + privateKeyPem: keypair.privateKeyPem + , privateKeyJwk: keypair.privateKeyJwk + }); + + return null; // or Promise.resolve(null); + }; + + + + // We need a way to retrieve a prior account's keypair for renewals and additional ACME certificate "orders" + store.accounts.checkKeypair = function (opts) { + console.log('accounts.checkKeypair:', opts); + + var id = opts.account.id || opts.email || 'default'; + var keyblob = cache.keypairs[id]; + + if (!keyblob) { return null; } + + return JSON.parse(keyblob); + }; + + + + // We can optionally implement ACME account storage and retrieval + // (to reduce API calls), but it's really not necessary. + /* + store.accounts.set = function (opts) { + console.log('accounts.set:', opts); + return null; + }; + store.accounts.check = function (opts) { + var id = opts.account.id || opts.email || 'default'; + console.log('accounts.check:', opts); + return null; + }; + */ + + + + // The certificate keypairs must not be the same as any account keypair + store.certificates.setKeypair = function (opts) { + console.log('certificates.setKeypair:', opts); + + var id = opts.certificate.kid || opts.certificate.id || opts.subject; + var keypair = opts.keypair; + + cache.keypairs[id] = JSON.stringify({ + privateKeyPem: keypair.privateKeyPem + , privateKeyJwk: keypair.privateKeyJwk + }); + // Note: you can use the "keypairs" package to convert between + // public and private for jwk and pem, as well as convert JWK <-> PEM + + return null; + }; + + + + // You won't be able to use a certificate without it's private key, gotta save it + store.certificates.checkKeypair = function (opts) { + console.log('certificates.checkKeypair:', opts); + + var id = opts.certificate.kid || opts.certificate.id || opts.subject; + var keyblob = cache.keypairs[id]; + + if (!keyblob) { return null; } + + return JSON.parse(keyblob); + }; + + + + // And you'll also need to save certificates. You may find the metadata useful to save + // (perhaps to delete expired keys), but the same information can also be redireved from + // the key using the "cert-info" package. + store.certificates.set = function (opts) { + console.log('certificates.set:', opts); + + var id = opts.certificate.id || opts.subject; + var pems = opts.pems; + cache.certificates[id] = JSON.stringify({ + cert: pems.cert + , chain: pems.chain + , subject: pems.subject + , altnames: pems.altnames + , issuedAt: pems.issuedAt // a.k.a. NotBefore + , expiresAt: pems.expiresAt // a.k.a. NotAfter + }); + + return null; + }; + + + + // This is actually the first thing to be called after approveDomins(), + // but it's easiest to implement last since it's not useful until there + // are certs that can actually be loaded from storage. + store.certificates.check = function (opts) { + console.log('certificates.check:', opts); + + var id = opts.certificate.id || opts.subject; + var certblob = cache.certificates[id]; + + if (!certblob) { return null; } + + return JSON.parse(certblob); + }; +}; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1a3f081 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,5 @@ +{ + "name": "le-store-memory", + "version": "1.0.0", + "lockfileVersion": 1 +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8b9b0dc --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "le-store-memory", + "version": "1.0.0", + "description": "An in-memory reference implementation for account, certificate, and keypair storage strategies in Greenlock", + "homepage": "https://git.coolaj86.com/coolaj86/le-store-memory.js", + "main": "index.js", + "directories": { + "test": "tests" + }, + "scripts": { + "test": "node tests" + }, + "repository": { + "type": "git", + "url": "https://git.coolaj86.com/coolaj86/le-store-memory.js.git" + }, + "keywords": [ + "greenlock", + "acme", + "json", + "keypairs", + "certificates", + "store", + "database" + ], + "author": "AJ ONeal (https://coolaj86.com/)", + "license": "MPL-2.0", + "dependencies": {} +}