From b8783ad31d2f9f2c57fe557c404ca6476feb72a4 Mon Sep 17 00:00:00 2001 From: vitu-77 Date: Wed, 25 May 2022 14:33:23 -0300 Subject: [PATCH 01/12] feat: Custom encrypt function feature --- src/encryption.test.ts | 38 +++++++++++++++++++++++++++++++++++++- src/encryption.ts | 25 ++++++++++++++++++++----- src/traverseTree.ts | 3 +++ src/types.ts | 6 ++++++ src/visitor.ts | 5 ++++- 5 files changed, 70 insertions(+), 7 deletions(-) diff --git a/src/encryption.test.ts b/src/encryption.test.ts index 7cf5006..a7284bd 100644 --- a/src/encryption.test.ts +++ b/src/encryption.test.ts @@ -1,9 +1,31 @@ import { formatKey } from '@47ng/cloak/dist/key' -import { configureKeys } from './encryption' +import { DMMFModels } from 'dmmf' +import { MiddlewareParams } from 'types' +import { configureKeys, encryptOnWrite, KeysConfiguration } from './encryption' import { errors } from './errors' const TEST_KEY = 'k1.aesgcm256.DbQoar8ZLuUsOHZNyrnjlskInHDYlzF3q6y1KGM7DUM=' +const encryptFunction = jest.fn( + (decripted: string) => `fake-encription-${decripted}` +) +const decryptFunction = jest.fn( + (encripted: string) => `fake-decription-${encripted}` +) +const fakeKeys: KeysConfiguration = { + encryptionKey: 'fake-encryptionKey', + keychain: 'fake-keychain' +} as any +const fakeParams: MiddlewareParams = { + action: 'create', + args: { data: { any: 'field' } }, + dataPath: ['any'], + runInTransaction: true, + model: 'User' +} +const fakeModels: DMMFModels = { User: null } as any +const fakeOperation = 'User.create' + describe('encryption', () => { describe('configureKeys', () => { test('No encryption key specified', () => { @@ -56,4 +78,18 @@ describe('encryption', () => { process.env.PRISMA_FIELD_DECRYPTION_KEYS = undefined }) }) + + describe('encryptOnWrite', () => { + test('Should call custom encrypt function', () => { + encryptOnWrite( + fakeParams, + fakeKeys, + fakeModels, + fakeOperation, + encryptFunction + ) + + expect(encryptFunction).toBeCalledTimes(1) + }) + }) }) diff --git a/src/encryption.ts b/src/encryption.ts index dd25205..dc12902 100644 --- a/src/encryption.ts +++ b/src/encryption.ts @@ -12,7 +12,12 @@ import produce, { Draft } from 'immer' import objectPath from 'object-path' import type { DMMFModels } from './dmmf' import { errors, warnings } from './errors' -import type { Configuration, MiddlewareParams } from './types' +import type { + Configuration, + MiddlewareParams, + EncryptionFunction, + DecryptionFunction +} from './types' import { visitInputTargetFields, visitOutputTargetFields } from './visitor' export interface KeysConfiguration { @@ -63,7 +68,8 @@ export function encryptOnWrite( params: MiddlewareParams, keys: KeysConfiguration, models: DMMFModels, - operation: string + operation: string, + cb?: EncryptionFunction ) { if (!writeOperations.includes(params.action)) { return params // No input data to encrypt @@ -89,7 +95,11 @@ export function encryptOnWrite( console.warn(warnings.whereClause(operation, path)) } try { - const cipherText = encryptStringSync(clearText, keys.encryptionKey) + const cipherText = + cb !== undefined + ? cb(clearText) + : encryptStringSync(clearText, keys.encryptionKey) + objectPath.set(draft.args, path, cipherText) } catch (error) { encryptionErrors.push( @@ -110,7 +120,8 @@ export function decryptOnRead( result: any, keys: KeysConfiguration, models: DMMFModels, - operation: string + operation: string, + cb?: DecryptionFunction ) { // Analyse the query to see if there's anything to decrypt. const model = models[params.model!] @@ -141,7 +152,11 @@ export function decryptOnRead( return } const decryptionKey = findKeyForMessage(cipherText, keys.keychain) - const clearText = decryptStringSync(cipherText, decryptionKey) + const clearText = + cb !== undefined + ? cb(cipherText) + : decryptStringSync(cipherText, decryptionKey) + objectPath.set(result, path, clearText) } catch (error) { const message = errors.fieldDecryptionError(model, field, path, error) diff --git a/src/traverseTree.ts b/src/traverseTree.ts index a106cc9..65e68f0 100644 --- a/src/traverseTree.ts +++ b/src/traverseTree.ts @@ -43,6 +43,9 @@ export function traverseTree( while (stack.length > 0) { const { state, ...item } = stack.shift()! + + console.log('CALLBACK') + const newState = callback(state, item) if (!isCollection(item.node)) { continue diff --git a/src/types.ts b/src/types.ts index b6a641d..bf57c75 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,9 +8,15 @@ export type DMMF = typeof Prisma.dmmf // Internal types -- +export type EncryptionFunction = (value: string) => string + +export type DecryptionFunction = (value: string) => string + export interface Configuration { encryptionKey?: string decryptionKeys?: string[] + encryptionFn?: EncryptionFunction + decryptionFn?: DecryptionFunction } export interface FieldConfiguration { diff --git a/src/visitor.ts b/src/visitor.ts index 7432b4c..8f9799f 100644 --- a/src/visitor.ts +++ b/src/visitor.ts @@ -16,7 +16,10 @@ export interface TargetField { export type TargetFieldVisitorFn = (targetField: TargetField) => void -const makeVisitor = (models: DMMFModels, visitor: TargetFieldVisitorFn) => +export const makeVisitor = ( + models: DMMFModels, + visitor: TargetFieldVisitorFn +) => function visitNode(state: VisitorState, { key, type, node, path }: Item) { const model = models[state.currentModel] if (!model || !key) { From 16693931956eb67a20e7c83581eb9bd3faa86dbb Mon Sep 17 00:00:00 2001 From: Gustavo Pedroni Date: Wed, 25 May 2022 15:06:37 -0300 Subject: [PATCH 02/12] feat: adding add to custom callback function --- src/encryption.test.ts | 91 +++++++++++++++++++++++------------------- src/encryption.ts | 16 ++++---- src/traverseTree.ts | 2 - src/types.ts | 8 ++-- 4 files changed, 62 insertions(+), 55 deletions(-) diff --git a/src/encryption.test.ts b/src/encryption.test.ts index a7284bd..0d75f0f 100644 --- a/src/encryption.test.ts +++ b/src/encryption.test.ts @@ -4,27 +4,11 @@ import { MiddlewareParams } from 'types' import { configureKeys, encryptOnWrite, KeysConfiguration } from './encryption' import { errors } from './errors' -const TEST_KEY = 'k1.aesgcm256.DbQoar8ZLuUsOHZNyrnjlskInHDYlzF3q6y1KGM7DUM=' - -const encryptFunction = jest.fn( - (decripted: string) => `fake-encription-${decripted}` -) -const decryptFunction = jest.fn( - (encripted: string) => `fake-decription-${encripted}` -) -const fakeKeys: KeysConfiguration = { - encryptionKey: 'fake-encryptionKey', - keychain: 'fake-keychain' -} as any -const fakeParams: MiddlewareParams = { - action: 'create', - args: { data: { any: 'field' } }, - dataPath: ['any'], - runInTransaction: true, - model: 'User' -} -const fakeModels: DMMFModels = { User: null } as any -const fakeOperation = 'User.create' +const ENCRYPTION_TEST_KEY = 'k1.aesgcm256.DbQoar8ZLuUsOHZNyrnjlskInHDYlzF3q6y1KGM7DUM=' +const DECRYPTION_TEST_KEY = [ + 'k1.aesgcm256.4BNYdJnjOQJP2adq9cGM9kb4dZxDujUs6aPS0VeRtAM=', + 'k1.aesgcm256.El9unG7WBAVRQdATOyMggE3XrLV2ZjTGKdajfmIeBPs=' +] describe('encryption', () => { describe('configureKeys', () => { @@ -35,44 +19,39 @@ describe('encryption', () => { test('Providing encryptionKey directly', () => { const { encryptionKey } = configureKeys({ - encryptionKey: TEST_KEY + encryptionKey: ENCRYPTION_TEST_KEY }) - expect(formatKey(encryptionKey.raw as Uint8Array)).toEqual(TEST_KEY) + + expect(formatKey(encryptionKey.raw as Uint8Array)).toEqual(ENCRYPTION_TEST_KEY) }) test('Providing encryptionKey via the environment', () => { - process.env.PRISMA_FIELD_ENCRYPTION_KEY = TEST_KEY + process.env.PRISMA_FIELD_ENCRYPTION_KEY = ENCRYPTION_TEST_KEY const { encryptionKey } = configureKeys({}) - expect(formatKey(encryptionKey.raw as Uint8Array)).toEqual(TEST_KEY) + expect(formatKey(encryptionKey.raw as Uint8Array)).toEqual(ENCRYPTION_TEST_KEY) process.env.PRISMA_FIELD_ENCRYPTION_KEY = undefined }) test('Encryption key is in the keychain', () => { const { encryptionKey, keychain } = configureKeys({ - encryptionKey: TEST_KEY + encryptionKey: ENCRYPTION_TEST_KEY }) expect(keychain[encryptionKey.fingerprint].key).toEqual(encryptionKey) }) test('Loading decryption keys directly', () => { const { keychain } = configureKeys({ - encryptionKey: TEST_KEY, - decryptionKeys: [ - 'k1.aesgcm256.4BNYdJnjOQJP2adq9cGM9kb4dZxDujUs6aPS0VeRtAM=', - 'k1.aesgcm256.El9unG7WBAVRQdATOyMggE3XrLV2ZjTGKdajfmIeBPs=' - ] + encryptionKey: ENCRYPTION_TEST_KEY, + decryptionKeys: DECRYPTION_TEST_KEY }) expect(Object.values(keychain).length).toEqual(3) }) test('Loading decryption keys via the environment', () => { - process.env.PRISMA_FIELD_DECRYPTION_KEYS = [ - 'k1.aesgcm256.4BNYdJnjOQJP2adq9cGM9kb4dZxDujUs6aPS0VeRtAM=', - 'k1.aesgcm256.El9unG7WBAVRQdATOyMggE3XrLV2ZjTGKdajfmIeBPs=' - ].join(',') + process.env.PRISMA_FIELD_DECRYPTION_KEYS = DECRYPTION_TEST_KEY.join(',') const { keychain } = configureKeys({ - encryptionKey: TEST_KEY + encryptionKey: ENCRYPTION_TEST_KEY }) expect(Object.values(keychain).length).toEqual(3) process.env.PRISMA_FIELD_DECRYPTION_KEYS = undefined @@ -80,12 +59,42 @@ describe('encryption', () => { }) describe('encryptOnWrite', () => { - test('Should call custom encrypt function', () => { + test('Should call custom encrypt function', async () => { + + const encryptFunction = jest.fn( + (decripted: string) => `fake-encription-${decripted}` + ) + + const params: MiddlewareParams = { + action: 'create', + args: { data: { name: 'value' } }, + dataPath: ['any'], + runInTransaction: true, + model: 'User' + } + + const dmmfModels: DMMFModels = { + User: { + connections: {}, + fields: { + name: { + encrypt: true, + strictDecryption: true + } + } + } + } + + const keys = configureKeys({ + encryptionKey: ENCRYPTION_TEST_KEY, + decryptionKeys: DECRYPTION_TEST_KEY + }) + encryptOnWrite( - fakeParams, - fakeKeys, - fakeModels, - fakeOperation, + params, + keys, + dmmfModels, + 'User.create', encryptFunction ) diff --git a/src/encryption.ts b/src/encryption.ts index dc12902..cd60201 100644 --- a/src/encryption.ts +++ b/src/encryption.ts @@ -15,8 +15,8 @@ import { errors, warnings } from './errors' import type { Configuration, MiddlewareParams, - EncryptionFunction, - DecryptionFunction + EncryptionFn, + DecryptionFn } from './types' import { visitInputTargetFields, visitOutputTargetFields } from './visitor' @@ -69,7 +69,7 @@ export function encryptOnWrite( keys: KeysConfiguration, models: DMMFModels, operation: string, - cb?: EncryptionFunction + encryptFn?: EncryptionFn ) { if (!writeOperations.includes(params.action)) { return params // No input data to encrypt @@ -96,8 +96,8 @@ export function encryptOnWrite( } try { const cipherText = - cb !== undefined - ? cb(clearText) + encryptFn !== undefined + ? encryptFn(clearText) : encryptStringSync(clearText, keys.encryptionKey) objectPath.set(draft.args, path, cipherText) @@ -121,7 +121,7 @@ export function decryptOnRead( keys: KeysConfiguration, models: DMMFModels, operation: string, - cb?: DecryptionFunction + decryptFn?: DecryptionFn ) { // Analyse the query to see if there's anything to decrypt. const model = models[params.model!] @@ -153,8 +153,8 @@ export function decryptOnRead( } const decryptionKey = findKeyForMessage(cipherText, keys.keychain) const clearText = - cb !== undefined - ? cb(cipherText) + decryptFn !== undefined + ? decryptFn(cipherText) : decryptStringSync(cipherText, decryptionKey) objectPath.set(result, path, clearText) diff --git a/src/traverseTree.ts b/src/traverseTree.ts index 65e68f0..afb5d10 100644 --- a/src/traverseTree.ts +++ b/src/traverseTree.ts @@ -44,8 +44,6 @@ export function traverseTree( while (stack.length > 0) { const { state, ...item } = stack.shift()! - console.log('CALLBACK') - const newState = callback(state, item) if (!isCollection(item.node)) { continue diff --git a/src/types.ts b/src/types.ts index bf57c75..c7e68ce 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,15 +8,15 @@ export type DMMF = typeof Prisma.dmmf // Internal types -- -export type EncryptionFunction = (value: string) => string +export type EncryptionFn = (value: string) => string -export type DecryptionFunction = (value: string) => string +export type DecryptionFn = (value: string) => string export interface Configuration { encryptionKey?: string decryptionKeys?: string[] - encryptionFn?: EncryptionFunction - decryptionFn?: DecryptionFunction + encryptionFn?: EncryptionFn + decryptionFn?: DecryptionFn } export interface FieldConfiguration { From 2eb7f4f7f1fef1c6a24738eb41528d385b9fde61 Mon Sep 17 00:00:00 2001 From: vitu-77 Date: Wed, 25 May 2022 16:29:50 -0300 Subject: [PATCH 03/12] feat: creating test for custom decrypt function --- src/encryption.test.ts | 100 ++++++++++++++++++++++++++++++++--------- src/encryption.ts | 23 +++++----- 2 files changed, 92 insertions(+), 31 deletions(-) diff --git a/src/encryption.test.ts b/src/encryption.test.ts index 0d75f0f..31a6f38 100644 --- a/src/encryption.test.ts +++ b/src/encryption.test.ts @@ -1,11 +1,12 @@ import { formatKey } from '@47ng/cloak/dist/key' import { DMMFModels } from 'dmmf' import { MiddlewareParams } from 'types' -import { configureKeys, encryptOnWrite, KeysConfiguration } from './encryption' +import { configureKeys, decryptOnRead, encryptOnWrite } from './encryption' import { errors } from './errors' -const ENCRYPTION_TEST_KEY = 'k1.aesgcm256.DbQoar8ZLuUsOHZNyrnjlskInHDYlzF3q6y1KGM7DUM=' -const DECRYPTION_TEST_KEY = [ +const ENCRYPTION_TEST_KEY = + 'k1.aesgcm256.DbQoar8ZLuUsOHZNyrnjlskInHDYlzF3q6y1KGM7DUM=' +const DECRYPTION_TEST_KEYS = [ 'k1.aesgcm256.4BNYdJnjOQJP2adq9cGM9kb4dZxDujUs6aPS0VeRtAM=', 'k1.aesgcm256.El9unG7WBAVRQdATOyMggE3XrLV2ZjTGKdajfmIeBPs=' ] @@ -22,13 +23,17 @@ describe('encryption', () => { encryptionKey: ENCRYPTION_TEST_KEY }) - expect(formatKey(encryptionKey.raw as Uint8Array)).toEqual(ENCRYPTION_TEST_KEY) + expect(formatKey(encryptionKey.raw as Uint8Array)).toEqual( + ENCRYPTION_TEST_KEY + ) }) test('Providing encryptionKey via the environment', () => { process.env.PRISMA_FIELD_ENCRYPTION_KEY = ENCRYPTION_TEST_KEY const { encryptionKey } = configureKeys({}) - expect(formatKey(encryptionKey.raw as Uint8Array)).toEqual(ENCRYPTION_TEST_KEY) + expect(formatKey(encryptionKey.raw as Uint8Array)).toEqual( + ENCRYPTION_TEST_KEY + ) process.env.PRISMA_FIELD_ENCRYPTION_KEY = undefined }) @@ -42,13 +47,13 @@ describe('encryption', () => { test('Loading decryption keys directly', () => { const { keychain } = configureKeys({ encryptionKey: ENCRYPTION_TEST_KEY, - decryptionKeys: DECRYPTION_TEST_KEY + decryptionKeys: DECRYPTION_TEST_KEYS }) expect(Object.values(keychain).length).toEqual(3) }) test('Loading decryption keys via the environment', () => { - process.env.PRISMA_FIELD_DECRYPTION_KEYS = DECRYPTION_TEST_KEY.join(',') + process.env.PRISMA_FIELD_DECRYPTION_KEYS = DECRYPTION_TEST_KEYS.join(',') const { keychain } = configureKeys({ encryptionKey: ENCRYPTION_TEST_KEY @@ -60,45 +65,98 @@ describe('encryption', () => { describe('encryptOnWrite', () => { test('Should call custom encrypt function', async () => { - const encryptFunction = jest.fn( (decripted: string) => `fake-encription-${decripted}` ) + const name = 'value' const params: MiddlewareParams = { + model: 'User', action: 'create', - args: { data: { name: 'value' } }, - dataPath: ['any'], + args: { data: { name } }, runInTransaction: true, - model: 'User' + dataPath: ['any'] } - + const dmmfModels: DMMFModels = { User: { - connections: {}, + connections: { + 'fake-connection': { + modelName: 'User', + isList: false + } + }, fields: { name: { encrypt: true, - strictDecryption: true + strictDecryption: false } } } } - const keys = configureKeys({ - encryptionKey: ENCRYPTION_TEST_KEY, - decryptionKeys: DECRYPTION_TEST_KEY + const keys = configureKeys({ + encryptionKey: ENCRYPTION_TEST_KEY, + decryptionKeys: DECRYPTION_TEST_KEYS }) - encryptOnWrite( + encryptOnWrite(params, keys, dmmfModels, 'User.create', encryptFunction) + + expect(encryptFunction).toBeCalledTimes(1) + expect(encryptFunction).toBeCalledWith(name) + }) + }) + + describe('decryptOnRead', () => { + test('Should call custom decryp function', async () => { + const decryptFunction = jest.fn( + (encrypted: string) => `fake-encription-${encrypted}` + ) + + const params: MiddlewareParams = { + model: 'User', + action: 'findFirst', + args: { where: { name: 'value' } }, + runInTransaction: true, + dataPath: ['any'] + } + + const dmmfModels: DMMFModels = { + User: { + connections: { + 'fake-connection': { + modelName: 'User', + isList: false + } + }, + fields: { + name: { + encrypt: true, + strictDecryption: false + } + } + } + } + + const keys = configureKeys({ + encryptionKey: ENCRYPTION_TEST_KEY, + decryptionKeys: DECRYPTION_TEST_KEYS + }) + + const encryptedName = 'a1b2c3d4e5d6' + const result = { name: encryptedName } + + decryptOnRead( params, + result, keys, dmmfModels, - 'User.create', - encryptFunction + 'User.findFirst', + decryptFunction ) - expect(encryptFunction).toBeCalledTimes(1) + expect(decryptFunction).toBeCalledTimes(1) + expect(decryptFunction).toBeCalledWith(encryptedName) }) }) }) diff --git a/src/encryption.ts b/src/encryption.ts index cd60201..4f13f6b 100644 --- a/src/encryption.ts +++ b/src/encryption.ts @@ -12,12 +12,7 @@ import produce, { Draft } from 'immer' import objectPath from 'object-path' import type { DMMFModels } from './dmmf' import { errors, warnings } from './errors' -import type { - Configuration, - MiddlewareParams, - EncryptionFn, - DecryptionFn -} from './types' +import type { MiddlewareParams, EncryptionFn, DecryptionFn } from './types' import { visitInputTargetFields, visitOutputTargetFields } from './visitor' export interface KeysConfiguration { @@ -25,7 +20,12 @@ export interface KeysConfiguration { keychain: CloakKeychain } -export function configureKeys(config: Configuration): KeysConfiguration { +export interface ConfigureKeysParams { + encryptionKey?: string + decryptionKeys?: string[] +} + +export function configureKeys(config: ConfigureKeysParams): KeysConfiguration { const encryptionKey = config.encryptionKey || process.env.PRISMA_FIELD_ENCRYPTION_KEY @@ -148,14 +148,17 @@ export function decryptOnRead( field }) { try { - if (!cloakedStringRegex.test(cipherText)) { + if (!decryptFn && !cloakedStringRegex.test(cipherText)) { return } - const decryptionKey = findKeyForMessage(cipherText, keys.keychain) + const clearText = decryptFn !== undefined ? decryptFn(cipherText) - : decryptStringSync(cipherText, decryptionKey) + : decryptStringSync( + cipherText, + findKeyForMessage(cipherText, keys.keychain) + ) objectPath.set(result, path, clearText) } catch (error) { From c899eedb0b3bf51c3b5723eb9c8453497c285184 Mon Sep 17 00:00:00 2001 From: vitu-77 Date: Wed, 25 May 2022 17:34:31 -0300 Subject: [PATCH 04/12] fix: fixing fake decryption fn text --- src/encryption.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/encryption.test.ts b/src/encryption.test.ts index 31a6f38..678688d 100644 --- a/src/encryption.test.ts +++ b/src/encryption.test.ts @@ -110,7 +110,7 @@ describe('encryption', () => { describe('decryptOnRead', () => { test('Should call custom decryp function', async () => { const decryptFunction = jest.fn( - (encrypted: string) => `fake-encription-${encrypted}` + (encrypted: string) => `fake-decription-${encrypted}` ) const params: MiddlewareParams = { From a988d9c2bbfe08bc4b469fffea4235b4801d074a Mon Sep 17 00:00:00 2001 From: vitu-77 Date: Wed, 25 May 2022 17:34:55 -0300 Subject: [PATCH 05/12] fix: configureKeys params --- src/index.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index f4bb9e5..ba3f702 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,10 @@ import { analyseDMMF } from './dmmf' -import { configureKeys, decryptOnRead, encryptOnWrite } from './encryption' +import { + configureKeys, + decryptOnRead, + encryptOnWrite, + ConfigureKeysParams +} from './encryption' import type { Configuration, Middleware, MiddlewareParams } from './types' export function fieldEncryptionMiddleware( @@ -7,7 +12,12 @@ export function fieldEncryptionMiddleware( ): Middleware { // This will throw if the encryption key is missing // or if anything is invalid. - const keys = configureKeys(config) + const configureKeysParams: ConfigureKeysParams = { + encryptionKey: config.encryptionKey, + decryptionKeys: config.decryptionKeys + } + + const keys = configureKeys(configureKeysParams) const models = analyseDMMF() return async function fieldEncryptionMiddleware( From d4ff75a01dc6b9e631f985aa4586184e7c1959ab Mon Sep 17 00:00:00 2001 From: vitu-77 Date: Wed, 25 May 2022 17:51:19 -0300 Subject: [PATCH 06/12] feat: passing encryptFn and decryptFn as write and read params --- src/index.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index ba3f702..fdeed01 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,9 +31,25 @@ export function fieldEncryptionMiddleware( const operation = `${params.model}.${params.action}` // Params are mutated in-place for modifications to occur. // See https://github.com/prisma/prisma/issues/9522 - const encryptedParams = encryptOnWrite(params, keys, models, operation) + const encryptedParams = encryptOnWrite( + params, + keys, + models, + operation, + config.encryptionFn + ) + let result = await next(encryptedParams) - decryptOnRead(encryptedParams, result, keys, models, operation) + + decryptOnRead( + encryptedParams, + result, + keys, + models, + operation, + config.decryptionFn + ) + return result } } From 7edf3a30b38ad89b859168a966b4b00e086ebec3 Mon Sep 17 00:00:00 2001 From: vitu-77 Date: Tue, 31 May 2022 11:35:42 -0300 Subject: [PATCH 07/12] feat: ensuring encryption and decryption functions are provided --- src/encryption.test.ts | 4 ++-- src/index.ts | 8 ++++---- src/types.ts | 8 ++++++-- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/encryption.test.ts b/src/encryption.test.ts index 678688d..631d86c 100644 --- a/src/encryption.test.ts +++ b/src/encryption.test.ts @@ -64,7 +64,7 @@ describe('encryption', () => { }) describe('encryptOnWrite', () => { - test('Should call custom encrypt function', async () => { + test('Should call custom cypher encrypt function', async () => { const encryptFunction = jest.fn( (decripted: string) => `fake-encription-${decripted}` ) @@ -108,7 +108,7 @@ describe('encryption', () => { }) describe('decryptOnRead', () => { - test('Should call custom decryp function', async () => { + test('Should call custom cypher decrypt function', async () => { const decryptFunction = jest.fn( (encrypted: string) => `fake-decription-${encrypted}` ) diff --git a/src/index.ts b/src/index.ts index fdeed01..444ed36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,13 +10,13 @@ import type { Configuration, Middleware, MiddlewareParams } from './types' export function fieldEncryptionMiddleware( config: Configuration = {} ): Middleware { - // This will throw if the encryption key is missing - // or if anything is invalid. const configureKeysParams: ConfigureKeysParams = { encryptionKey: config.encryptionKey, decryptionKeys: config.decryptionKeys } + // This will throw if the encryption key is missing + // or if anything is invalid. const keys = configureKeys(configureKeysParams) const models = analyseDMMF() @@ -36,7 +36,7 @@ export function fieldEncryptionMiddleware( keys, models, operation, - config.encryptionFn + config.cipher?.encrypt ) let result = await next(encryptedParams) @@ -47,7 +47,7 @@ export function fieldEncryptionMiddleware( keys, models, operation, - config.decryptionFn + config.cipher?.decrypt ) return result diff --git a/src/types.ts b/src/types.ts index c7e68ce..3548ab8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,11 +12,15 @@ export type EncryptionFn = (value: string) => string export type DecryptionFn = (value: string) => string +export type CipherFunctions = { + encrypt: EncryptionFn + decrypt: DecryptionFn +} + export interface Configuration { encryptionKey?: string decryptionKeys?: string[] - encryptionFn?: EncryptionFn - decryptionFn?: DecryptionFn + cipher?: CipherFunctions } export interface FieldConfiguration { From a260e5b88610d527d8f1599768953636d012ed77 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Wed, 1 Jun 2022 09:19:27 +0200 Subject: [PATCH 08/12] chore: Fix typos, exports & formatting --- src/encryption.test.ts | 8 ++++---- src/traverseTree.ts | 1 - src/types.ts | 5 ++--- src/visitor.ts | 5 +---- 4 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/encryption.test.ts b/src/encryption.test.ts index 631d86c..afe13b0 100644 --- a/src/encryption.test.ts +++ b/src/encryption.test.ts @@ -1,8 +1,8 @@ import { formatKey } from '@47ng/cloak/dist/key' -import { DMMFModels } from 'dmmf' -import { MiddlewareParams } from 'types' +import type { DMMFModels } from './dmmf' import { configureKeys, decryptOnRead, encryptOnWrite } from './encryption' import { errors } from './errors' +import type { MiddlewareParams } from './types' const ENCRYPTION_TEST_KEY = 'k1.aesgcm256.DbQoar8ZLuUsOHZNyrnjlskInHDYlzF3q6y1KGM7DUM=' @@ -66,7 +66,7 @@ describe('encryption', () => { describe('encryptOnWrite', () => { test('Should call custom cypher encrypt function', async () => { const encryptFunction = jest.fn( - (decripted: string) => `fake-encription-${decripted}` + (decrypted: string) => `fake-encryption-${decrypted}` ) const name = 'value' @@ -110,7 +110,7 @@ describe('encryption', () => { describe('decryptOnRead', () => { test('Should call custom cypher decrypt function', async () => { const decryptFunction = jest.fn( - (encrypted: string) => `fake-decription-${encrypted}` + (encrypted: string) => `fake-decryption-${encrypted}` ) const params: MiddlewareParams = { diff --git a/src/traverseTree.ts b/src/traverseTree.ts index afb5d10..a106cc9 100644 --- a/src/traverseTree.ts +++ b/src/traverseTree.ts @@ -43,7 +43,6 @@ export function traverseTree( while (stack.length > 0) { const { state, ...item } = stack.shift()! - const newState = callback(state, item) if (!isCollection(item.node)) { continue diff --git a/src/types.ts b/src/types.ts index 3548ab8..d4f7a28 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,9 +8,8 @@ export type DMMF = typeof Prisma.dmmf // Internal types -- -export type EncryptionFn = (value: string) => string - -export type DecryptionFn = (value: string) => string +export type EncryptionFn = (clearText: string) => string +export type DecryptionFn = (cipherText: string) => string export type CipherFunctions = { encrypt: EncryptionFn diff --git a/src/visitor.ts b/src/visitor.ts index 8f9799f..7432b4c 100644 --- a/src/visitor.ts +++ b/src/visitor.ts @@ -16,10 +16,7 @@ export interface TargetField { export type TargetFieldVisitorFn = (targetField: TargetField) => void -export const makeVisitor = ( - models: DMMFModels, - visitor: TargetFieldVisitorFn -) => +const makeVisitor = (models: DMMFModels, visitor: TargetFieldVisitorFn) => function visitNode(state: VisitorState, { key, type, node, path }: Item) { const model = models[state.currentModel] if (!model || !key) { From 38f41d9aa08005d1965d3cff67c5d2cc5d33e222 Mon Sep 17 00:00:00 2001 From: vitu-77 Date: Tue, 5 Jul 2022 18:40:12 -0300 Subject: [PATCH 09/12] feat: discriminating union of configuration --- src/encryption.test.ts | 175 ++++++++++++++++++--- src/encryption.ts | 138 ++++++++++++++--- src/errors.ts | 5 + src/index.ts | 22 ++- src/tests/integration.test.ts | 280 ++++++++++++++++++++-------------- src/tests/migrate.ts | 4 +- src/tests/prismaClient.ts | 78 +++++----- src/types.ts | 13 +- 8 files changed, 509 insertions(+), 206 deletions(-) diff --git a/src/encryption.test.ts b/src/encryption.test.ts index 631d86c..217b6cb 100644 --- a/src/encryption.test.ts +++ b/src/encryption.test.ts @@ -1,7 +1,16 @@ import { formatKey } from '@47ng/cloak/dist/key' import { DMMFModels } from 'dmmf' -import { MiddlewareParams } from 'types' -import { configureKeys, decryptOnRead, encryptOnWrite } from './encryption' +import { Configuration, MiddlewareParams } from 'types' +import { + configureFunctions, + configureKeys, + configureKeysAndFunctions, + decryptOnRead, + encryptOnWrite, + getMethod, + isCustomConfiguration, + isDefaultConfiguration +} from './encryption' import { errors } from './errors' const ENCRYPTION_TEST_KEY = @@ -11,6 +20,14 @@ const DECRYPTION_TEST_KEYS = [ 'k1.aesgcm256.El9unG7WBAVRQdATOyMggE3XrLV2ZjTGKdajfmIeBPs=' ] +const encryptFunction = jest.fn( + (decripted: string) => `fake-encription-${decripted}` +) + +const decryptFunction = jest.fn( + (encrypted: string) => `fake-decription-${encrypted}` +) + describe('encryption', () => { describe('configureKeys', () => { test('No encryption key specified', () => { @@ -63,12 +80,118 @@ describe('encryption', () => { }) }) + describe('configureKeysAndFunctions', () => { + test('Should return keys === null ', () => { + const config = { + encryptFn: encryptFunction, + decryptFn: decryptFunction + } + + const result = configureKeysAndFunctions(config) + + expect(result.keys).toBeNull() + }) + }) + + describe('isDefaultConfiguration', () => { + test('should be truthy', () => { + const keysConfig = { + decryptionKeys: DECRYPTION_TEST_KEYS, + encryptionKey: ENCRYPTION_TEST_KEY + } + + expect(isDefaultConfiguration(keysConfig)).toBeTruthy() + expect(isDefaultConfiguration({})).toBeTruthy() + }) + + test('should be falsy', () => { + const config = { + decryptFn: decryptFunction, + encryptFn: encryptFunction + } + + const result = isDefaultConfiguration(config) + + expect(result).toBeFalsy() + }) + }) + + describe('isCustomConfiguration', () => { + test('should be truthy', () => { + const config = { + decryptFn: decryptFunction, + encryptFn: encryptFunction + } + + const result = isCustomConfiguration(config) + + expect(result).toBeTruthy() + }) + + test('should be falsy', () => { + const config = { + decryptionKeys: DECRYPTION_TEST_KEYS, + encryptionKey: ENCRYPTION_TEST_KEY + } + + const result = isCustomConfiguration(config) + + expect(result).toBeFalsy() + }) + }) + + describe('getMethod', () => { + test('Should throw error providing keys and cypher functions', () => { + const config = { + decryptFn: decryptFunction, + encryptFn: encryptFunction, + decryptionKeys: DECRYPTION_TEST_KEYS, + encryptionKey: ENCRYPTION_TEST_KEY + } + + const run = () => getMethod(config) + + expect(run).toThrowError(errors.invalidConfig) + }) + + test('Should return method === "CUSTOM"', () => { + const config = { + encryptFn: encryptFunction, + decryptFn: decryptFunction + } + + const result = getMethod(config) + + expect(result).toBe('CUSTOM') + }) + + test('Should return method === "DEFAULT" ', () => { + const config = { + decryptionKeys: DECRYPTION_TEST_KEYS, + encryptionKey: ENCRYPTION_TEST_KEY + } + + const result = getMethod(config) + + expect(result).toBe('DEFAULT') + }) + }) + + describe('configureFunctions', () => { + test('Should throw error providing invalid cypher functions', () => { + const config = { + encryptFn: 'NOT A FUNCTION', + decryptFn: 'STILL NOT A FUNCTION' + } + + const run = () => configureFunctions(config) + + expect(run).toThrowError(errors.invalidFunctionsConfiguration) + }) + }) + describe('encryptOnWrite', () => { test('Should call custom cypher encrypt function', async () => { - const encryptFunction = jest.fn( - (decripted: string) => `fake-encription-${decripted}` - ) - const name = 'value' const params: MiddlewareParams = { model: 'User', @@ -95,12 +218,22 @@ describe('encryption', () => { } } - const keys = configureKeys({ - encryptionKey: ENCRYPTION_TEST_KEY, - decryptionKeys: DECRYPTION_TEST_KEYS - }) + const config: Configuration = { + encryptFn: encryptFunction, + decryptFn: () => 'fake-decryption' + } + + const { keys, cipherFunctions, method } = + configureKeysAndFunctions(config) - encryptOnWrite(params, keys, dmmfModels, 'User.create', encryptFunction) + encryptOnWrite( + params, + dmmfModels, + 'User.create', + method, + keys, + cipherFunctions?.encryptFn + ) expect(encryptFunction).toBeCalledTimes(1) expect(encryptFunction).toBeCalledWith(name) @@ -109,10 +242,6 @@ describe('encryption', () => { describe('decryptOnRead', () => { test('Should call custom cypher decrypt function', async () => { - const decryptFunction = jest.fn( - (encrypted: string) => `fake-decription-${encrypted}` - ) - const params: MiddlewareParams = { model: 'User', action: 'findFirst', @@ -138,10 +267,13 @@ describe('encryption', () => { } } - const keys = configureKeys({ - encryptionKey: ENCRYPTION_TEST_KEY, - decryptionKeys: DECRYPTION_TEST_KEYS - }) + const config: Configuration = { + decryptFn: decryptFunction, + encryptFn: () => 'encrypted-text' + } + + const { keys, cipherFunctions, method } = + configureKeysAndFunctions(config) const encryptedName = 'a1b2c3d4e5d6' const result = { name: encryptedName } @@ -149,10 +281,11 @@ describe('encryption', () => { decryptOnRead( params, result, - keys, dmmfModels, 'User.findFirst', - decryptFunction + method, + keys, + cipherFunctions?.decryptFn ) expect(decryptFunction).toBeCalledTimes(1) diff --git a/src/encryption.ts b/src/encryption.ts index 4f13f6b..21d90dd 100644 --- a/src/encryption.ts +++ b/src/encryption.ts @@ -12,7 +12,14 @@ import produce, { Draft } from 'immer' import objectPath from 'object-path' import type { DMMFModels } from './dmmf' import { errors, warnings } from './errors' -import type { MiddlewareParams, EncryptionFn, DecryptionFn } from './types' +import type { + MiddlewareParams, + EncryptionFn, + DecryptionFn, + Configuration, + CipherFunctions, + Keys +} from './types' import { visitInputTargetFields, visitOutputTargetFields } from './visitor' export interface KeysConfiguration { @@ -20,14 +27,91 @@ export interface KeysConfiguration { keychain: CloakKeychain } +export interface FunctionsConfiguration { + encryptFn: EncryptionFn + decryptFn: DecryptionFn +} + export interface ConfigureKeysParams { encryptionKey?: string decryptionKeys?: string[] } -export function configureKeys(config: ConfigureKeysParams): KeysConfiguration { +export interface KeysAndFunctionsConfiguration { + keys: KeysConfiguration | null + cipherFunctions: CipherFunctions | null + method: Method +} + +const ENCRYPTION_KEY_PROP = 'encryptionKey' +const DECRYPTION_KEYS_PROP = 'decryptionKeys' +const ENCRYPTION_FN_PROP = 'encryptFn' +const DECRYPTION_FN_PROP = 'decryptFn' + +export type Method = 'CUSTOM' | 'DEFAULT' + +export function configureKeysAndFunctions( + config: Configuration +): KeysAndFunctionsConfiguration { + const method: Method = getMethod(config) + const keys = method === 'DEFAULT' ? configureKeys(config) : null + const cipherFunctions = + method === 'CUSTOM' ? configureFunctions(config) : null + + return { cipherFunctions, keys, method } +} + +export function getMethod(config: Configuration): Method { + if (isDefaultConfiguration(config)) { + return 'DEFAULT' + } + + if (isCustomConfiguration(config)) { + return 'CUSTOM' + } + + throw new Error(errors.invalidConfig) +} + +export function isDefaultConfiguration(config: Configuration): boolean { + return !(ENCRYPTION_FN_PROP in config) && !(DECRYPTION_FN_PROP in config) +} + +export function isCustomConfiguration(config: Configuration): boolean { + return ( + ENCRYPTION_FN_PROP in config && + DECRYPTION_FN_PROP in config && + !(ENCRYPTION_KEY_PROP in config) && + !(DECRYPTION_KEYS_PROP in config) + ) +} + +export function configureFunctions( + config: Configuration +): FunctionsConfiguration { + const encryptFn = (config as CipherFunctions)[ENCRYPTION_FN_PROP] + const decryptFn = (config as CipherFunctions)[DECRYPTION_FN_PROP] + + if (typeof encryptFn !== 'function' || typeof decryptFn !== 'function') { + throw new Error(errors.invalidFunctionsConfiguration) + } + + const cipherFunctions = { + encryptFn, + decryptFn + } + + return cipherFunctions +} + +export function configureKeys(config: Configuration): KeysConfiguration { + const configureKeysParams: ConfigureKeysParams = { + encryptionKey: (config as Keys)[ENCRYPTION_KEY_PROP], + decryptionKeys: (config as Keys)[DECRYPTION_KEYS_PROP] + } + const encryptionKey = - config.encryptionKey || process.env.PRISMA_FIELD_ENCRYPTION_KEY + configureKeysParams.encryptionKey || process.env.PRISMA_FIELD_ENCRYPTION_KEY if (!encryptionKey) { throw new Error(errors.noEncryptionKey) @@ -40,7 +124,7 @@ export function configureKeys(config: ConfigureKeysParams): KeysConfiguration { const decryptionKeys: string[] = Array.from( new Set([ encryptionKey, - ...(config.decryptionKeys ?? decryptionKeysFromEnv) + ...(configureKeysParams.decryptionKeys ?? decryptionKeysFromEnv) ]) ) @@ -66,9 +150,10 @@ const whereClauseRegExp = /\.where\./ export function encryptOnWrite( params: MiddlewareParams, - keys: KeysConfiguration, models: DMMFModels, operation: string, + method: Method, + keys: KeysConfiguration | null, encryptFn?: EncryptionFn ) { if (!writeOperations.includes(params.action)) { @@ -95,10 +180,19 @@ export function encryptOnWrite( console.warn(warnings.whereClause(operation, path)) } try { - const cipherText = - encryptFn !== undefined - ? encryptFn(clearText) - : encryptStringSync(clearText, keys.encryptionKey) + let cipherText: string | undefined + + if (method === 'CUSTOM' && !!encryptFn) { + cipherText = encryptFn(clearText) + } + + if (method === 'DEFAULT' && !!keys) { + cipherText = encryptStringSync(clearText, keys.encryptionKey) + } + + if (!cipherText) { + throw new Error(errors.invalidConfig) + } objectPath.set(draft.args, path, cipherText) } catch (error) { @@ -118,9 +212,10 @@ export function encryptOnWrite( export function decryptOnRead( params: MiddlewareParams, result: any, - keys: KeysConfiguration, models: DMMFModels, operation: string, + method: Method, + keys: KeysConfiguration | null, decryptFn?: DecryptionFn ) { // Analyse the query to see if there's anything to decrypt. @@ -152,13 +247,22 @@ export function decryptOnRead( return } - const clearText = - decryptFn !== undefined - ? decryptFn(cipherText) - : decryptStringSync( - cipherText, - findKeyForMessage(cipherText, keys.keychain) - ) + let clearText: string | undefined + + if (method === 'CUSTOM' && !!decryptFn) { + clearText = decryptFn(cipherText) + } + + if (method === 'DEFAULT' && !!keys) { + clearText = decryptStringSync( + cipherText, + findKeyForMessage(cipherText, keys.keychain) + ) + } + + if (!clearText) { + throw new Error(errors.invalidConfig) + } objectPath.set(result, path, clearText) } catch (error) { diff --git a/src/errors.ts b/src/errors.ts index 0d53af0..d742812 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -8,6 +8,11 @@ const prefixWarning = (input: string) => `${header} Warning: ${input}` export const errors = { // Setup errors noEncryptionKey: prefixError('no encryption key provided.'), + invalidConfig: prefixError('invalid configuration provided.'), + invalidKeysConfiguration: prefixError('invalid keys configuration provided.'), + invalidFunctionsConfiguration: prefixError( + 'invalid cipher functions configuration provided.' + ), unsupportedFieldType: (model: Prisma.DMMF.Model, field: Prisma.DMMF.Field) => prefixError( `encryption enabled for field ${model.name}.${field.name} of unsupported type ${field.type}: only String fields can be encrypted.` diff --git a/src/index.ts b/src/index.ts index 444ed36..afb9d5d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,23 +1,16 @@ import { analyseDMMF } from './dmmf' import { - configureKeys, decryptOnRead, encryptOnWrite, - ConfigureKeysParams + configureKeysAndFunctions } from './encryption' import type { Configuration, Middleware, MiddlewareParams } from './types' export function fieldEncryptionMiddleware( config: Configuration = {} ): Middleware { - const configureKeysParams: ConfigureKeysParams = { - encryptionKey: config.encryptionKey, - decryptionKeys: config.decryptionKeys - } + const { cipherFunctions, keys, method } = configureKeysAndFunctions(config) - // This will throw if the encryption key is missing - // or if anything is invalid. - const keys = configureKeys(configureKeysParams) const models = analyseDMMF() return async function fieldEncryptionMiddleware( @@ -31,12 +24,14 @@ export function fieldEncryptionMiddleware( const operation = `${params.model}.${params.action}` // Params are mutated in-place for modifications to occur. // See https://github.com/prisma/prisma/issues/9522 + const encryptedParams = encryptOnWrite( params, - keys, models, operation, - config.cipher?.encrypt + method, + keys, + cipherFunctions?.encryptFn ) let result = await next(encryptedParams) @@ -44,10 +39,11 @@ export function fieldEncryptionMiddleware( decryptOnRead( encryptedParams, result, - keys, models, operation, - config.cipher?.decrypt + method, + keys, + cipherFunctions?.decryptFn ) return result diff --git a/src/tests/integration.test.ts b/src/tests/integration.test.ts index efedf8f..bf21824 100644 --- a/src/tests/integration.test.ts +++ b/src/tests/integration.test.ts @@ -1,143 +1,191 @@ import { cloakedStringRegex } from '@47ng/cloak' -import { client } from './prismaClient' +import { + createClient, + defaultMiddleware, + customMiddleware, + CIPHER +} from './prismaClient' import * as sqlite from './sqlite' describe('integration', () => { - const email = '007@hmss.gov.uk' + describe('Default configuration', () => { + const email = '007@hmss.gov.uk' + const client = createClient(defaultMiddleware) - test('create user', async () => { - const received = await client.user.create({ - data: { - email, - name: 'James Bond' - } + test('create user', async () => { + const received = await client.user.create({ + data: { + email, + name: 'James Bond' + } + }) + const dbValue = await sqlite.get({ table: 'User', where: { email } }) + expect(received.name).toEqual('James Bond') // clear text in returned value + expect(dbValue.name).toMatch(cloakedStringRegex) // encrypted in database }) - const dbValue = await sqlite.get({ table: 'User', where: { email } }) - expect(received.name).toEqual('James Bond') // clear text in returned value - expect(dbValue.name).toMatch(cloakedStringRegex) // encrypted in database - }) - test('delete user', async () => { - const received = await client.user.delete({ where: { email } }) - expect(received.name).toEqual('James Bond') - }) + test('delete user', async () => { + const received = await client.user.delete({ where: { email } }) + expect(received.name).toEqual('James Bond') + }) - test('create post & associated user', async () => { - const received = await client.post.create({ - data: { - title: "I'm back", - content: 'You only live twice.', - author: { - create: { - email, - name: 'James Bond' + test('create post & associated user', async () => { + const received = await client.post.create({ + data: { + title: "I'm back", + content: 'You only live twice.', + author: { + create: { + email, + name: 'James Bond' + } } + }, + select: { + id: true, + author: true, + content: true } - }, - select: { - id: true, - author: true, - content: true - } - }) - const user = await sqlite.get({ table: 'User', where: { email } }) - const post = await sqlite.get({ - table: 'Post', - where: { id: received.id.toString() } + }) + const user = await sqlite.get({ table: 'User', where: { email } }) + const post = await sqlite.get({ + table: 'Post', + where: { id: received.id.toString() } + }) + expect(received.author?.name).toEqual('James Bond') + expect(received.content).toEqual('You only live twice.') + expect(user.name).toMatch(cloakedStringRegex) + expect(post.content).toMatch(cloakedStringRegex) + expect(post.title).toEqual("I'm back") // clear text in the database }) - expect(received.author?.name).toEqual('James Bond') - expect(received.content).toEqual('You only live twice.') - expect(user.name).toMatch(cloakedStringRegex) - expect(post.content).toMatch(cloakedStringRegex) - expect(post.title).toEqual("I'm back") // clear text in the database - }) - test('update user (with set)', async () => { - const received = await client.user.update({ - data: { - name: { - set: 'Bond, James Bond.' + test('update user (with set)', async () => { + const received = await client.user.update({ + data: { + name: { + set: 'Bond, James Bond.' + } + }, + where: { + email } - }, - where: { - email - } + }) + const user = await sqlite.get({ table: 'User', where: { email } }) + expect(received.name).toEqual('Bond, James Bond.') + expect(user.name).toMatch(cloakedStringRegex) }) - const user = await sqlite.get({ table: 'User', where: { email } }) - expect(received.name).toEqual('Bond, James Bond.') - expect(user.name).toMatch(cloakedStringRegex) - }) - test('complex query nesting', async () => { - const received = await client.user.create({ - data: { - email: '006@hmss.gov.uk', - name: 'Alec Trevelyan', - posts: { - create: [ - { - title: '006 - First report', - content: 'For England, James?' - }, - { - title: 'Janus Quotes', - content: "I've set the timers for six minutes", - categories: { - create: { - name: 'Quotes' + test('complex query nesting', async () => { + const received = await client.user.create({ + data: { + email: '006@hmss.gov.uk', + name: 'Alec Trevelyan', + posts: { + create: [ + { + title: '006 - First report', + content: 'For England, James?' + }, + { + title: 'Janus Quotes', + content: "I've set the timers for six minutes", + categories: { + create: { + name: 'Quotes' + } } } + ] + } + }, + include: { + posts: { + include: { + categories: true } - ] - } - }, - include: { - posts: { - include: { - categories: true } } - } - }) - expect(received.name).toEqual('Alec Trevelyan') - expect(received.posts[0].content).toEqual('For England, James?') - expect(received.posts[1].content).toEqual( - "I've set the timers for six minutes" - ) - const user = await sqlite.get({ - table: 'User', - where: { email: '006@hmss.gov.uk' } + }) + expect(received.name).toEqual('Alec Trevelyan') + expect(received.posts[0].content).toEqual('For England, James?') + expect(received.posts[1].content).toEqual( + "I've set the timers for six minutes" + ) + const user = await sqlite.get({ + table: 'User', + where: { email: '006@hmss.gov.uk' } + }) + const post1 = await sqlite.get({ + table: 'Post', + where: { id: received.posts[0].id.toString() } + }) + const post2 = await sqlite.get({ + table: 'Post', + where: { id: received.posts[1].id.toString() } + }) + const category = await sqlite.get({ + table: 'Category', + where: { name: 'Quotes' } + }) + expect(user.name).toMatch(cloakedStringRegex) + expect(post1.content).toMatch(cloakedStringRegex) + expect(post2.content).toMatch(cloakedStringRegex) + expect(category.name).toEqual('Quotes') }) - const post1 = await sqlite.get({ - table: 'Post', - where: { id: received.posts[0].id.toString() } + + test('immutable params', async () => { + const email = 'xenia@cccp.ru' + const params = { + data: { + name: 'Xenia Onatop', + email + } + } + const received = await client.user.create(params) + const user = await sqlite.get({ table: 'User', where: { email } }) + expect(params.data.name).toEqual('Xenia Onatop') + expect(received.name).toEqual('Xenia Onatop') + expect(user.name).toMatch(cloakedStringRegex) }) - const post2 = await sqlite.get({ - table: 'Post', - where: { id: received.posts[1].id.toString() } + }) + + describe('Custom configuration', () => { + const email = '007@hmss.gov.br' + const client = createClient(customMiddleware) + + test('create user', async () => { + const received = await client.user.create({ + data: { + email, + name: 'James Bond' + } + }) + + const dbValue = await sqlite.get({ table: 'User', where: { email } }) + expect(received.name).toEqual('James Bond') // clear text in returned value + expect(dbValue.name.endsWith(CIPHER)).toBeTruthy() // encrypted in database }) - const category = await sqlite.get({ - table: 'Category', - where: { name: 'Quotes' } + + test('update user (with set)', async () => { + const received = await client.user.update({ + data: { + name: { + set: 'Bond, James Bond.' + } + }, + where: { + email + } + }) + const user = await sqlite.get({ table: 'User', where: { email } }) + + expect(received.name).toEqual('Bond, James Bond.') + expect(user.name.endsWith(CIPHER)).toBeTruthy() }) - expect(user.name).toMatch(cloakedStringRegex) - expect(post1.content).toMatch(cloakedStringRegex) - expect(post2.content).toMatch(cloakedStringRegex) - expect(category.name).toEqual('Quotes') - }) - test('immutable params', async () => { - const email = 'xenia@cccp.ru' - const params = { - data: { - name: 'Xenia Onatop', - email - } - } - const received = await client.user.create(params) - const user = await sqlite.get({ table: 'User', where: { email } }) - expect(params.data.name).toEqual('Xenia Onatop') - expect(received.name).toEqual('Xenia Onatop') - expect(user.name).toMatch(cloakedStringRegex) + test('delete user', async () => { + const received = await client.user.delete({ where: { email } }) + expect(received.name).toEqual('Bond, James Bond.') + }) }) }) diff --git a/src/tests/migrate.ts b/src/tests/migrate.ts index 3e9e0ef..f3364de 100644 --- a/src/tests/migrate.ts +++ b/src/tests/migrate.ts @@ -1,6 +1,8 @@ // @ts-ignore import { migrate } from './migrations' -import { client } from './prismaClient' +import { createClient, defaultMiddleware } from './prismaClient' + +const client = createClient(defaultMiddleware) async function main() { await Promise.all([ diff --git a/src/tests/prismaClient.ts b/src/tests/prismaClient.ts index d71f862..68c16db 100644 --- a/src/tests/prismaClient.ts +++ b/src/tests/prismaClient.ts @@ -1,5 +1,5 @@ -import { PrismaClient } from '@prisma/client' -import { fieldEncryptionMiddleware } from '../index' +import { Prisma, PrismaClient } from '@prisma/client' +import { fieldEncryptionMiddleware } from '..' export const TEST_ENCRYPTION_KEY = 'k1.aesgcm256.OsqVmAOZBB_WW3073q1wU4ag0ap0ETYAYMh041RuxuI=' @@ -15,38 +15,48 @@ export const logger = warn: console.warn // and warnings } -export const client = new PrismaClient() - -client.$use(async (params, next) => { - const operation = `${params.model}.${params.action}` - logger.dir( - { 'πŸ‘€': `${operation}: before encryption`, params }, - { depth: null } - ) - const result = await next(params) - logger.dir( - { 'πŸ‘€': `${operation}: after decryption`, result }, - { depth: null } - ) - return result +export const defaultMiddleware = fieldEncryptionMiddleware({ + encryptionKey: TEST_ENCRYPTION_KEY }) -client.$use( - fieldEncryptionMiddleware({ - encryptionKey: TEST_ENCRYPTION_KEY - }) -) - -client.$use(async (params, next) => { - const operation = `${params.model}.${params.action}` - logger.dir( - { 'πŸ‘€': `${operation}: sent to database`, params }, - { depth: null } - ) - const result = await next(params) - logger.dir( - { 'πŸ‘€': `${operation}: received from database`, result }, - { depth: null } - ) - return result +export const CIPHER = 'ABC#789*_CBA' +export const customMiddleware = fieldEncryptionMiddleware({ + encryptFn: value => `${value}${CIPHER}`, + decryptFn: value => value.replace(CIPHER, '') }) + +export function createClient(middleware: Prisma.Middleware): PrismaClient { + const client = new PrismaClient() + + client.$use(async (params, next) => { + const operation = `${params.model}.${params.action}` + logger.dir( + { 'πŸ‘€': `${operation}: before encryption`, params }, + { depth: null } + ) + const result = await next(params) + logger.dir( + { 'πŸ‘€': `${operation}: after decryption`, result }, + { depth: null } + ) + return result + }) + + client.$use(middleware) + + client.$use(async (params, next) => { + const operation = `${params.model}.${params.action}` + logger.dir( + { 'πŸ‘€': `${operation}: sent to database`, params }, + { depth: null } + ) + const result = await next(params) + logger.dir( + { 'πŸ‘€': `${operation}: received from database`, result }, + { depth: null } + ) + return result + }) + + return client +} diff --git a/src/types.ts b/src/types.ts index 3548ab8..7d4c093 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,16 +13,21 @@ export type EncryptionFn = (value: string) => string export type DecryptionFn = (value: string) => string export type CipherFunctions = { - encrypt: EncryptionFn - decrypt: DecryptionFn + encryptFn: EncryptionFn + decryptFn: DecryptionFn + encryptionKey?: never + decryptionKeys?: never } -export interface Configuration { +export type Keys = { encryptionKey?: string decryptionKeys?: string[] - cipher?: CipherFunctions + encryptFn?: never + decryptFn?: never } +export type Configuration = CipherFunctions | Keys | {} + export interface FieldConfiguration { encrypt: boolean strictDecryption: boolean From 49b4002e4c20239eabe46d223982e629f31280f1 Mon Sep 17 00:00:00 2001 From: vitu-77 Date: Wed, 6 Jul 2022 10:25:25 -0300 Subject: [PATCH 10/12] fix: encrypt and recrypt types --- src/types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/types.ts b/src/types.ts index 7d4c093..6cbaf6c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,9 +8,9 @@ export type DMMF = typeof Prisma.dmmf // Internal types -- -export type EncryptionFn = (value: string) => string +export type EncryptionFn = (value: any) => string -export type DecryptionFn = (value: string) => string +export type DecryptionFn = (value: string) => any export type CipherFunctions = { encryptFn: EncryptionFn From fcabf7da7a161abc1fdca673dd615f57597d36d9 Mon Sep 17 00:00:00 2001 From: vitu-77 Date: Wed, 6 Jul 2022 10:29:17 -0300 Subject: [PATCH 11/12] doc: custom cipher suite docs --- README.md | 49 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f2fc37d..0e14929 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,13 @@ _Tip: place the middleware as low as you need cleartext data._ _Any middleware registered after field encryption will receive encrypted data for the selected fields._ -### 2. Setup your encryption key +### 2. Setup your configuration + +You can use two distinct configuration setups. The first is using encryption key and the other way is using your own encrypt/decript functions and logic: + +> ⚠️ **Both ways are mutually exclusive, so using one of the configurations prevents you from using the other.** + +#### 2.1. Using encryption key Generate an encryption key: @@ -79,6 +85,47 @@ client.$use( _Tip: a key provided in code will take precedence over a key from the environment._ +> ⚠️ **When using this method you will not be able to perform queries using encrypted fields.** + +#### 2.2. Using your own encrypt/decript functions + +Using your own functions is useful when you want full control over the cryptograph logic or whe you want to **perform queries over encrypted fields**, since you can use some static encryption algorithm. as static encryptions always generate the same hash for similar texts, you can encrypt the search field before performing the query. + +First of all you must define your encryp/decrypt functions and pass then directly in the middleware config. + +The following example shows using the native nodejs crypto module to perform encryption and decryption: + +```ts +import crypto from 'crypto' + +function cipher(decrypted: unknown): string { + const cipher = crypto.createCipheriv( + 'aes-256-gcm', + process.env.CRYPTO_SALT, + process.env.CRYPTO_IV + ) + return cipher.update(decrypted, 'utf-8', 'hex') +} + +function decipher(encrypted: string): unknown { + const decipher = crypto.createDecipheriv( + 'aes-256-gcm', + process.env.CRYPTO_SALT, + process.env.CRYPTO_IV + ) + return decipher.update(encrypted, 'hex', 'utf-8') +} + +client.$use( + fieldEncryptionMiddleware({ + encryptFn: (decrypted: unknown) => cipher(decrypted), + decryptFn: (encrypted: string) => decipher(encrypted) + }) +) +``` + +> _Note: a valid encrypt function must always receive a value(it can be any valid DB data) and return a encrypted string. The opposite is valid for the decryption function._ + ### 3. Annotate your schema In your Prisma schema, add `/// @encrypted` to the fields you want to encrypt: From 9bdb15098e36040e1a897e5f7d1beb7383c2db5d Mon Sep 17 00:00:00 2001 From: vitu-77 Date: Fri, 29 Jul 2022 14:59:22 -0300 Subject: [PATCH 12/12] setting npm publish config --- package.json | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index f80962b..ffdfff6 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,20 @@ { - "name": "prisma-field-encryption", - "version": "0.0.0-semantically-released", - "description": "Transparent field-level encryption at rest for Prisma", + "name": "prisma-custom-field-encryption", + "version": "0.0.1", + "description": "Transparent and customizable field-level encryption at rest for Prisma", "main": "dist/index.js", "types": "dist/index.d.ts", "license": "MIT", "bin": { - "prisma-field-encryption": "./dist/generator/main.js" + "prisma-custom-field-encryption": "./dist/generator/main.js" }, "author": { - "name": "FranΓ§ois Best", - "email": "contact@francoisbest.com", - "url": "https://francoisbest.com" + "name": "Victor Rodrigues", + "email": "marcelo.victor05@gmail.com" }, "repository": { "type": "git", - "url": "https://github.com/47ng/prisma-field-encryption" + "url": "https://github.com/Vitu-77/prisma-field-encryption" }, "keywords": [ "prisma",