@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 ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "rules": {
3
+ "indent": 0,
4
+ "no-await-in-loop": 0,
5
+ "require-atomic-updates": 0
6
+ },
7
+ "globals": {
8
+ "BigInt": true
9
+ },
10
+ "extends": ["nodemailer", "prettier"],
11
+ "parserOptions": {
12
+ "ecmaVersion": 2018,
13
+ "sourceType": "script"
14
+ }
15
+ }
package/.prettierrc.js ADDED
@@ -0,0 +1,8 @@
1
+ module.exports = {
2
+ printWidth: 160,
3
+ tabWidth: 4,
4
+ singleQuote: true,
5
+ endOfLine: 'lf',
6
+ trailingComma: 'none',
7
+ arrowParens: 'avoid'
8
+ };
@@ -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 };
@@ -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
+ }