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: 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", diff --git a/src/encryption.test.ts b/src/encryption.test.ts index 7cf5006..9b508d2 100644 --- a/src/encryption.test.ts +++ b/src/encryption.test.ts @@ -1,8 +1,32 @@ import { formatKey } from '@47ng/cloak/dist/key' -import { configureKeys } from './encryption' +import { DMMFModels } from 'dmmf' +import { Configuration, MiddlewareParams } from './types' +import { + configureFunctions, + configureKeys, + configureKeysAndFunctions, + decryptOnRead, + encryptOnWrite, + getMethod, + isCustomConfiguration, + isDefaultConfiguration +} from './encryption' import { errors } from './errors' -const TEST_KEY = 'k1.aesgcm256.DbQoar8ZLuUsOHZNyrnjlskInHDYlzF3q6y1KGM7DUM=' +const ENCRYPTION_TEST_KEY = + 'k1.aesgcm256.DbQoar8ZLuUsOHZNyrnjlskInHDYlzF3q6y1KGM7DUM=' +const DECRYPTION_TEST_KEYS = [ + 'k1.aesgcm256.4BNYdJnjOQJP2adq9cGM9kb4dZxDujUs6aPS0VeRtAM=', + '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', () => { @@ -13,47 +37,259 @@ 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_KEYS }) 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_KEYS.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 }) }) + + 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 name = 'value' + const params: MiddlewareParams = { + model: 'User', + action: 'create', + args: { data: { name } }, + runInTransaction: true, + dataPath: ['any'] + } + + const dmmfModels: DMMFModels = { + User: { + connections: { + 'fake-connection': { + modelName: 'User', + isList: false + } + }, + fields: { + name: { + encrypt: true, + strictDecryption: false + } + } + } + } + + const config: Configuration = { + encryptFn: encryptFunction, + decryptFn: () => 'fake-decryption' + } + + const { keys, cipherFunctions, method } = + configureKeysAndFunctions(config) + + encryptOnWrite( + params, + dmmfModels, + 'User.create', + method, + keys, + cipherFunctions?.encryptFn + ) + + expect(encryptFunction).toBeCalledTimes(1) + expect(encryptFunction).toBeCalledWith(name) + }) + }) + + describe('decryptOnRead', () => { + test('Should call custom cypher decrypt function', async () => { + 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 config: Configuration = { + decryptFn: decryptFunction, + encryptFn: () => 'encrypted-text' + } + + const { keys, cipherFunctions, method } = + configureKeysAndFunctions(config) + + const encryptedName = 'a1b2c3d4e5d6' + const result = { name: encryptedName } + + decryptOnRead( + params, + result, + dmmfModels, + 'User.findFirst', + method, + keys, + cipherFunctions?.decryptFn + ) + + expect(decryptFunction).toBeCalledTimes(1) + expect(decryptFunction).toBeCalledWith(encryptedName) + }) + }) }) diff --git a/src/encryption.ts b/src/encryption.ts index dd25205..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 { Configuration, MiddlewareParams } from './types' +import type { + MiddlewareParams, + EncryptionFn, + DecryptionFn, + Configuration, + CipherFunctions, + Keys +} from './types' import { visitInputTargetFields, visitOutputTargetFields } from './visitor' export interface KeysConfiguration { @@ -20,9 +27,91 @@ export interface KeysConfiguration { keychain: CloakKeychain } +export interface FunctionsConfiguration { + encryptFn: EncryptionFn + decryptFn: DecryptionFn +} + +export interface ConfigureKeysParams { + encryptionKey?: string + decryptionKeys?: string[] +} + +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) @@ -35,7 +124,7 @@ export function configureKeys(config: Configuration): KeysConfiguration { const decryptionKeys: string[] = Array.from( new Set([ encryptionKey, - ...(config.decryptionKeys ?? decryptionKeysFromEnv) + ...(configureKeysParams.decryptionKeys ?? decryptionKeysFromEnv) ]) ) @@ -61,9 +150,11 @@ const whereClauseRegExp = /\.where\./ export function encryptOnWrite( params: MiddlewareParams, - keys: KeysConfiguration, models: DMMFModels, - operation: string + operation: string, + method: Method, + keys: KeysConfiguration | null, + encryptFn?: EncryptionFn ) { if (!writeOperations.includes(params.action)) { return params // No input data to encrypt @@ -89,7 +180,20 @@ export function encryptOnWrite( console.warn(warnings.whereClause(operation, path)) } try { - const cipherText = 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) { encryptionErrors.push( @@ -108,9 +212,11 @@ export function encryptOnWrite( export function decryptOnRead( params: MiddlewareParams, result: any, - keys: KeysConfiguration, models: DMMFModels, - operation: string + operation: string, + method: Method, + keys: KeysConfiguration | null, + decryptFn?: DecryptionFn ) { // Analyse the query to see if there's anything to decrypt. const model = models[params.model!] @@ -137,11 +243,27 @@ export function decryptOnRead( field }) { try { - if (!cloakedStringRegex.test(cipherText)) { + if (!decryptFn && !cloakedStringRegex.test(cipherText)) { return } - const decryptionKey = findKeyForMessage(cipherText, keys.keychain) - const clearText = decryptStringSync(cipherText, decryptionKey) + + 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) { const message = errors.fieldDecryptionError(model, field, path, 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 f4bb9e5..afb9d5d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,16 @@ import { analyseDMMF } from './dmmf' -import { configureKeys, decryptOnRead, encryptOnWrite } from './encryption' +import { + decryptOnRead, + encryptOnWrite, + configureKeysAndFunctions +} from './encryption' 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 keys = configureKeys(config) + const { cipherFunctions, keys, method } = configureKeysAndFunctions(config) + const models = analyseDMMF() return async function fieldEncryptionMiddleware( @@ -21,9 +24,28 @@ 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, + models, + operation, + method, + keys, + cipherFunctions?.encryptFn + ) + let result = await next(encryptedParams) - decryptOnRead(encryptedParams, result, keys, models, operation) + + decryptOnRead( + encryptedParams, + result, + models, + operation, + 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 b6a641d..cc97256 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,11 +8,25 @@ export type DMMF = typeof Prisma.dmmf // Internal types -- -export interface Configuration { +export type EncryptionFn = (clearText: any) => string +export type DecryptionFn = (cipherText: string) => any + +export type CipherFunctions = { + encryptFn: EncryptionFn + decryptFn: DecryptionFn + encryptionKey?: never + decryptionKeys?: never +} + +export type Keys = { encryptionKey?: string decryptionKeys?: string[] + encryptFn?: never + decryptFn?: never } +export type Configuration = CipherFunctions | Keys | {} + export interface FieldConfiguration { encrypt: boolean strictDecryption: boolean