Browse Source

v1.0.0: a stupid-simple reference implementation for storage strategies

master v1.0.0
AJ ONeal 5 years ago
commit
cbe20c4d00
  1. 61
      .gitignore
  2. 41
      LICENSE
  3. 245
      README.md
  4. 144
      index.js
  5. 5
      package-lock.json
  6. 29
      package.json

61
.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

41
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.

245
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

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

5
package-lock.json

@ -0,0 +1,5 @@
{
"name": "le-store-memory",
"version": "1.0.0",
"lockfileVersion": 1
}

29
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 <coolaj86@gmail.com> (https://coolaj86.com/)",
"license": "MPL-2.0",
"dependencies": {}
}
Loading…
Cancel
Save