diff --git a/.babelrc b/.babelrc deleted file mode 100644 index 1113850..0000000 --- a/.babelrc +++ /dev/null @@ -1,6 +0,0 @@ -{ - "plugins": [ - "transform-async-to-generator", - "transform-es2015-modules-commonjs", - ] -} diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..eaddb35 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,19 @@ +{ + "parser": "@typescript-eslint/parser", + "parserOptions": { + "project": "./tsconfig.json" + }, + "env": { + "node": true, + "es6": true, + "jest/globals": true + }, + "plugins": ["jest", "@typescript-eslint"], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended", + "prettier", + "prettier/@typescript-eslint" + ] +} diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..ced318d --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "arrowParens": "always", + "trailingComma": "all", + "singleQuote": true, + "htmlWhitespaceSensitivity": "ignore" +} diff --git a/.travis.yml b/.travis.yml index d65523e..a6a28b2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,21 @@ dist: bionic sudo: false language: node_js +cache: npm node_js: - "12" +install: + - npm ci + +script: + - npm run build + - npm run lint + +after_success: + - npm run semantic-release + addons: hosts: - db diff --git a/README.md b/README.md index a41f34d..0b5a042 100644 --- a/README.md +++ b/README.md @@ -97,3 +97,5 @@ To achieve a zero-downtime rotation from `oldKey` to `newKey`: ## License MIT + +Originally based on work by [defunctzombie](https://github.com/defunctzombie). diff --git a/index.js b/index.js deleted file mode 100644 index ca6d43e..0000000 --- a/index.js +++ /dev/null @@ -1,105 +0,0 @@ -var crypto = require('crypto'); - -function EncryptedField(Sequelize, key, opt) { - if (!(this instanceof EncryptedField)) { - return new EncryptedField(Sequelize, key, opt); - } - - var self = this; - - opt = opt || {}; - self._algorithm = opt.algorithm || 'aes-256-cbc'; - self._iv_length = opt.iv_length || 16; - self.encrypted_field_name = undefined; - - var extraDecryptionKeys = []; - if (opt.extraDecryptionKeys) { - extraDecryptionKeys = Array.isArray(opt.extraDecryptionKeys) ? - opt.extraDecryptionKeys : - Array(opt.extraDecryptionKeys); - } - self.decryptionKeys = ([key].concat(extraDecryptionKeys)) - .map(function (key) { - return new Buffer(key, 'hex'); - }); - self.encryptionKey = self.decryptionKeys[0]; - self.Sequelize = Sequelize; -}; - -EncryptedField.prototype.vault = function(name) { - var self = this; - - if (self.encrypted_field_name) { - throw new Error('vault already initialized'); - } - - self.encrypted_field_name = name; - - return { - type: self.Sequelize.BLOB, - get: function() { - var previous = this.getDataValue(name); - if (!previous) { - return {}; - } - - previous = new Buffer(previous); - - function decrypt(key) { - var iv = previous.slice(0, self._iv_length); - var content = previous.slice(self._iv_length, previous.length); - var decipher = crypto.createDecipheriv(self._algorithm, key, iv); - - var json = decipher.update(content, undefined, 'utf8') + decipher.final('utf8'); - return JSON.parse(json); - } - - var keyCount = self.decryptionKeys.length; - for (var i = 0; i < keyCount; i++) { - try { - return decrypt(self.decryptionKeys[i]); - } catch (error) { - if (i >= keyCount - 1) { - throw error; - } - } - } - }, - set: function(value) { - // if new data is set, we will use a new IV - var new_iv = crypto.randomBytes(self._iv_length); - - var cipher = crypto.createCipheriv(self._algorithm, self.encryptionKey, new_iv); - - cipher.end(JSON.stringify(value), 'utf-8'); - var enc_final = Buffer.concat([new_iv, cipher.read()]); - var previous = this.setDataValue(name, enc_final); - } - } -}; - -EncryptedField.prototype.field = function(name) { - var self = this; - - if (!self.encrypted_field_name) { - throw new Error('you must initialize the vault field before using encrypted fields'); - } - var encrypted_field_name = self.encrypted_field_name; - - return { - type: self.Sequelize.VIRTUAL, - set: function set_encrypted(val) { - // use `this` not self because we need to reference the sequelize instance - // not our EncryptedField instance - var encrypted = this[encrypted_field_name]; - encrypted[name] = val; - this[encrypted_field_name] = encrypted; - }, - get: function get_encrypted() { - var encrypted = this[encrypted_field_name]; - return encrypted[name]; - } - } -}; - -module.exports = EncryptedField; diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..1c5de73 --- /dev/null +++ b/index.ts @@ -0,0 +1,127 @@ +import * as crypto from 'crypto'; +import { + DataTypeAbstract, + DefineAttributeColumnOptions, + Instance, +} from 'sequelize'; + +type Key = string; + +export interface FieldOptions { + algorithm?: string; + iv_length?: number; + extraDecryptionKeys?: Key | Key[]; +} + +export interface SequelizeConstants { + BLOB: DataTypeAbstract; + VIRTUAL: DataTypeAbstract; +} + +export class EncryptedField { + _algorithm: string; + _iv_length: number; + encrypted_field_name: string | undefined = undefined; + encryptionKey: Buffer; + decryptionKeys: Buffer[]; + Sequelize: SequelizeConstants; + + constructor(Sequelize: SequelizeConstants, key: Key, opt?: FieldOptions) { + opt = opt || {}; + this._algorithm = opt.algorithm || 'aes-256-cbc'; + this._iv_length = opt.iv_length || 16; + + let extraDecryptionKeys: Key[] = []; + if (opt.extraDecryptionKeys) { + extraDecryptionKeys = Array.isArray(opt.extraDecryptionKeys) + ? opt.extraDecryptionKeys + : Array(opt.extraDecryptionKeys); + } + this.decryptionKeys = [key].concat(extraDecryptionKeys).map(function (key) { + return Buffer.from(key, 'hex'); + }); + this.encryptionKey = this.decryptionKeys[0]; + this.Sequelize = Sequelize; + } + + vault(name: string): DefineAttributeColumnOptions { + if (this.encrypted_field_name) { + throw new Error('vault already initialized'); + } + + this.encrypted_field_name = name; + + const self = this; + + return { + type: self.Sequelize.BLOB, + get: function (this: Instance) { + const stored: string | null = this.getDataValue(name); + if (!stored) { + return {}; + } + + const previous: Buffer = Buffer.from(stored); + + function decrypt(key: Buffer) { + const iv = previous.slice(0, self._iv_length); + const content = previous.slice(self._iv_length, previous.length); + const decipher = crypto.createDecipheriv(self._algorithm, key, iv); + + const json = + decipher.update(content, undefined, 'utf8') + + decipher.final('utf8'); + return JSON.parse(json); + } + + const keyCount = self.decryptionKeys.length; + for (let i = 0; i < keyCount; i++) { + try { + return decrypt(self.decryptionKeys[i]); + } catch (error) { + if (i >= keyCount - 1) { + throw error; + } + } + } + }, + set: function (this: Instance, value: any) { + // if new data is set, we will use a new IV + const new_iv = crypto.randomBytes(self._iv_length); + + const cipher = crypto.createCipheriv( + self._algorithm, + self.encryptionKey, + new_iv, + ); + + cipher.end(JSON.stringify(value), 'utf-8'); + const enc_final = Buffer.concat([new_iv, cipher.read()]); + this.setDataValue(name, enc_final); + }, + }; + } + + field(name: string): DefineAttributeColumnOptions { + if (!this.encrypted_field_name) { + throw new Error( + 'you must initialize the vault field before using encrypted fields', + ); + } + + const encrypted_field_name = this.encrypted_field_name; + + return { + type: this.Sequelize.VIRTUAL, + set: function (this: any, val) { + // the proxying breaks if you don't use this local + const encrypted = this[encrypted_field_name]; + encrypted[name] = val; + this[encrypted_field_name] = encrypted; + }, + get: function (this: any) { + return this[encrypted_field_name][name]; + }, + }; + } +} diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..234bcc4 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,10 @@ +module.exports = { + testEnvironment: 'node', + transform: { + '^.+\\.(ts)?$': 'ts-jest', + }, + testRegex: '(/test/.*|(\\.|/)(spec.jest))(?", + "author": "snyk.io", "license": "MIT", "bugs": { - "url": "https://github.com/defunctzombie/sequelize-encrypted/issues" + "url": "https://github.com/snyk/sequelize-encrypted/issues" }, - "homepage": "https://github.com/defunctzombie/sequelize-encrypted", + "homepage": "https://github.com/snyk/sequelize-encrypted", "devDependencies": { - "babel-plugin-transform-async-to-generator": "6.8.0", - "babel-plugin-transform-es2015-modules-commonjs": "6.10.3", - "babel-polyfill": "6.9.1", - "babel-preset-es2015": "6.9.0", - "babel-register": "6.9.0", - "mocha": "2.0.1", - "pg": "6.0.1", - "sequelize": "3.23.4" + "@types/jest": "^25.2.1", + "@types/node": "^12.12.36", + "@types/sequelize": "^3.30.13", + "@typescript-eslint/eslint-plugin": "^2.28.0", + "@typescript-eslint/parser": "^2.28.0", + "eslint": "^6.8.0", + "eslint-config-prettier": "^6.10.1", + "eslint-plugin-jest": "^23.8.2", + "jest": "^25.3.0", + "pg": "^6.4.2", + "semantic-release": "^17.0.6", + "sequelize": "^3.35.1", + "ts-jest": "^25.4.0", + "typescript": "^3.8.3" + }, + "publishConfig": { + "access": "restricted" } } diff --git a/test/index.js b/test/index.js deleted file mode 100644 index 7a36feb..0000000 --- a/test/index.js +++ /dev/null @@ -1,120 +0,0 @@ -import assert from 'assert'; -import Sequelize from 'sequelize'; -import EncryptedField from '../'; - -const sequelize = new Sequelize('postgres://postgres@db:5432/postgres'); - -const key1 = 'a593e7f567d01031d153b5af6d9a25766b95926cff91c6be3438c7f7ac37230e'; -const key2 = 'a593e7f567d01031d153b5af6d9a25766b95926cff91c6be3438c7f7ac37230f'; - -const v1 = EncryptedField(Sequelize, key1); -const v2 = EncryptedField(Sequelize, key2); - -describe('sequelize-encrypted', () => { - - const User = sequelize.define('user', { - name: Sequelize.STRING, - encrypted: v1.vault('encrypted'), - another_encrypted: v2.vault('another_encrypted'), - - // encrypted virtual fields - private_1: v1.field('private_1'), - private_2: v2.field('private_2'), - }); - - before('create models', async () => { - await User.sync({force: true}); - }); - - it('should save an encrypted field', async () => { - const user = User.build(); - user.private_1 = 'test'; - - await user.save(); - const found = await User.findById(user.id); - assert.equal(found.private_1, user.private_1); - }); - - it('should support multiple encrypted fields', async() => { - const user = User.build(); - user.private_1 = 'baz'; - user.private_2 = 'foobar'; - await user.save(); - - const vault = EncryptedField(Sequelize, key2); - - const AnotherUser = sequelize.define('user', { - name: Sequelize.STRING, - another_encrypted: vault.vault('another_encrypted'), - private_2: vault.field('private_2'), - private_1: vault.field('private_1'), - }); - - const found = await AnotherUser.findById(user.id); - assert.equal(found.private_2, user.private_2); - - // encrypted with key1 and different field originally - // and thus can't be recovered with key2 - assert.equal(found.private_1, undefined); - }); - - it('should throw error on decryption using invalid key', async() => { - // attempt to use key2 for vault encrypted with key1 - const badEncryptedField = EncryptedField(Sequelize, key2); - const BadEncryptionUser = sequelize.define('user', { - name: Sequelize.STRING, - encrypted: badEncryptedField.vault('encrypted'), - private_1: badEncryptedField.field('private_1'), - }); - - const model = User.build(); - model.private_1 = 'secret!'; - await model.save(); - - let threw; - try { - const found = await BadEncryptionUser.findById(model.id) - found.private_1; // trigger decryption - } catch (error) { - threw = error; - } - - assert.ok(threw && /bad decrypt$/.test(threw.message), - 'should have thrown decryption error'); - }); - - it('should support extra decryption keys (to facilitate key rotation)', async() => { - const keyOneEncryptedField = EncryptedField(Sequelize, key1); - const keyTwoAndOneEncryptedField = EncryptedField(Sequelize, key2, { - extraDecryptionKeys: [key1], - }); - - // models both access the same table, with different encryption keys - const KeyOneModel = sequelize.define('rotateMe', { - encrypted: keyOneEncryptedField.vault('encrypted'), - private: keyOneEncryptedField.field('private'), - }); - const KeyTwoAndOneModel = sequelize.define('rotateMe', { - encrypted: keyTwoAndOneEncryptedField.vault('encrypted'), - private: keyTwoAndOneEncryptedField.field('private'), - }); - - await KeyOneModel.sync({force: true}); - - const modelUsingKeyOne = KeyOneModel.build(); - const modelUsingKeyTwo = KeyTwoAndOneModel.build(); - modelUsingKeyOne.private = 'secret!'; - modelUsingKeyTwo.private = 'also secret!'; - await Promise.all([ - modelUsingKeyOne.save(), - modelUsingKeyTwo.save(), - ]); - - // note: both sets of data accessed via KeyTwoAndOneModel - const foundFromKeyOne = await KeyTwoAndOneModel.findById(modelUsingKeyOne.id); - const foundFromKeyTwo = await KeyTwoAndOneModel.findById(modelUsingKeyTwo.id); - - assert.equal(foundFromKeyOne.private, modelUsingKeyOne.private); - assert.equal(foundFromKeyTwo.private, modelUsingKeyTwo.private); - }); -}); diff --git a/test/mocha.opts b/test/mocha.opts deleted file mode 100644 index 8df2fe0..0000000 --- a/test/mocha.opts +++ /dev/null @@ -1,5 +0,0 @@ ---reporter spec ---check-leaks ---bail ---compilers js:babel-register ---require babel-polyfill diff --git a/test/smoke.test.ts b/test/smoke.test.ts new file mode 100644 index 0000000..81bdb93 --- /dev/null +++ b/test/smoke.test.ts @@ -0,0 +1,121 @@ +import * as Sequelize from 'sequelize'; +import { EncryptedField } from '../index'; + +const dbHost = process.env.DB_HOST || 'db'; +const sequelize = new Sequelize(`postgres://postgres@${dbHost}:5432/postgres`); + +const key1 = 'a593e7f567d01031d153b5af6d9a25766b95926cff91c6be3438c7f7ac37230e'; +const key2 = 'a593e7f567d01031d153b5af6d9a25766b95926cff91c6be3438c7f7ac37230f'; + +const v1 = new EncryptedField(Sequelize, key1); +const v2 = new EncryptedField(Sequelize, key2); + +const User = sequelize.define('user', { + name: Sequelize.STRING, + encrypted: v1.vault('encrypted'), + another_encrypted: v2.vault('another_encrypted'), + + // encrypted virtual fields + private_1: v1.field('private_1'), + private_2: v2.field('private_2'), +}); + +beforeAll(async () => { + await User.sync({ force: true }); +}); + +afterAll(async () => { + sequelize.close(); +}); + +test('should save an encrypted field', async () => { + const user: any = User.build(); + user.private_1 = 'test'; + + await user.save(); + const found: any = await User.findById(user.id); + expect(found.private_1).toEqual(user.private_1); +}); + +test('should support multiple encrypted fields', async () => { + const user: any = User.build(); + user.private_1 = 'baz'; + user.private_2 = 'foobar'; + await user.save(); + + const vault = new EncryptedField(Sequelize, key2); + + const AnotherUser = sequelize.define('user', { + name: Sequelize.STRING, + another_encrypted: vault.vault('another_encrypted'), + private_2: vault.field('private_2'), + private_1: vault.field('private_1'), + }); + + const found: any = await AnotherUser.findById(user.id); + expect(found.private_2).toEqual(user.private_2); + + // encrypted with key1 and different field originally + // and thus can't be recovered with key2 + expect(found.private_1).toBeUndefined(); +}); + +test('should throw error on decryption using invalid key', async () => { + // attempt to use key2 for vault encrypted with key1 + const badEncryptedField = new EncryptedField(Sequelize, key2); + const BadEncryptionUser = sequelize.define('user', { + name: Sequelize.STRING, + encrypted: badEncryptedField.vault('encrypted'), + private_1: badEncryptedField.field('private_1'), + }); + + const model: any = User.build(); + model.private_1 = 'secret!'; + await model.save(); + + let threw; + try { + const found: any = await BadEncryptionUser.findById(model.id); + found.private_1; // trigger decryption + } catch (error) { + threw = error; + } + + expect(threw && /bad decrypt$/.test(threw.message)).toBeTruthy(); +}); + +test('should support extra decryption keys (to facilitate key rotation)', async () => { + const keyOneEncryptedField = new EncryptedField(Sequelize, key1); + const keyTwoAndOneEncryptedField = new EncryptedField(Sequelize, key2, { + extraDecryptionKeys: [key1], + }); + + // models both access the same table, with different encryption keys + const KeyOneModel = sequelize.define('rotateMe', { + encrypted: keyOneEncryptedField.vault('encrypted'), + private: keyOneEncryptedField.field('private'), + }); + const KeyTwoAndOneModel = sequelize.define('rotateMe', { + encrypted: keyTwoAndOneEncryptedField.vault('encrypted'), + private: keyTwoAndOneEncryptedField.field('private'), + }); + + await KeyOneModel.sync({ force: true }); + + const modelUsingKeyOne: any = KeyOneModel.build(); + const modelUsingKeyTwo: any = KeyTwoAndOneModel.build(); + modelUsingKeyOne.private = 'secret!'; + modelUsingKeyTwo.private = 'also secret!'; + await Promise.all([modelUsingKeyOne.save(), modelUsingKeyTwo.save()]); + + // note: both sets of data accessed via KeyTwoAndOneModel + const foundFromKeyOne: any = await KeyTwoAndOneModel.findById( + modelUsingKeyOne.id, + ); + const foundFromKeyTwo: any = await KeyTwoAndOneModel.findById( + modelUsingKeyTwo.id, + ); + + expect(foundFromKeyOne.private).toEqual(modelUsingKeyOne.private); + expect(foundFromKeyTwo.private).toEqual(modelUsingKeyTwo.private); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5920d6b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "outDir": "./dist", + "target": "es2017", + "module": "commonjs", + "declaration": true, + "sourceMap": true, + "pretty": true, + "importHelpers": true, + "strict": true, + "noImplicitAny": true, + "noUnusedLocals": true, + "noImplicitReturns": true + }, + "include": [ + "./*" + ] +}