Skip to content

Commit 065727f

Browse files
author
Jamie Curnow
committed
Certificates polish
1 parent c859250 commit 065727f

File tree

15 files changed

+563
-256
lines changed

15 files changed

+563
-256
lines changed

src/backend/internal/certificate.js

Lines changed: 213 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,18 @@ const _ = require('lodash');
55
const error = require('../lib/error');
66
const certificateModel = require('../models/certificate');
77
const internalAuditLog = require('./audit-log');
8-
const internalHost = require('./host');
98
const tempWrite = require('temp-write');
109
const utils = require('../lib/utils');
10+
const moment = require('moment');
1111

1212
function omissions () {
1313
return ['is_deleted'];
1414
}
1515

1616
const internalCertificate = {
1717

18+
allowed_ssl_files: ['certificate', 'certificate_key', 'intermediate_certificate'],
19+
1820
/**
1921
* @param {Access} access
2022
* @param {Object} data
@@ -57,8 +59,39 @@ const internalCertificate = {
5759
update: (access, data) => {
5860
return access.can('certificates:update', data.id)
5961
.then(access_data => {
60-
// TODO
61-
return {};
62+
return internalCertificate.get(access, {id: data.id});
63+
})
64+
.then(row => {
65+
if (row.id !== data.id) {
66+
// Sanity check that something crazy hasn't happened
67+
throw new error.InternalValidationError('Certificate could not be updated, IDs do not match: ' + row.id + ' !== ' + data.id);
68+
}
69+
70+
return certificateModel
71+
.query()
72+
.omit(omissions())
73+
.patchAndFetchById(row.id, data)
74+
.debug()
75+
.then(saved_row => {
76+
saved_row.meta = internalCertificate.cleanMeta(saved_row.meta);
77+
data.meta = internalCertificate.cleanMeta(data.meta);
78+
79+
// Add row.nice_name for custom certs
80+
if (saved_row.provider === 'other') {
81+
data.nice_name = saved_row.nice_name;
82+
}
83+
84+
// Add to audit log
85+
return internalAuditLog.add(access, {
86+
action: 'updated',
87+
object_type: 'certificate',
88+
object_id: row.id,
89+
meta: _.omit(data, ['expires_on']) // this prevents json circular reference because expires_on might be raw
90+
})
91+
.then(() => {
92+
return _.omit(saved_row, omissions());
93+
});
94+
});
6295
});
6396
},
6497

@@ -113,10 +146,10 @@ const internalCertificate = {
113146
},
114147

115148
/**
116-
* @param {Access} access
117-
* @param {Object} data
118-
* @param {Integer} data.id
119-
* @param {String} [data.reason]
149+
* @param {Access} access
150+
* @param {Object} data
151+
* @param {Integer} data.id
152+
* @param {String} [data.reason]
120153
* @returns {Promise}
121154
*/
122155
delete: (access, data) => {
@@ -134,6 +167,17 @@ const internalCertificate = {
134167
.where('id', row.id)
135168
.patch({
136169
is_deleted: 1
170+
})
171+
.then(() => {
172+
// Add to audit log
173+
row.meta = internalCertificate.cleanMeta(row.meta);
174+
175+
return internalAuditLog.add(access, {
176+
action: 'deleted',
177+
object_type: 'certificate',
178+
object_id: row.id,
179+
meta: _.omit(row, omissions())
180+
});
137181
});
138182
})
139183
.then(() => {
@@ -204,19 +248,18 @@ const internalCertificate = {
204248

205249
/**
206250
* Validates that the certs provided are good.
207-
* This is probably a horrible way to do this.
251+
* No access required here, nothing is changed or stored.
208252
*
209-
* @param {Access} access
210253
* @param {Object} data
211254
* @param {Object} data.files
212255
* @returns {Promise}
213256
*/
214-
validate: (access, data) => {
257+
validate: data => {
215258
return new Promise(resolve => {
216259
// Put file contents into an object
217260
let files = {};
218261
_.map(data.files, (file, name) => {
219-
if (internalHost.allowed_ssl_files.indexOf(name) !== -1) {
262+
if (internalCertificate.allowed_ssl_files.indexOf(name) !== -1) {
220263
files[name] = file.data.toString();
221264
}
222265
});
@@ -228,56 +271,26 @@ const internalCertificate = {
228271
// Then test it depending on the file type
229272
let promises = [];
230273
_.map(files, (content, type) => {
231-
promises.push(tempWrite(content, '/tmp')
232-
.then(filepath => {
233-
if (type === 'certificate_key') {
234-
return utils.exec('openssl rsa -in ' + filepath + ' -check')
235-
.then(result => {
236-
return {tmp: filepath, result: result.split("\n").shift()};
237-
}).catch(err => {
238-
return {tmp: filepath, result: false, err: new error.ValidationError('Certificate Key is not valid')};
239-
});
240-
241-
} else if (type === 'certificate') {
242-
return utils.exec('openssl x509 -in ' + filepath + ' -text -noout')
243-
.then(result => {
244-
return {tmp: filepath, result: result};
245-
}).catch(err => {
246-
return {tmp: filepath, result: false, err: new error.ValidationError('Certificate is not valid')};
247-
});
248-
} else {
249-
return {tmp: filepath, result: false};
250-
}
251-
})
252-
.then(file_result => {
253-
// Remove temp files
254-
fs.unlinkSync(file_result.tmp);
255-
delete file_result.tmp;
256-
257-
return {[type]: file_result};
258-
})
259-
);
274+
promises.push(new Promise((resolve, reject) => {
275+
if (type === 'certificate_key') {
276+
resolve(internalCertificate.checkPrivateKey(content));
277+
} else {
278+
// this should handle `certificate` and intermediate certificate
279+
resolve(internalCertificate.getCertificateInfo(content, true));
280+
}
281+
}).then(res => {
282+
return {[type]: res};
283+
}));
260284
});
261285

262-
// With the results, delete the temp files for security mainly.
263-
// If there was an error with any of them, wait until we've done the deleting
264-
// before throwing it.
265286
return Promise.all(promises)
266287
.then(files => {
267288
let data = {};
268-
let err = null;
269289

270290
_.each(files, file => {
271291
data = _.assign({}, data, file);
272-
if (typeof file.err !== 'undefined' && file.err) {
273-
err = file.err;
274-
}
275292
});
276293

277-
if (err) {
278-
throw err;
279-
}
280-
281294
return data;
282295
});
283296
});
@@ -297,28 +310,159 @@ const internalCertificate = {
297310
throw new error.ValidationError('Cannot upload certificates for this type of provider');
298311
}
299312

300-
_.map(data.files, (file, name) => {
301-
if (internalHost.allowed_ssl_files.indexOf(name) !== -1) {
302-
row.meta[name] = file.data.toString();
303-
}
304-
});
313+
return internalCertificate.validate(data)
314+
.then(validations => {
315+
if (typeof validations.certificate === 'undefined') {
316+
throw new error.ValidationError('Certificate file was not provided');
317+
}
305318

306-
return internalCertificate.update(access, {
307-
id: data.id,
308-
meta: row.meta
309-
});
310-
})
311-
.then(row => {
312-
return internalAuditLog.add(access, {
313-
action: 'updated',
314-
object_type: 'certificate',
315-
object_id: row.id,
316-
meta: data
317-
})
319+
_.map(data.files, (file, name) => {
320+
if (internalCertificate.allowed_ssl_files.indexOf(name) !== -1) {
321+
row.meta[name] = file.data.toString();
322+
}
323+
});
324+
325+
return internalCertificate.update(access, {
326+
id: data.id,
327+
expires_on: certificateModel.raw('FROM_UNIXTIME(' + validations.certificate.dates.to + ')'),
328+
domain_names: [validations.certificate.cn],
329+
meta: row.meta
330+
});
331+
})
318332
.then(() => {
319-
return _.pick(row.meta, internalHost.allowed_ssl_files);
333+
return _.pick(row.meta, internalCertificate.allowed_ssl_files);
334+
});
335+
});
336+
},
337+
338+
/**
339+
* Uses the openssl command to validate the private key.
340+
* It will save the file to disk first, then run commands on it, then delete the file.
341+
*
342+
* @param {String} private_key This is the entire key contents as a string
343+
*/
344+
checkPrivateKey: private_key => {
345+
return tempWrite(private_key, '/tmp')
346+
.then(filepath => {
347+
return utils.exec('openssl rsa -in ' + filepath + ' -check -noout')
348+
.then(result => {
349+
if (!result.toLowerCase().includes('key ok')) {
350+
throw new error.ValidationError(result);
351+
}
352+
353+
fs.unlinkSync(filepath);
354+
return true;
355+
}).catch(err => {
356+
fs.unlinkSync(filepath);
357+
throw new error.ValidationError('Certificate Key is not valid (' + err.message + ')', err);
320358
});
321359
});
360+
},
361+
362+
/**
363+
* Uses the openssl command to both validate and get info out of the certificate.
364+
* It will save the file to disk first, then run commands on it, then delete the file.
365+
*
366+
* @param {String} certificate This is the entire cert contents as a string
367+
* @param {Boolean} [throw_expired] Throw when the certificate is out of date
368+
*/
369+
getCertificateInfo: (certificate, throw_expired) => {
370+
return tempWrite(certificate, '/tmp')
371+
.then(filepath => {
372+
let cert_data = {};
373+
374+
return utils.exec('openssl x509 -in ' + filepath + ' -subject -noout')
375+
.then(result => {
376+
// subject=CN = something.example.com
377+
let regex = /(?:subject=)?[^=]+=\s+(\S+)/gim;
378+
let match = regex.exec(result);
379+
380+
if (typeof match[1] === 'undefined') {
381+
throw new error.ValidationError('Could not determine subject from certificate: ' + result);
382+
}
383+
384+
cert_data['cn'] = match[1];
385+
})
386+
.then(() => {
387+
return utils.exec('openssl x509 -in ' + filepath + ' -issuer -noout');
388+
})
389+
.then(result => {
390+
// issuer=C = US, O = Let's Encrypt, CN = Let's Encrypt Authority X3
391+
let regex = /^(?:issuer=)?(.*)$/gim;
392+
let match = regex.exec(result);
393+
394+
if (typeof match[1] === 'undefined') {
395+
throw new error.ValidationError('Could not determine issuer from certificate: ' + result);
396+
}
397+
398+
cert_data['issuer'] = match[1];
399+
})
400+
.then(() => {
401+
return utils.exec('openssl x509 -in ' + filepath + ' -dates -noout');
402+
})
403+
.then(result => {
404+
// notBefore=Jul 14 04:04:29 2018 GMT
405+
// notAfter=Oct 12 04:04:29 2018 GMT
406+
let valid_from = null;
407+
let valid_to = null;
408+
409+
let lines = result.split('\n');
410+
lines.map(function (str) {
411+
let regex = /^(\S+)=(.*)$/gim;
412+
let match = regex.exec(str.trim());
413+
414+
if (match && typeof match[2] !== 'undefined') {
415+
let date = parseInt(moment(match[2], 'MMM DD HH:mm:ss YYYY z').format('X'), 10);
416+
417+
if (match[1].toLowerCase() === 'notbefore') {
418+
valid_from = date;
419+
} else if (match[1].toLowerCase() === 'notafter') {
420+
valid_to = date;
421+
}
422+
}
423+
});
424+
425+
if (!valid_from || !valid_to) {
426+
throw new error.ValidationError('Could not determine dates from certificate: ' + result);
427+
}
428+
429+
if (throw_expired && valid_to < parseInt(moment().format('X'), 10)) {
430+
throw new error.ValidationError('Certificate has expired');
431+
}
432+
433+
cert_data['dates'] = {
434+
from: valid_from,
435+
to: valid_to
436+
};
437+
})
438+
.then(() => {
439+
fs.unlinkSync(filepath);
440+
return cert_data;
441+
}).catch(err => {
442+
fs.unlinkSync(filepath);
443+
throw new error.ValidationError('Certificate is not valid (' + err.message + ')', err);
444+
});
445+
});
446+
},
447+
448+
/**
449+
* Cleans the ssl keys from the meta object and sets them to "true"
450+
*
451+
* @param {Object} meta
452+
* @param {Boolean} [remove]
453+
* @returns {Object}
454+
*/
455+
cleanMeta: function (meta, remove) {
456+
internalCertificate.allowed_ssl_files.map(key => {
457+
if (typeof meta[key] !== 'undefined' && meta[key]) {
458+
if (remove) {
459+
delete meta[key];
460+
} else {
461+
meta[key] = true;
462+
}
463+
}
464+
});
465+
return meta;
322466
}
323467
};
324468

0 commit comments

Comments
 (0)