UNPKG

mongoose-encryption

Version:

Simple encryption and authentication plugin for Mongoose

453 lines (367 loc) 17 kB
'use strict'; var crypto = require('crypto'); var _ = require('underscore'); var mongoose = require('mongoose'); var stableStringify = require('json-stable-stringify'); var async = require('async'); var dotty = require('dotty'); var bufferEqual = require('buffer-equal-constant-time'); var mpath = require('mpath'); var semver = require('semver'); var objectUtil = require('../util/object-util.js'); var cryptoUtil = require('../util/crypto-util.js'); var decryptEmbeddedDocs = require('../util/decryptEmbeddedDocs.js'); var pick = objectUtil.pick; var setFieldValue = objectUtil.setFieldValue; var isEmbeddedDocument = objectUtil.isEmbeddedDocument; var drop256 = cryptoUtil.drop256; var deriveKey = cryptoUtil.deriveKey; /** Plugin Constants */ var VERSION = 'a'; var ENCRYPTION_ALGORITHM = 'aes-256-cbc'; var IV_LENGTH = 16; var AAC_LENGTH = 32; var VERSION_LENGTH = 1; var VERSION_BUF = new Buffer(VERSION); if(semver.lt(mongoose.version, '5.0.0')){ throw new Error('Mongoose version 5.0.0 or greater is required'); } /** * Mongoose encryption plugin * @module mongoose-encryption * * * @param {Object} schema The schema * @param {Object} options Plugin options * @param {string} [options.secret] A secret string which will be used to generate an encryption key and a signing key * @param {string} [options.encryptionKey] A secret string which will be used to generate an encryption key * @param {string} [options.signingKey] A secret string which will be used to generate a signing key * @param {string[]} [options.encryptedFields] A list of fields to encrypt. Default is to encrypt all fields. * @param {string[]} [options.excludeFromEncryption] A list of fields to not encrypt * @param {string[]} [options.additionalAuthenticatedFields] A list of fields to authenticate even if they aren't encrypted * @param {boolean} [options.requireAuthenticationCode=true] Whether documents without an authentication code are valid * @param {boolean} [options.decryptPostSave=true] Whether to automatically decrypt documents in the application after saving them (faster if false) * @param {string} [options.collectionId] If you update the Model name of the schema, this should be set to its original name * @return {undefined} */ var mongooseEncryption = function(schema, options) { var encryptedFields, excludedFields, authenticatedFields, encryptionKey, signingKey, path; _.defaults(options, { middleware: true, // allow for skipping middleware with false requireAuthenticationCode: true, // allow for no authentication code on docs (not recommended), decryptPostSave: true, // allow for skipping the decryption after save for improved performance _suppressDuplicatePluginError: false // used for testing only }); /** Encryption Keys */ if (options.secret) { if (options.encryptionKey || options.signingKey) { throw new Error('if options.secret is used, then options.encryptionKey and options.signingKey must not be included'); } else { encryptionKey = drop256(deriveKey(options.secret, 'enc')); signingKey = deriveKey(options.secret, 'sig'); } } else { if (!options.encryptionKey || !options.signingKey) { throw new Error('must provide either options.secret or both options.encryptionKey and options.signingKey'); } else { encryptionKey = new Buffer(options.encryptionKey, 'base64'); if (encryptionKey.length !== 32) { throw new Error('options.encryptionKey must be a a 32 byte base64 string'); } signingKey = new Buffer(options.signingKey, 'base64'); if (signingKey.length !== 64) { throw new Error('options.signingKey must be a a 64 byte base64 string'); } } } /** Deprecated options */ if (options.fields) { options.encryptedFields = options.fields; console.warn('options.fields has been deprecated. please use options.encryptedFields'); } if (options.exclude) { options.excludeFromEncryption = options.exclude; console.warn('options.fields has been deprecated. please use options.excludeFromEncryption'); } /** Encryption Options */ if (options.encryptedFields) { encryptedFields = _.difference(options.encryptedFields, ['_ct']); } else { excludedFields = _.union(['_id', '_ct'], options.excludeFromEncryption); encryptedFields = _.chain(schema.paths) .filter(function(pathDetails) { // exclude indexed fields return !pathDetails._index }) .pluck('path') // get path name .difference(excludedFields) // exclude excluded fields .uniq() .value(); } /** Authentication Options */ var baselineAuthenticateFields = ['_id', '_ct']; if (options.additionalAuthenticatedFields) { authenticatedFields = _.union(options.additionalAuthenticatedFields, baselineAuthenticateFields); } else { authenticatedFields = baselineAuthenticateFields; } /** Augment Schema */ if (!schema.paths._ct) { // ciphertext schema.add({ _ct: { type: Buffer } }); } if (!schema.paths._ac) { // authentication code schema.add({ _ac: { type: Buffer } }); } /** Authentication Functions */ var computeAC = function(doc, fields, version, modelName) { // HMAC-SHA512-drop-256 var hmac = crypto.createHmac('sha512', signingKey); if (!(fields instanceof Array)){ throw new Error('fields must be an array'); } if (fields.indexOf('_id') === -1) { throw new Error('_id must be in array of fields to authenticate'); } if (fields.indexOf('_ac') !== -1) { throw new Error('_ac cannot be in array of fields to authenticate'); } var collectionId = options.collectionId || modelName || doc.constructor.modelName; if (!collectionId) { throw new Error('For authentication, each collection must have a unique id. This is normally the model name when there is one, but can be overridden or added by options.collectionId'); } // convert to regular object if possible in order to convert to the eventual mongo form which may be different than mongoose form // and only pick fields that will be authenticated var objectToAuthenticate = pick((doc.toObject ? doc.toObject() : doc), fields); var stringToAuthenticate = stableStringify(objectToAuthenticate); hmac.update(collectionId); hmac.update(version); hmac.update(stringToAuthenticate); hmac.update(JSON.stringify(fields)); var fullAuthenticationBuffer = new Buffer(hmac.digest()); return drop256(fullAuthenticationBuffer); }; /** Functions To Check If Authenticated Fields Were Selected By Query */ var authenticationFieldsToCheck = _.chain(authenticatedFields).union(['_ac']).without('_id').value(); // _id is implicitly selected var authenticatedFieldsIsSelected = function(doc){ return _.map(authenticationFieldsToCheck, function(field) { return doc.isSelected(field); }); }; var allAuthenticationFieldsSelected = function(doc){ var isSelected = authenticatedFieldsIsSelected(doc); if (_.uniq(isSelected).length === 1){ return isSelected[0]; } else { return false; } }; var noAuthenticationFieldsSelected = function(doc){ var isSelected = authenticatedFieldsIsSelected(doc); if (_.uniq(isSelected).length === 1){ return isSelected[0] === false; } else { return false; } }; /** Ensure plugin only added once per schema */ if(schema.statics._mongooseEncryptionInstalled){ if(!options._suppressDuplicatePluginError){ throw new Error( 'Mongoose encryption plugin can only be added once per schema.\n\n' + 'If you are running migrations, please remove encryption middleware first. ' + 'Migrations should be run in a script where `encrypt.migrations` is added to the schema, '+ 'however the standard `encrypt` middleware should not be present at the same time. ' ); } } else { schema.statics._mongooseEncryptionInstalled = true; } /** Middleware */ if (options.middleware) { // defaults to true schema.pre('init', function(data) { var err = null; try { // this hook must be synchronous for embedded docs, so everything is synchronous for code simplicity if (!isEmbeddedDocument(this)){ // don't authenticate embedded docs because there's no way to handle the error appropriately if (allAuthenticationFieldsSelected(this)) { this.authenticateSync.call(data, this.constructor.modelName); } else { if (!noAuthenticationFieldsSelected(this)){ throw new Error("Authentication failed: Only some authenticated fields were selected by the query. Either all or none of the authenticated fields (" + authenticationFieldsToCheck + ") should be selected for proper authentication."); } } } if (this.isSelected('_ct')){ this.decryptSync.call(data); } } catch (e) { err = e; } if (isEmbeddedDocument(this)) { if (err) { console.error(err); throw err; // note: this won't actually get thrown until save, because errors in subdoc init fns are CastErrors and aren't thrown by validate() } return this; } else { if (err) throw err; } }); schema.pre('save', function(next) { var that = this; if (this.isNew || this.isSelected('_ct') ){ that.encrypt(function(err){ if (err) { next(err); } else { if ((that.isNew || allAuthenticationFieldsSelected(that)) && !isEmbeddedDocument(that)) { _.forEach(authenticatedFields, function(authenticatedField){ that.markModified(authenticatedField) }); that.sign(next); } else { next(); } } }); } else if (allAuthenticationFieldsSelected(this) && !isEmbeddedDocument(this)) { // _ct is not selected but all authenticated fields are. cannot get hit in current version. _.forEach(authenticatedFields, function(authenticatedField){ that.markModified(authenticatedField) }); this.sign(next); } else { next(); } }); if (options.decryptPostSave) { // true by default schema.post('save', function(doc) { if (isEmbeddedDocument(doc)) { return doc; } if (_.isFunction(doc.decryptSync)) { doc.decryptSync(); } decryptEmbeddedDocs(doc); return doc; }); } } /** Encryption Instance Methods */ schema.methods.encrypt = function(cb) { var that = this; if(this._ct){ return cb(new Error('Encrypt failed: document already contains ciphertext')); } // generate random iv crypto.randomBytes(IV_LENGTH, function(err, iv) { var cipher, jsonToEncrypt, objectToEncrypt; if (err) { return cb(err); } cipher = crypto.createCipheriv(ENCRYPTION_ALGORITHM, encryptionKey, iv); objectToEncrypt = pick(that, encryptedFields, {excludeUndefinedValues: true}); jsonToEncrypt = JSON.stringify(objectToEncrypt); cipher.write(jsonToEncrypt); cipher.end(); var chunk, chunks = []; while ((chunk = cipher.read()) !== null) { chunks.push(chunk); } var encrypted = Buffer.concat(chunks); // add ciphertext to document that._ct = Buffer.concat([VERSION_BUF, iv, Buffer.from(encrypted, 'utf8')]); // remove encrypted fields from cleartext encryptedFields.forEach(function(field){ setFieldValue(that, field, undefined); }); cb(null); }); }; schema.methods.decrypt = function(cb) { // callback style but actually synchronous to allow for decryptSync without copypasta or complication try { schema.methods.decryptSync.call(this); } catch(e){ return cb(e); } cb(); }; schema.methods.decryptSync = function() { var that = this; var ct, ctWithIV, decipher, iv, idString, decryptedObject, decryptedObjectJSON, decipheredVal; if (this._ct) { ctWithIV = this._ct.hasOwnProperty('buffer') ? this._ct.buffer : this._ct; iv = ctWithIV.slice(VERSION_LENGTH, VERSION_LENGTH + IV_LENGTH); ct = ctWithIV.slice(VERSION_LENGTH + IV_LENGTH, ctWithIV.length); decipher = crypto.createDecipheriv(ENCRYPTION_ALGORITHM, encryptionKey, iv); try { decryptedObjectJSON = decipher.update(ct, undefined, 'utf8') + decipher.final('utf8'); decryptedObject = JSON.parse(decryptedObjectJSON); } catch (err) { if (this._id) { idString = this._id.toString(); } else { idString = 'unknown'; } throw new Error('Error parsing JSON during decrypt of ' + idString + ': ' + err); } encryptedFields.forEach(function(field) { decipheredVal = mpath.get(field, decryptedObject); //JSON.parse returns {type: "Buffer", data: Buffer} for Buffers //https://nodejs.org/api/buffer.html#buffer_buf_tojson if(_.isObject(decipheredVal) && decipheredVal.type === "Buffer"){ setFieldValue(that, field, decipheredVal.data); }else { setFieldValue(that, field, decipheredVal); } }); this._ct = undefined; this._ac = undefined; } }; /** Authentication Instance Methods */ schema.methods.sign = function(cb) { var basicAC = computeAC(this, authenticatedFields, VERSION); var authenticatedFieldsBuf = new Buffer(JSON.stringify(authenticatedFields)); this._ac = Buffer.concat([VERSION_BUF, basicAC, authenticatedFieldsBuf]); cb(); }; schema.methods.authenticate = function(cb) { // callback style but actually synchronous to allow for decryptSync without copypasta or complication try { schema.methods.authenticateSync.call(this); } catch(e){ return cb(e); } cb(); }; schema.methods.authenticateSync = function() { if (!this._ac) { if (options.requireAuthenticationCode) { throw new Error('Authentication code missing'); } else { return null; } } var acBuf = this._ac.hasOwnProperty('buffer') ? this._ac.buffer : this._ac; if (acBuf.length < VERSION_LENGTH + AAC_LENGTH + 2) { throw new Error('_ac is too short and has likely been cut off or modified'); } var versionUsed = acBuf.slice(0, VERSION_LENGTH).toString(); var basicAC = acBuf.slice(VERSION_LENGTH, VERSION_LENGTH + AAC_LENGTH); var authenticatedFieldsUsed = JSON.parse(acBuf.slice(VERSION_LENGTH + AAC_LENGTH, acBuf.length).toString()); var expectedHMAC = computeAC(this, authenticatedFieldsUsed, versionUsed, arguments[0]); // pass in modelName as argument in init hook var authentic = bufferEqual(basicAC, expectedHMAC); if (!authentic){ throw new Error('Authentication failed'); } }; }; module.exports = mongooseEncryption; // Exports For Tests // module.exports.AAC_LENGTH = AAC_LENGTH; module.exports.VERSION_LENGTH = VERSION_LENGTH;