@postalsys/certs 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc +15 -0
- package/.prettierrc.js +8 -0
- package/examples/test.js +85 -0
- package/lib/acme-challenge.js +134 -0
- package/lib/certs.js +482 -0
- package/lib/settings.js +81 -0
- package/lib/tools.js +75 -0
- package/package.json +31 -0
package/.eslintrc
ADDED
package/.prettierrc.js
ADDED
package/examples/test.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const Redis = require('ioredis');
|
|
4
|
+
|
|
5
|
+
const express = require('express');
|
|
6
|
+
const app = express();
|
|
7
|
+
const port = 7003;
|
|
8
|
+
|
|
9
|
+
// Create a Redis instance.
|
|
10
|
+
// By default, it will connect to localhost:6379.
|
|
11
|
+
// We are going to cover how to specify connection options soon.
|
|
12
|
+
const redis = new Redis();
|
|
13
|
+
const { Certs } = require('../');
|
|
14
|
+
|
|
15
|
+
let certs = new Certs({
|
|
16
|
+
redis,
|
|
17
|
+
namespace: 'test',
|
|
18
|
+
|
|
19
|
+
encryptFn: async value => {
|
|
20
|
+
if (!value) {
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
if (typeof value === 'string') {
|
|
24
|
+
value = Buffer.from(value);
|
|
25
|
+
}
|
|
26
|
+
if (!Buffer.isBuffer(value)) {
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
return '$' + value.toString('hex');
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
decryptFn: async value => {
|
|
33
|
+
if (Buffer.isBuffer(value)) {
|
|
34
|
+
value = value.toString();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (typeof value !== 'string' || !value || value.charAt(0) !== '$') {
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return Buffer.from(value.substr(1), 'hex').toString();
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const main = async () => {
|
|
46
|
+
console.log(await certs.getAcmeAccount());
|
|
47
|
+
|
|
48
|
+
console.log(await certs.getCertificate('localdev.kreata.ee'));
|
|
49
|
+
|
|
50
|
+
console.log(await certs.acquireCert('localdev.kreata.ee'));
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
app.get('/', (req, res) => {
|
|
54
|
+
res.send('Hello World!');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
app.get('/.well-known/acme-challenge/:token', (req, res) => {
|
|
58
|
+
const token = req.params.token;
|
|
59
|
+
const domain = req.get('host');
|
|
60
|
+
certs
|
|
61
|
+
.routeHandler(domain, token)
|
|
62
|
+
.then(challenge => {
|
|
63
|
+
res.status(200).set('content-type', 'text/plain').send(challenge);
|
|
64
|
+
})
|
|
65
|
+
.catch(err => {
|
|
66
|
+
res.status(err.statusCode || 500).send({
|
|
67
|
+
error: err.message,
|
|
68
|
+
code: err.code,
|
|
69
|
+
details: err.details
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
app.listen(port, () => {
|
|
75
|
+
console.log(`Example app listening on port ${port}`);
|
|
76
|
+
|
|
77
|
+
main()
|
|
78
|
+
.then(() => {
|
|
79
|
+
process.exit(0);
|
|
80
|
+
})
|
|
81
|
+
.catch(err => {
|
|
82
|
+
console.error(err);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { normalizeDomain } = require('./tools');
|
|
4
|
+
const { Settings } = require('./settings');
|
|
5
|
+
const msgpack = require('msgpack5')();
|
|
6
|
+
const { v4: uuid } = require('uuid');
|
|
7
|
+
|
|
8
|
+
// Unfinished challenges are deleted after this amount of time
|
|
9
|
+
const DEFAULT_TTL = 2 * 3600 * 1000; // milliseconds
|
|
10
|
+
|
|
11
|
+
class AcmeChallenge {
|
|
12
|
+
static create(config = {}) {
|
|
13
|
+
return new AcmeChallenge(config);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
constructor(config) {
|
|
17
|
+
this.config = config;
|
|
18
|
+
const { redis, ttl, namespace } = this.config;
|
|
19
|
+
|
|
20
|
+
this.uuid = uuid();
|
|
21
|
+
this.redis = redis;
|
|
22
|
+
this.ttl = ttl || DEFAULT_TTL;
|
|
23
|
+
|
|
24
|
+
this.namespace = namespace;
|
|
25
|
+
this.ns = namespace ? `${namespace}:` : '';
|
|
26
|
+
|
|
27
|
+
this.settings = Settings.create({
|
|
28
|
+
redis: this.redis,
|
|
29
|
+
namespace: this.namespace
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
init(/*opts*/) {
|
|
34
|
+
// not much to do here
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
getKey(name) {
|
|
39
|
+
return `${this.ns}certs:${name}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async getData(domain, token) {
|
|
43
|
+
let keyName = this.getKey(`challenge:${domain}:${token}`);
|
|
44
|
+
|
|
45
|
+
let encoded = await this.redis.getBuffer(keyName);
|
|
46
|
+
if (encoded && encoded.length) {
|
|
47
|
+
return msgpack.decode(encoded);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async setData(domain, token, data) {
|
|
54
|
+
let keyName = this.getKey(`challenge:${domain}:${token}`);
|
|
55
|
+
|
|
56
|
+
let result = await this.redis.multi().set(keyName, msgpack.encode(data)).expire(keyName, this.ttl).exec();
|
|
57
|
+
if (result[0][0]) {
|
|
58
|
+
throw result[0][0];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (result[1][0]) {
|
|
62
|
+
throw result[1][0];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return result && result[0] && result[1];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async deleteData(domain, token) {
|
|
69
|
+
let keyName = this.getKey(`challenge:${domain}:${token}`);
|
|
70
|
+
return await this.redis.del(keyName);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async set(opts) {
|
|
74
|
+
const { challenge } = opts;
|
|
75
|
+
const { altname, keyAuthorization, token } = challenge;
|
|
76
|
+
|
|
77
|
+
let domain = normalizeDomain(altname);
|
|
78
|
+
|
|
79
|
+
let dataKey = `domain:${domain}:data`;
|
|
80
|
+
if (!(await this.settings.has(dataKey))) {
|
|
81
|
+
let err = new Error('Domain not found');
|
|
82
|
+
err.responseCode = 404;
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
await this.setData(domain, token, {
|
|
87
|
+
acme: {
|
|
88
|
+
token,
|
|
89
|
+
secret: {
|
|
90
|
+
value: keyAuthorization,
|
|
91
|
+
created: new Date(),
|
|
92
|
+
expires: new Date(Date.now() + this.ttl)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async get(query) {
|
|
101
|
+
const { challenge } = query;
|
|
102
|
+
const { identifier, token } = challenge;
|
|
103
|
+
const domain = normalizeDomain(identifier.value);
|
|
104
|
+
|
|
105
|
+
let tokenData = await this.getData(domain, token);
|
|
106
|
+
if (!tokenData) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (
|
|
111
|
+
!tokenData.acme ||
|
|
112
|
+
!tokenData.acme.secret ||
|
|
113
|
+
!tokenData.acme.secret.value ||
|
|
114
|
+
(tokenData.acme.secret.expires && tokenData.acme.secret.expires < new Date())
|
|
115
|
+
) {
|
|
116
|
+
await this.deleteData(domain, token);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
keyAuthorization: tokenData.acme.secret.value
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async remove(opts) {
|
|
126
|
+
const { challenge } = opts;
|
|
127
|
+
const { identifier, token } = challenge;
|
|
128
|
+
const domain = normalizeDomain(identifier.value);
|
|
129
|
+
|
|
130
|
+
return this.deleteData(domain, token);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = AcmeChallenge;
|
package/lib/certs.js
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const ACME = require('@root/acme');
|
|
4
|
+
const { pem2jwk } = require('pem-jwk');
|
|
5
|
+
const CSR = require('@root/csr');
|
|
6
|
+
const pino = require('pino');
|
|
7
|
+
const Joi = require('joi');
|
|
8
|
+
const Lock = require('ioredfour');
|
|
9
|
+
const { Resolver } = require('dns').promises;
|
|
10
|
+
|
|
11
|
+
const pkg = require('../package.json');
|
|
12
|
+
const AcmeChallenge = require('./acme-challenge');
|
|
13
|
+
const { normalizeDomain, generateKey, parseCertificate, validationErrors } = require('./tools');
|
|
14
|
+
const { Settings } = require('./settings');
|
|
15
|
+
|
|
16
|
+
const resolver = new Resolver();
|
|
17
|
+
|
|
18
|
+
const RENEW_AFTER_REMAINING = 10000 + 30 * 24 * 3600 * 1000;
|
|
19
|
+
const BLOCK_RENEW_AFTER_ERROR_TTL = 10; //3600;
|
|
20
|
+
|
|
21
|
+
class Certs {
|
|
22
|
+
static create(options = {}) {
|
|
23
|
+
return new Certs(options);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
constructor(options) {
|
|
27
|
+
options = options || {};
|
|
28
|
+
|
|
29
|
+
this.redis = options.redis;
|
|
30
|
+
|
|
31
|
+
this.namespace = options.namespace;
|
|
32
|
+
this.ns = options.namespace ? `${options.namespace}:` : '';
|
|
33
|
+
|
|
34
|
+
this.encryptFn = options.encryptFn || (async val => val);
|
|
35
|
+
this.decryptFn = options.decryptFn || (async val => val);
|
|
36
|
+
|
|
37
|
+
this.acmeOptions = Object.assign(
|
|
38
|
+
{
|
|
39
|
+
environment: 'development',
|
|
40
|
+
directoryUrl: 'https://acme-staging-v02.api.letsencrypt.org/directory',
|
|
41
|
+
email: pkg.author.email,
|
|
42
|
+
caaDomains: ['letsencrypt.org']
|
|
43
|
+
},
|
|
44
|
+
options.acme || {}
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
if (!Array.isArray(this.acmeOptions.caaDomains)) {
|
|
48
|
+
this.acmeOptions.caaDomains = [].concat(this.acmeOptions.caaDomains || []);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this.keyBits = Number(options.keyBits) || 2048;
|
|
52
|
+
this.keyExponent = Number(options.keyExponent) || 65537;
|
|
53
|
+
|
|
54
|
+
this.logger = options.logger || pino();
|
|
55
|
+
|
|
56
|
+
this.acme = ACME.create({
|
|
57
|
+
maintainerEmail: pkg.author.email,
|
|
58
|
+
packageAgent: pkg.name + '/' + pkg.version,
|
|
59
|
+
notify: (ev, params) => {
|
|
60
|
+
this.logger.info({ msg: 'ACME Notification', ev, params });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
this.settings = Settings.create({
|
|
65
|
+
redis: this.redis,
|
|
66
|
+
namespace: this.namespace
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
this.locking = new Lock({
|
|
70
|
+
redis: this.redis,
|
|
71
|
+
namespace: `${this.ns}acme:lock`
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
this.acmeChallenge = AcmeChallenge.create({
|
|
75
|
+
redis: this.redis,
|
|
76
|
+
namespace: this.namespace
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
this.acmeInitialized = false;
|
|
80
|
+
this.acmeInitializing = false;
|
|
81
|
+
this.acmeInitPending = [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
getKey(name) {
|
|
85
|
+
return `${this.ns}certs:${name}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/*
|
|
89
|
+
* Make sure that the ACME object is initialized
|
|
90
|
+
* If not, then queue the call and resolve/reject once done
|
|
91
|
+
*/
|
|
92
|
+
async ensureAcme() {
|
|
93
|
+
if (this.acmeInitialized) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
if (this.acmeInitializing) {
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
this.acmeInitPending.push({ resolve, reject });
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
await this.acme.init(this.acmeOptions.directoryUrl);
|
|
104
|
+
this.acmeInitialized = true;
|
|
105
|
+
|
|
106
|
+
if (this.acmeInitPending.length) {
|
|
107
|
+
for (let entry of this.acmeInitPending) {
|
|
108
|
+
entry.resolve(true);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (this.acmeInitPending.length) {
|
|
113
|
+
for (let entry of this.acmeInitPending) {
|
|
114
|
+
entry.reject(err);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
throw err;
|
|
118
|
+
} finally {
|
|
119
|
+
this.acmeInitializing = false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async getAcmeAccount() {
|
|
126
|
+
await this.ensureAcme();
|
|
127
|
+
|
|
128
|
+
const settingKey = `account:${this.acmeOptions.environment}`;
|
|
129
|
+
const accountData = await this.settings.get(settingKey);
|
|
130
|
+
|
|
131
|
+
// there is already an existing acme account, no need to create a new one
|
|
132
|
+
if (accountData) {
|
|
133
|
+
if (accountData.privateKey) {
|
|
134
|
+
accountData.privateKey = await this.decryptFn(accountData.privateKey);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return accountData;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// account not found, create a new one
|
|
141
|
+
this.logger.info({
|
|
142
|
+
msg: 'ACME account not found, provisioning a new one',
|
|
143
|
+
directoryUrl: this.acmeOptions.directoryUrl,
|
|
144
|
+
environment: this.acmeOptions.environment
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const privateKey = await generateKey(this.acmeOptions.keyBits, this.acmeOptions.keyExponent);
|
|
148
|
+
|
|
149
|
+
const jwkAccount = pem2jwk(privateKey);
|
|
150
|
+
this.logger.trace({ msg: 'Generated Acme account key', environment: this.acmeOptions.environment });
|
|
151
|
+
|
|
152
|
+
const accountOptions = {
|
|
153
|
+
subscriberEmail: this.acmeOptions.email,
|
|
154
|
+
agreeToTerms: true,
|
|
155
|
+
accountKey: jwkAccount
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const account = await this.acme.accounts.create(accountOptions);
|
|
159
|
+
|
|
160
|
+
this.settings.set(settingKey, {
|
|
161
|
+
privateKey: await this.encryptFn(privateKey),
|
|
162
|
+
account
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
this.logger.trace({ msg: 'ACME account provisioned', environment: this.acmeOptions.environment });
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
privateKey,
|
|
169
|
+
account
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async validateDomain(domain) {
|
|
174
|
+
// check domain name format
|
|
175
|
+
const validation = Joi.string()
|
|
176
|
+
.domain({ tlds: { allow: true } })
|
|
177
|
+
.validate(domain);
|
|
178
|
+
|
|
179
|
+
if (validation.error) {
|
|
180
|
+
// invalid domain name, can not create certificate
|
|
181
|
+
let err = new Error('${domain} is not a valid domain name');
|
|
182
|
+
err.responseCode = 400;
|
|
183
|
+
err.code = 'invalid_domain';
|
|
184
|
+
throw err;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// check CAA support
|
|
188
|
+
const caaDomains = this.acmeOptions.caaDomains.map(normalizeDomain).filter(d => d);
|
|
189
|
+
|
|
190
|
+
// CAA support in node 15+
|
|
191
|
+
if (typeof resolver.resolveCaa === 'function' && caaDomains.length) {
|
|
192
|
+
let parts = domain.split('.');
|
|
193
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
194
|
+
let subdomain = parts.slice(i).join('.');
|
|
195
|
+
let caaRes;
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
caaRes = await resolver.resolveCaa(subdomain);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
// assume not found
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (caaRes && caaRes.length && !caaRes.some(r => caaDomains.includes(normalizeDomain(r && r.issue)))) {
|
|
204
|
+
let err = new Error(`LE not listed in the CAA record for ${subdomain} (${domain})`);
|
|
205
|
+
err.responseCode = 403;
|
|
206
|
+
err.code = 'caa_mismatch';
|
|
207
|
+
throw err;
|
|
208
|
+
} else if (caaRes && caaRes.length) {
|
|
209
|
+
this.logger.trace({ msg: 'Found matching CAA record', subdomain, domain, caaRes });
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async loadCertificateData(domain) {
|
|
219
|
+
let dataKey = `domain:${domain}:data`;
|
|
220
|
+
let lastCheckKey = `domain:${domain}:lastCheck`;
|
|
221
|
+
let privateKeyKey = `domain:${domain}:privateKey`;
|
|
222
|
+
let lastErrorKey = `domain:${domain}:lastError`;
|
|
223
|
+
let versionKey = `domain:${domain}:certVersion`;
|
|
224
|
+
|
|
225
|
+
let data = await this.settings.get([dataKey, lastCheckKey, privateKeyKey, lastErrorKey]);
|
|
226
|
+
let certVersion = await this.redis.hget(this.settings.getKey('settings'), versionKey);
|
|
227
|
+
|
|
228
|
+
if (!data[dataKey]) {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return Object.assign(data[dataKey], {
|
|
233
|
+
lastCheck: data[lastCheckKey] || null,
|
|
234
|
+
privateKey: data[privateKeyKey] ? await this.decryptFn(data[privateKeyKey]) : null,
|
|
235
|
+
lastError: data[lastErrorKey] || null,
|
|
236
|
+
certVersion: Number(certVersion) || null
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async setCertificateData(domain, updates) {
|
|
241
|
+
updates = updates || {};
|
|
242
|
+
|
|
243
|
+
let dataKey = `domain:${domain}:data`;
|
|
244
|
+
let lastCheckKey = `domain:${domain}:lastCheck`;
|
|
245
|
+
let privateKeyKey = `domain:${domain}:privateKey`;
|
|
246
|
+
let lastErrorKey = `domain:${domain}:lastError`;
|
|
247
|
+
let versionKey = `domain:${domain}:certVersion`;
|
|
248
|
+
|
|
249
|
+
let values = {};
|
|
250
|
+
|
|
251
|
+
let incrVersion = !!updates.cert;
|
|
252
|
+
|
|
253
|
+
if ('privateKey' in updates) {
|
|
254
|
+
values[privateKeyKey] = await this.encryptFn(updates.privateKey);
|
|
255
|
+
delete updates.privateKey;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if ('lastCheck' in updates) {
|
|
259
|
+
values[lastCheckKey] = updates.lastCheck;
|
|
260
|
+
delete updates.lastCheck;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if ('lastError' in updates) {
|
|
264
|
+
values[lastErrorKey] = updates.lastError;
|
|
265
|
+
delete updates.lastError;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if ('certVersion' in updates) {
|
|
269
|
+
delete updates.certVersion;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (Object.keys(updates).length) {
|
|
273
|
+
let currendData = await this.settings.get(dataKey);
|
|
274
|
+
values[dataKey] = Object.assign(currendData || {}, updates);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (incrVersion) {
|
|
278
|
+
await this.redis.hincrby(this.settings.getKey('settings'), versionKey, 1);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (Object.keys(values).length) {
|
|
282
|
+
return await this.settings.set(values);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async acquireCert(domain) {
|
|
287
|
+
domain = normalizeDomain(domain);
|
|
288
|
+
|
|
289
|
+
const domainSafeLockKey = this.getKey(`lock:safe:${domain}`);
|
|
290
|
+
const domainOpLockKey = this.getKey(`lock:op:${domain}`);
|
|
291
|
+
|
|
292
|
+
let existingCertificateData = await this.loadCertificateData(domain);
|
|
293
|
+
|
|
294
|
+
if (await this.redis.exists(domainSafeLockKey)) {
|
|
295
|
+
// nothing to do here, renewal blocked
|
|
296
|
+
this.logger.info({ msg: 'Renewal blocked by failsafe lock', domain, lock: domainSafeLockKey });
|
|
297
|
+
|
|
298
|
+
// use default
|
|
299
|
+
return existingCertificateData;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
// throws if can not validate domain
|
|
304
|
+
await this.validateDomain(domain);
|
|
305
|
+
this.logger.trace({ msg: 'Domain validation', domain });
|
|
306
|
+
} catch (err) {
|
|
307
|
+
this.logger.error({ msg: 'Failed to validate domain', domain, err });
|
|
308
|
+
return existingCertificateData;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
let lock = await this.locking.waitAcquireLock(domainOpLockKey, 10 * 60 * 1000, 3 * 60 * 1000);
|
|
312
|
+
if (!lock.success) {
|
|
313
|
+
return existingCertificateData;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
// reload from db, maybe already renewed
|
|
318
|
+
if (existingCertificateData.validTo && existingCertificateData.validTo > new Date(Date.now() + RENEW_AFTER_REMAINING)) {
|
|
319
|
+
// no need to renew
|
|
320
|
+
return existingCertificateData;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
let privateKey = existingCertificateData.privateKey;
|
|
324
|
+
if (!privateKey) {
|
|
325
|
+
// generate new key
|
|
326
|
+
this.logger.trace({ msg: 'Provision new private key', domain });
|
|
327
|
+
privateKey = await generateKey(this.acmeOptions.keyBits, this.acmeOptions.keyExponent);
|
|
328
|
+
await this.setCertificateData(domain, { domain, privateKey, status: 'pending', lastError: null });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const jwkPrivateKey = pem2jwk(privateKey);
|
|
332
|
+
const csr = await CSR.csr({
|
|
333
|
+
jwk: jwkPrivateKey,
|
|
334
|
+
domains: [domain],
|
|
335
|
+
encoding: 'pem'
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const acmeAccount = await this.getAcmeAccount();
|
|
339
|
+
if (!acmeAccount) {
|
|
340
|
+
this.logger.error({ msg: 'Skip certificate renewal, acme account not found', domain });
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const jwkAccount = pem2jwk(acmeAccount.privateKey);
|
|
345
|
+
const certificateOptions = {
|
|
346
|
+
account: acmeAccount.account,
|
|
347
|
+
accountKey: jwkAccount,
|
|
348
|
+
csr,
|
|
349
|
+
domains: [domain],
|
|
350
|
+
challenges: {
|
|
351
|
+
'http-01': this.acmeChallenge
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const aID = ((acmeAccount && acmeAccount.account && acmeAccount.account.key && acmeAccount.account.key.kid) || '').split('/acct/').pop();
|
|
356
|
+
this.logger.info({ msg: 'Generate ACME cert', domain, aID });
|
|
357
|
+
const cert = await this.acme.certificates.create(certificateOptions);
|
|
358
|
+
if (!cert || !cert.cert) {
|
|
359
|
+
this.logger.error({ msg: 'Failed to generate certificate. Empty response', domain });
|
|
360
|
+
return existingCertificateData;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
this.logger.info({ msg: 'Received certificate from ACME', domain });
|
|
364
|
+
|
|
365
|
+
let now = new Date();
|
|
366
|
+
const parsed = parseCertificate(cert.cert);
|
|
367
|
+
|
|
368
|
+
let updates = Object.assign(parseCertificate(cert.cert), {
|
|
369
|
+
cert: cert.cert,
|
|
370
|
+
ca: [].concat(cert.chain || []),
|
|
371
|
+
lastCheck: now,
|
|
372
|
+
lastError: null,
|
|
373
|
+
status: 'valid'
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
await this.setCertificateData(domain, updates);
|
|
377
|
+
this.logger.info({ msg: 'Certificate successfully generated', domain, expires: parsed.validTo });
|
|
378
|
+
return await this.loadCertificateData(domain);
|
|
379
|
+
} catch (err) {
|
|
380
|
+
try {
|
|
381
|
+
await this.redis.multi().set(domainSafeLockKey, 1).expire(domainSafeLockKey, BLOCK_RENEW_AFTER_ERROR_TTL).exec();
|
|
382
|
+
} catch (err) {
|
|
383
|
+
this.logger.error({ msg: 'Redis call failed', domainSafeLockKey, domain, err });
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
this.logger.error({ msg: 'Failed to generate certificate', domain, err });
|
|
387
|
+
|
|
388
|
+
if (existingCertificateData) {
|
|
389
|
+
try {
|
|
390
|
+
await this.setCertificateData(domain, {
|
|
391
|
+
lastError: {
|
|
392
|
+
err: err.message,
|
|
393
|
+
code: err.code,
|
|
394
|
+
time: new Date()
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
} catch (err) {
|
|
398
|
+
this.logger.error({ msg: 'Failed to update certificate record', domain, err });
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (existingCertificateData && existingCertificateData.cert) {
|
|
403
|
+
// use existing certificate data if exists
|
|
404
|
+
return existingCertificateData;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
throw err;
|
|
408
|
+
} finally {
|
|
409
|
+
try {
|
|
410
|
+
await this.locking.releaseLock(lock);
|
|
411
|
+
} catch (err) {
|
|
412
|
+
this.logger.error({ msg: 'Failed to release lock', domainOpLockKey, err });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async routeHandler(domain, token) {
|
|
418
|
+
const schema = Joi.object().keys({
|
|
419
|
+
domain: Joi.string().domain({ tlds: { allow: true } }),
|
|
420
|
+
token: Joi.string().empty('').max(256).required()
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
const result = schema.validate(
|
|
424
|
+
{ domain, token },
|
|
425
|
+
{
|
|
426
|
+
abortEarly: false,
|
|
427
|
+
convert: true,
|
|
428
|
+
allowUnknown: true
|
|
429
|
+
}
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
if (result.error) {
|
|
433
|
+
let err = new Error(result.error.message);
|
|
434
|
+
err.code = 'InputValidationError';
|
|
435
|
+
err.details = validationErrors(result);
|
|
436
|
+
err.responseCode = 400;
|
|
437
|
+
throw err;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
let challenge;
|
|
441
|
+
try {
|
|
442
|
+
challenge = await this.acmeChallenge.get({
|
|
443
|
+
challenge: {
|
|
444
|
+
token,
|
|
445
|
+
identifier: {
|
|
446
|
+
value: domain
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
} catch (err) {
|
|
451
|
+
this.logger.error({ msg: `Error verifying challenge`, domain, token, err });
|
|
452
|
+
|
|
453
|
+
let resErr = new Error(`Failed to verify authentication token`);
|
|
454
|
+
err.code = 'ChallengeFail';
|
|
455
|
+
resErr.responseCode = 500;
|
|
456
|
+
throw resErr;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (!challenge || !challenge.keyAuthorization) {
|
|
460
|
+
this.logger.error({ msg: `Unknown challenge`, domain, token });
|
|
461
|
+
|
|
462
|
+
let err = new Error(`Unknown challenge`);
|
|
463
|
+
err.code = 'ChallengeNotFound';
|
|
464
|
+
err.responseCode = 404;
|
|
465
|
+
throw err;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return challenge.keyAuthorization;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async getCertificate(domain) {
|
|
472
|
+
domain = normalizeDomain(domain);
|
|
473
|
+
let certificateData = await this.loadCertificateData(domain);
|
|
474
|
+
if (certificateData && certificateData.status === 'valid' && certificateData.validTo && certificateData.validTo >= new Date()) {
|
|
475
|
+
return certificateData;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return await this.acquireCert(domain);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
module.exports = { Certs };
|
package/lib/settings.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const msgpack = require('msgpack5')();
|
|
4
|
+
|
|
5
|
+
class Settings {
|
|
6
|
+
static create(options = {}) {
|
|
7
|
+
return new Settings(options);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
constructor(options) {
|
|
11
|
+
this.options = options;
|
|
12
|
+
const { redis, namespace } = this.options;
|
|
13
|
+
this.redis = redis;
|
|
14
|
+
|
|
15
|
+
this.namespace = namespace;
|
|
16
|
+
this.ns = namespace ? `${namespace}:` : '';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
getKey(name) {
|
|
20
|
+
return `${this.ns}certs:${name}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async set(...args) {
|
|
24
|
+
let settingsKey = this.getKey('settings');
|
|
25
|
+
|
|
26
|
+
let props = false;
|
|
27
|
+
|
|
28
|
+
if (args.length === 1 && typeof args[0] === 'object' && args[0]) {
|
|
29
|
+
props = {};
|
|
30
|
+
for (let key of Object.keys(args[0])) {
|
|
31
|
+
props[key] = msgpack.encode(args[0][key]);
|
|
32
|
+
}
|
|
33
|
+
} else if (args.length === 2 && typeof args[0] === 'string') {
|
|
34
|
+
props = {
|
|
35
|
+
[args[0]]: msgpack.encode(args[1])
|
|
36
|
+
};
|
|
37
|
+
} else {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (await this.redis.hmset(settingsKey, props)) === 'OK';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async get(...args) {
|
|
45
|
+
let settingsKey = this.getKey('settings');
|
|
46
|
+
|
|
47
|
+
let keys = args.flatMap(arg => arg);
|
|
48
|
+
let list = await this.redis.hmgetBuffer(settingsKey, keys);
|
|
49
|
+
|
|
50
|
+
let data = {};
|
|
51
|
+
for (let i = 0; i < list.length; i++) {
|
|
52
|
+
try {
|
|
53
|
+
let key = keys[i];
|
|
54
|
+
data[key] = msgpack.decode(list[i]);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
// ignore?
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (keys.length === 1) {
|
|
61
|
+
return data[keys[0]];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return data;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async delete(...args) {
|
|
68
|
+
let settingsKey = this.getKey('settings');
|
|
69
|
+
|
|
70
|
+
let keys = args.flatMap(arg => arg);
|
|
71
|
+
|
|
72
|
+
return await this.redis.hdel(settingsKey, ...keys);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async has(key) {
|
|
76
|
+
let settingsKey = this.getKey('settings');
|
|
77
|
+
return await this.redis.hexists(settingsKey, key);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { Settings };
|
package/lib/tools.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const punycode = require('punycode/');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const { promisify } = require('util');
|
|
6
|
+
const generateKeyPair = promisify(crypto.generateKeyPair);
|
|
7
|
+
|
|
8
|
+
module.exports = {
|
|
9
|
+
normalizeDomain(domain) {
|
|
10
|
+
domain = (domain || '').toLowerCase().trim();
|
|
11
|
+
try {
|
|
12
|
+
if (/^xn--/.test(domain)) {
|
|
13
|
+
domain = punycode.toUnicode(domain).normalize('NFC').toLowerCase().trim();
|
|
14
|
+
}
|
|
15
|
+
} catch (E) {
|
|
16
|
+
// ignore
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return domain;
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
async generateKey(keyBits, keyExponent, opts) {
|
|
23
|
+
opts = opts || {};
|
|
24
|
+
const { privateKey /*, publicKey */ } = await generateKeyPair('rsa', {
|
|
25
|
+
modulusLength: keyBits || 2048, // options
|
|
26
|
+
publicExponent: keyExponent || 65537,
|
|
27
|
+
publicKeyEncoding: {
|
|
28
|
+
type: opts.publicKeyEncoding || 'spki',
|
|
29
|
+
format: 'pem'
|
|
30
|
+
},
|
|
31
|
+
privateKeyEncoding: {
|
|
32
|
+
// jwk functions fail on other encodings (eg. pkcs8)
|
|
33
|
+
type: opts.privateKeyEncoding || 'pkcs1',
|
|
34
|
+
format: 'pem'
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return privateKey;
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
parseCertificate(cert) {
|
|
42
|
+
const parseNames = x509 => {
|
|
43
|
+
let input = []
|
|
44
|
+
.concat(x509.subject || [])
|
|
45
|
+
.concat(x509.subjectAltName || [])
|
|
46
|
+
.join(', ');
|
|
47
|
+
let names = new Set();
|
|
48
|
+
input.replace(/(CN=|DNS:)([^,\s]+)/gi, (o, p, name) => {
|
|
49
|
+
names.add(module.exports.normalizeDomain(name));
|
|
50
|
+
});
|
|
51
|
+
return Array.from(names);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
let x509 = new crypto.X509Certificate(cert);
|
|
55
|
+
return {
|
|
56
|
+
serialNumber: x509.serialNumber,
|
|
57
|
+
fingerprint: x509.fingerprint,
|
|
58
|
+
altNames: parseNames(x509),
|
|
59
|
+
validFrom: new Date(x509.validFrom),
|
|
60
|
+
validTo: new Date(x509.validTo)
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
validationErrors(validationResult) {
|
|
65
|
+
const errors = {};
|
|
66
|
+
if (validationResult.error && validationResult.error.details) {
|
|
67
|
+
validationResult.error.details.forEach(detail => {
|
|
68
|
+
if (!errors[detail.path]) {
|
|
69
|
+
errors[detail.path] = detail.message;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return errors;
|
|
74
|
+
}
|
|
75
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@postalsys/certs",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Manage Let's Encrypt certificates",
|
|
5
|
+
"main": "lib/certs.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [],
|
|
10
|
+
"author": {
|
|
11
|
+
"name": "Postal Systems OÜ",
|
|
12
|
+
"email": "acme@postalsys.com"
|
|
13
|
+
},
|
|
14
|
+
"license": "ISC",
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"eslint-config-nodemailer": "1.2.0",
|
|
17
|
+
"eslint-config-prettier": "8.5.0",
|
|
18
|
+
"express": "4.18.1",
|
|
19
|
+
"ioredis": "5.0.6"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@root/acme": "3.1.0",
|
|
23
|
+
"ioredfour": "1.2.0-ioredis-06",
|
|
24
|
+
"joi": "17.6.0",
|
|
25
|
+
"msgpack5": "6.0.1",
|
|
26
|
+
"pem-jwk": "2.0.0",
|
|
27
|
+
"pino": "8.1.0",
|
|
28
|
+
"punycode": "2.1.1",
|
|
29
|
+
"uuid": "8.3.2"
|
|
30
|
+
}
|
|
31
|
+
}
|