From 23a06cc8a254235f923328e050217c52584761de Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Fri, 20 Dec 2024 15:39:01 -0300 Subject: [PATCH 01/20] feat: add encrypted kind --- .../src/enhancements/node/create-enhancement.ts | 11 +++++++++-- packages/runtime/src/types.ts | 2 +- packages/schema/src/res/stdlib.zmodel | 8 ++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/enhancements/node/create-enhancement.ts b/packages/runtime/src/enhancements/node/create-enhancement.ts index adec1fdf2..b5897d17e 100644 --- a/packages/runtime/src/enhancements/node/create-enhancement.ts +++ b/packages/runtime/src/enhancements/node/create-enhancement.ts @@ -14,13 +14,14 @@ import { withJsonProcessor } from './json-processor'; import { Logger } from './logger'; import { withOmit } from './omit'; import { withPassword } from './password'; +import { withEncrypted } from './encrypted'; import { policyProcessIncludeRelationPayload, withPolicy } from './policy'; import type { PolicyDef } from './types'; /** * All enhancement kinds */ -const ALL_ENHANCEMENTS: EnhancementKind[] = ['password', 'omit', 'policy', 'validation', 'delegate']; +const ALL_ENHANCEMENTS: EnhancementKind[] = ['password', 'omit', 'policy', 'validation', 'delegate', 'encrypted']; /** * Options for {@link createEnhancement} @@ -100,6 +101,7 @@ export function createEnhancement( } const hasPassword = allFields.some((field) => field.attributes?.some((attr) => attr.name === '@password')); + const hasEncrypted = allFields.some((field) => field.attributes?.some((attr) => attr.name === '@encrypted')); const hasOmit = allFields.some((field) => field.attributes?.some((attr) => attr.name === '@omit')); const hasDefaultAuth = allFields.some((field) => field.defaultValueProvider); const hasTypeDefField = allFields.some((field) => field.isTypeDef); @@ -120,13 +122,18 @@ export function createEnhancement( } } - // password enhancement must be applied prior to policy because it changes then length of the field + // password and encrypted enhancement must be applied prior to policy because it changes then length of the field // and can break validation rules like `@length` if (hasPassword && kinds.includes('password')) { // @password proxy result = withPassword(result, options); } + if (hasEncrypted && kinds.includes('encrypted')) { + // @encrypted proxy + result = withEncrypted(result, options); + } + // 'policy' and 'validation' enhancements are both enabled by `withPolicy` if (kinds.includes('policy') || kinds.includes('validation')) { result = withPolicy(result, options, context); diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index 7c4df97c1..a3e319368 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -145,7 +145,7 @@ export type EnhancementContext = { /** * Kinds of enhancements to `PrismaClient` */ -export type EnhancementKind = 'password' | 'omit' | 'policy' | 'validation' | 'delegate'; +export type EnhancementKind = 'password' | 'omit' | 'policy' | 'validation' | 'delegate' | 'encrypted'; /** * Function for transforming errors. diff --git a/packages/schema/src/res/stdlib.zmodel b/packages/schema/src/res/stdlib.zmodel index 3316a90a9..65c36653a 100644 --- a/packages/schema/src/res/stdlib.zmodel +++ b/packages/schema/src/res/stdlib.zmodel @@ -552,6 +552,14 @@ attribute @@auth() @@@supportTypeDef */ attribute @password(saltLength: Int?, salt: String?) @@@targetField([StringField]) + +/** + * Indicates that the field is encrypted when storing in the DB and should be decrypted when read + * + * ZenStack uses the Web Crypto API to encrypt and decrypt the field. + */ +attribute @encrypted(secret: String) @@@targetField([StringField]) + /** * Indicates that the field should be omitted when read from the generated services. */ From 77840990b2223c14b23cf42004a0639e7adaf535 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Fri, 20 Dec 2024 15:58:47 -0300 Subject: [PATCH 02/20] chore: add encrypt function --- .../src/enhancements/node/encrypted.ts | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 packages/runtime/src/enhancements/node/encrypted.ts diff --git a/packages/runtime/src/enhancements/node/encrypted.ts b/packages/runtime/src/enhancements/node/encrypted.ts new file mode 100644 index 000000000..55a2e8cb5 --- /dev/null +++ b/packages/runtime/src/enhancements/node/encrypted.ts @@ -0,0 +1,109 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import { NestedWriteVisitor, type PrismaWriteActionType } from '../../cross'; +import { DbClientContract } from '../../types'; +import { InternalEnhancementOptions } from './create-enhancement'; +import { DefaultPrismaProxyHandler, PrismaProxyActions, makeProxy } from './proxy'; + +/** + * Gets an enhanced Prisma client that supports `@encrypted` attribute. + * + * @private + */ +export function withEncrypted( + prisma: DbClient, + options: InternalEnhancementOptions +): DbClient { + return makeProxy( + prisma, + options.modelMeta, + (_prisma, model) => new EncryptedHandler(_prisma as DbClientContract, model, options), + 'encrypted' + ); +} + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +const getKey = async (secret: string): Promise => { + return crypto.subtle.importKey('raw', encoder.encode(secret).slice(0, 32), 'AES-GCM', false, [ + 'encrypt', + 'decrypt', + ]); +}; +const encryptFunc = async (data: string, secret: string): Promise => { + const key = await getKey(secret); + const iv = crypto.getRandomValues(new Uint8Array(12)); + + const encrypted = await crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv, + }, + key, + encoder.encode(data) + ); + + // Combine IV and encrypted data into a single array of bytes + const bytes = [...iv, ...new Uint8Array(encrypted)]; + + // Convert bytes to base64 string + return btoa(String.fromCharCode(...bytes)); +}; + +const decryptFunc = async (encryptedData: string, secret: string): Promise => { + const key = await getKey(secret); + + // Convert base64 back to bytes + const bytes = Uint8Array.from(atob(encryptedData), (c) => c.charCodeAt(0)); + + // First 12 bytes are IV, rest is encrypted data + const decrypted = await crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: bytes.slice(0, 12), + }, + key, + bytes.slice(12) + ); + + return decoder.decode(decrypted); +}; + +class EncryptedHandler extends DefaultPrismaProxyHandler { + constructor(prisma: DbClientContract, model: string, options: InternalEnhancementOptions) { + super(prisma, model, options); + } + + // base override + protected async preprocessArgs(action: PrismaProxyActions, args: any) { + const actionsOfInterest: PrismaProxyActions[] = ['create', 'createMany', 'update', 'updateMany', 'upsert']; + if (args && args.data && actionsOfInterest.includes(action)) { + await this.preprocessWritePayload(this.model, action as PrismaWriteActionType, args); + } + return args; + } + + // base override + protected async processResultEntity(action: PrismaProxyActions, args: any) { + return args; + } + + private async preprocessWritePayload(model: string, action: PrismaWriteActionType, args: any) { + const visitor = new NestedWriteVisitor(this.options.modelMeta, { + field: async (field, _action, data, context) => { + const encAttr = field.attributes?.find((attr) => attr.name === '@encrypted'); + if (encAttr && field.type === 'String') { + // encrypt value + + let secret: string = encAttr.args.find((arg) => arg.name === 'secret')?.value as string; + + context.parent[field.name] = await encryptFunc(data, secret); + } + }, + }); + + await visitor.visit(model, action, args); + } +} From f8ee2045c91e9ab555a3f991aaef92d904872fec Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Fri, 20 Dec 2024 16:01:15 -0300 Subject: [PATCH 03/20] test: add integration tests for encrypted model functionality --- .../with-encrypted/with-encrypted.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 tests/integration/tests/enhancements/with-encrypted/with-encrypted.test.ts diff --git a/tests/integration/tests/enhancements/with-encrypted/with-encrypted.test.ts b/tests/integration/tests/enhancements/with-encrypted/with-encrypted.test.ts new file mode 100644 index 000000000..63c80cccc --- /dev/null +++ b/tests/integration/tests/enhancements/with-encrypted/with-encrypted.test.ts @@ -0,0 +1,34 @@ +import { loadSchema } from '@zenstackhq/testtools'; +import path from 'path'; + +describe('Encrypted test', () => { + let origDir: string; + + beforeAll(async () => { + origDir = path.resolve('.'); + }); + + afterEach(async () => { + process.chdir(origDir); + }); + + it('encrypted tests', async () => { + const { enhance } = await loadSchema(` + model User { + id String @id @default(cuid()) + encrypted_value String @encrypted(saltLength: 16) + + @@allow('all', true) + }`); + + const db = enhance(); + const r = await db.user.create({ + data: { + id: '1', + encrypted_value: 'abc123', + }, + }); + + expect(r.encrypted_value).toBe('abc123'); + }); +}); From 6bff7f43cfd906a9f98cdcfd1c8de935c5c2c1bb Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Fri, 20 Dec 2024 17:13:07 -0300 Subject: [PATCH 04/20] test: Add test --- .../src/enhancements/edge/encrypted.ts | 141 ++++++++++++++++++ .../src/enhancements/node/encrypted.ts | 40 ++++- .../with-encrypted/with-encrypted.test.ts | 16 +- 3 files changed, 190 insertions(+), 7 deletions(-) create mode 100644 packages/runtime/src/enhancements/edge/encrypted.ts diff --git a/packages/runtime/src/enhancements/edge/encrypted.ts b/packages/runtime/src/enhancements/edge/encrypted.ts new file mode 100644 index 000000000..4fcb64dd0 --- /dev/null +++ b/packages/runtime/src/enhancements/edge/encrypted.ts @@ -0,0 +1,141 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import { NestedWriteVisitor, enumerate, getModelFields, resolveField, type PrismaWriteActionType } from '../../cross'; +import { DbClientContract } from '../../types'; +import { InternalEnhancementOptions } from './create-enhancement'; +import { DefaultPrismaProxyHandler, PrismaProxyActions, makeProxy } from './proxy'; +import { QueryUtils } from './query-utils'; + +/** + * Gets an enhanced Prisma client that supports `@encrypted` attribute. + * + * @private + */ +export function withEncrypted( + prisma: DbClient, + options: InternalEnhancementOptions +): DbClient { + return makeProxy( + prisma, + options.modelMeta, + (_prisma, model) => new EncryptedHandler(_prisma as DbClientContract, model, options), + 'encrypted' + ); +} + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +const getKey = async (secret: string): Promise => { + return crypto.subtle.importKey('raw', encoder.encode(secret).slice(0, 32), 'AES-GCM', false, [ + 'encrypt', + 'decrypt', + ]); +}; +const encryptFunc = async (data: string, secret: string): Promise => { + const key = await getKey(secret); + const iv = crypto.getRandomValues(new Uint8Array(12)); + + const encrypted = await crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv, + }, + key, + encoder.encode(data) + ); + + // Combine IV and encrypted data into a single array of bytes + const bytes = [...iv, ...new Uint8Array(encrypted)]; + + // Convert bytes to base64 string + return btoa(String.fromCharCode(...bytes)); +}; + +const decryptFunc = async (encryptedData: string, secret: string): Promise => { + const key = await getKey(secret); + + // Convert base64 back to bytes + const bytes = Uint8Array.from(atob(encryptedData), (c) => c.charCodeAt(0)); + + // First 12 bytes are IV, rest is encrypted data + const decrypted = await crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: bytes.slice(0, 12), + }, + key, + bytes.slice(12) + ); + + return decoder.decode(decrypted); +}; + +class EncryptedHandler extends DefaultPrismaProxyHandler { + private queryUtils: QueryUtils; + + constructor(prisma: DbClientContract, model: string, options: InternalEnhancementOptions) { + super(prisma, model, options); + + this.queryUtils = new QueryUtils(prisma, options); + } + + // base override + protected async preprocessArgs(action: PrismaProxyActions, args: any) { + const actionsOfInterest: PrismaProxyActions[] = ['create', 'createMany', 'update', 'updateMany', 'upsert']; + if (args && args.data && actionsOfInterest.includes(action)) { + await this.preprocessWritePayload(this.model, action as PrismaWriteActionType, args); + } + return args; + } + + // base override + protected async processResultEntity(method: PrismaProxyActions, data: T): Promise { + if (!data || typeof data !== 'object') { + return data; + } + + for (const value of enumerate(data)) { + await this.doPostProcess(value, this.model); + } + + return data; + } + + private async doPostProcess(entityData: any, model: string) { + const realModel = this.queryUtils.getDelegateConcreteModel(model, entityData); + + for (const field of getModelFields(entityData)) { + const fieldInfo = await resolveField(this.options.modelMeta, realModel, field); + + if (!fieldInfo) { + continue; + } + + const shouldDecrypt = fieldInfo.attributes?.find((attr) => attr.name === '@encrypted'); + if (shouldDecrypt) { + const descryptSecret = shouldDecrypt.args.find((arg) => arg.name === 'secret')?.value as string; + + entityData[field] = await decryptFunc(entityData[field], descryptSecret); + } + } + } + + private async preprocessWritePayload(model: string, action: PrismaWriteActionType, args: any) { + const visitor = new NestedWriteVisitor(this.options.modelMeta, { + field: async (field, _action, data, context) => { + const encAttr = field.attributes?.find((attr) => attr.name === '@encrypted'); + if (encAttr && field.type === 'String') { + // encrypt value + + const secret: string = encAttr.args.find((arg) => arg.name === 'secret')?.value as string; + + context.parent[field.name] = await encryptFunc(data, secret); + } + }, + }); + + await visitor.visit(model, action, args); + } +} diff --git a/packages/runtime/src/enhancements/node/encrypted.ts b/packages/runtime/src/enhancements/node/encrypted.ts index 55a2e8cb5..4fcb64dd0 100644 --- a/packages/runtime/src/enhancements/node/encrypted.ts +++ b/packages/runtime/src/enhancements/node/encrypted.ts @@ -1,10 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { NestedWriteVisitor, type PrismaWriteActionType } from '../../cross'; +import { NestedWriteVisitor, enumerate, getModelFields, resolveField, type PrismaWriteActionType } from '../../cross'; import { DbClientContract } from '../../types'; import { InternalEnhancementOptions } from './create-enhancement'; import { DefaultPrismaProxyHandler, PrismaProxyActions, makeProxy } from './proxy'; +import { QueryUtils } from './query-utils'; /** * Gets an enhanced Prisma client that supports `@encrypted` attribute. @@ -72,8 +73,12 @@ const decryptFunc = async (encryptedData: string, secret: string): Promise(method: PrismaProxyActions, data: T): Promise { + if (!data || typeof data !== 'object') { + return data; + } + + for (const value of enumerate(data)) { + await this.doPostProcess(value, this.model); + } + + return data; + } + + private async doPostProcess(entityData: any, model: string) { + const realModel = this.queryUtils.getDelegateConcreteModel(model, entityData); + + for (const field of getModelFields(entityData)) { + const fieldInfo = await resolveField(this.options.modelMeta, realModel, field); + + if (!fieldInfo) { + continue; + } + + const shouldDecrypt = fieldInfo.attributes?.find((attr) => attr.name === '@encrypted'); + if (shouldDecrypt) { + const descryptSecret = shouldDecrypt.args.find((arg) => arg.name === 'secret')?.value as string; + + entityData[field] = await decryptFunc(entityData[field], descryptSecret); + } + } } private async preprocessWritePayload(model: string, action: PrismaWriteActionType, args: any) { @@ -97,7 +129,7 @@ class EncryptedHandler extends DefaultPrismaProxyHandler { if (encAttr && field.type === 'String') { // encrypt value - let secret: string = encAttr.args.find((arg) => arg.name === 'secret')?.value as string; + const secret: string = encAttr.args.find((arg) => arg.name === 'secret')?.value as string; context.parent[field.name] = await encryptFunc(data, secret); } diff --git a/tests/integration/tests/enhancements/with-encrypted/with-encrypted.test.ts b/tests/integration/tests/enhancements/with-encrypted/with-encrypted.test.ts index 63c80cccc..5fc1c065d 100644 --- a/tests/integration/tests/enhancements/with-encrypted/with-encrypted.test.ts +++ b/tests/integration/tests/enhancements/with-encrypted/with-encrypted.test.ts @@ -13,22 +13,32 @@ describe('Encrypted test', () => { }); it('encrypted tests', async () => { + const ENCRYPTION_KEY = 'c558Gq0YQK2QcqtkMF9BGXHCQn4dMF8w'; + const { enhance } = await loadSchema(` model User { id String @id @default(cuid()) - encrypted_value String @encrypted(saltLength: 16) + encrypted_value String @encrypted(secret: "${ENCRYPTION_KEY}") @@allow('all', true) }`); const db = enhance(); - const r = await db.user.create({ + + const create = await db.user.create({ data: { id: '1', encrypted_value: 'abc123', }, }); - expect(r.encrypted_value).toBe('abc123'); + const read = await db.user.findUnique({ + where: { + id: '1', + }, + }); + + expect(create.encrypted_value).toBe('abc123'); + expect(read.encrypted_value).toBe('abc123'); }); }); From b86e81471c28001023798feb41d10c49c47afa5f Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Mon, 23 Dec 2024 13:37:19 -0300 Subject: [PATCH 05/20] fix: require encryption options for @encrypted enhancement --- packages/runtime/src/enhancements/node/create-enhancement.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/runtime/src/enhancements/node/create-enhancement.ts b/packages/runtime/src/enhancements/node/create-enhancement.ts index b5897d17e..871f8a1b4 100644 --- a/packages/runtime/src/enhancements/node/create-enhancement.ts +++ b/packages/runtime/src/enhancements/node/create-enhancement.ts @@ -130,6 +130,10 @@ export function createEnhancement( } if (hasEncrypted && kinds.includes('encrypted')) { + if (!options.encryption) { + throw new Error('Encryption options are required for @encrypted enhancement'); + } + // @encrypted proxy result = withEncrypted(result, options); } From e0789b74c4df31439e20f0eb92e830dfbe33f979 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Mon, 23 Dec 2024 13:37:55 -0300 Subject: [PATCH 06/20] feat: enhance encryption handling in EncryptedHandler and update schema attribute --- .../src/enhancements/node/encrypted.ts | 104 ++++++++++-------- packages/runtime/src/types.ts | 13 +++ packages/schema/src/res/stdlib.zmodel | 2 +- packages/testtools/src/schema.ts | 2 +- .../with-encrypted/with-encrypted.test.ts | 6 +- 5 files changed, 78 insertions(+), 49 deletions(-) diff --git a/packages/runtime/src/enhancements/node/encrypted.ts b/packages/runtime/src/enhancements/node/encrypted.ts index 4fcb64dd0..3d758edb6 100644 --- a/packages/runtime/src/enhancements/node/encrypted.ts +++ b/packages/runtime/src/enhancements/node/encrypted.ts @@ -1,8 +1,15 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { NestedWriteVisitor, enumerate, getModelFields, resolveField, type PrismaWriteActionType } from '../../cross'; -import { DbClientContract } from '../../types'; +import { + FieldInfo, + NestedWriteVisitor, + enumerate, + getModelFields, + resolveField, + type PrismaWriteActionType, +} from '../../cross'; +import { DbClientContract, CustomEncryption, SimpleEncryption } from '../../types'; import { InternalEnhancementOptions } from './create-enhancement'; import { DefaultPrismaProxyHandler, PrismaProxyActions, makeProxy } from './proxy'; import { QueryUtils } from './query-utils'; @@ -33,52 +40,65 @@ const getKey = async (secret: string): Promise => { 'decrypt', ]); }; -const encryptFunc = async (data: string, secret: string): Promise => { - const key = await getKey(secret); - const iv = crypto.getRandomValues(new Uint8Array(12)); - - const encrypted = await crypto.subtle.encrypt( - { - name: 'AES-GCM', - iv, - }, - key, - encoder.encode(data) - ); - // Combine IV and encrypted data into a single array of bytes - const bytes = [...iv, ...new Uint8Array(encrypted)]; +class EncryptedHandler extends DefaultPrismaProxyHandler { + private queryUtils: QueryUtils; - // Convert bytes to base64 string - return btoa(String.fromCharCode(...bytes)); -}; + constructor(prisma: DbClientContract, model: string, options: InternalEnhancementOptions) { + super(prisma, model, options); -const decryptFunc = async (encryptedData: string, secret: string): Promise => { - const key = await getKey(secret); + this.queryUtils = new QueryUtils(prisma, options); + } - // Convert base64 back to bytes - const bytes = Uint8Array.from(atob(encryptedData), (c) => c.charCodeAt(0)); + private isCustomEncryption(encryption: CustomEncryption | SimpleEncryption): encryption is CustomEncryption { + return 'encrypt' in encryption && 'decrypt' in encryption; + } - // First 12 bytes are IV, rest is encrypted data - const decrypted = await crypto.subtle.decrypt( - { - name: 'AES-GCM', - iv: bytes.slice(0, 12), - }, - key, - bytes.slice(12) - ); + private async encrypt(field: FieldInfo, data: string): Promise { + if (this.isCustomEncryption(this.options.encryption!)) { + return this.options.encryption.encrypt(this.model, field, data); + } - return decoder.decode(decrypted); -}; + const key = await getKey(this.options.encryption!.encryptionKey); + const iv = crypto.getRandomValues(new Uint8Array(12)); -class EncryptedHandler extends DefaultPrismaProxyHandler { - private queryUtils: QueryUtils; + const encrypted = await crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv, + }, + key, + encoder.encode(data) + ); - constructor(prisma: DbClientContract, model: string, options: InternalEnhancementOptions) { - super(prisma, model, options); + // Combine IV and encrypted data into a single array of bytes + const bytes = [...iv, ...new Uint8Array(encrypted)]; - this.queryUtils = new QueryUtils(prisma, options); + // Convert bytes to base64 string + return btoa(String.fromCharCode(...bytes)); + } + + private async decrypt(field: FieldInfo, data: string): Promise { + if (this.isCustomEncryption(this.options.encryption!)) { + return this.options.encryption.decrypt(this.model, field, data); + } + + const key = await getKey(this.options.encryption!.encryptionKey); + + // Convert base64 back to bytes + const bytes = Uint8Array.from(atob(data), (c) => c.charCodeAt(0)); + + // First 12 bytes are IV, rest is encrypted data + const decrypted = await crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: bytes.slice(0, 12), + }, + key, + bytes.slice(12) + ); + + return decoder.decode(decrypted); } // base override @@ -115,9 +135,7 @@ class EncryptedHandler extends DefaultPrismaProxyHandler { const shouldDecrypt = fieldInfo.attributes?.find((attr) => attr.name === '@encrypted'); if (shouldDecrypt) { - const descryptSecret = shouldDecrypt.args.find((arg) => arg.name === 'secret')?.value as string; - - entityData[field] = await decryptFunc(entityData[field], descryptSecret); + entityData[field] = await this.decrypt(fieldInfo, entityData[field]); } } } @@ -131,7 +149,7 @@ class EncryptedHandler extends DefaultPrismaProxyHandler { const secret: string = encAttr.args.find((arg) => arg.name === 'secret')?.value as string; - context.parent[field.name] = await encryptFunc(data, secret); + context.parent[field.name] = await this.encrypt(field, data); } }, }); diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index a3e319368..6e4e7f2d1 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { z } from 'zod'; +import { FieldInfo } from './cross'; export type PrismaPromise = Promise & Record PrismaPromise>; @@ -133,6 +134,11 @@ export type EnhancementOptions = { * The `isolationLevel` option passed to `prisma.$transaction()` call for transactions initiated by ZenStack. */ transactionIsolationLevel?: TransactionIsolationLevel; + + /** + * The encryption options for using the `encrypted` enhancement. + */ + encryption?: SimpleEncryption | CustomEncryption; }; /** @@ -166,3 +172,10 @@ export type ZodSchemas = { */ input?: Record>; }; + +export type CustomEncryption = { + encrypt: (model: string, field: FieldInfo, plain: string) => string; + decrypt: (model: string, field: FieldInfo, cipher: string) => string; +}; + +export type SimpleEncryption = { encryptionKey: string }; diff --git a/packages/schema/src/res/stdlib.zmodel b/packages/schema/src/res/stdlib.zmodel index 65c36653a..3f1ba0efc 100644 --- a/packages/schema/src/res/stdlib.zmodel +++ b/packages/schema/src/res/stdlib.zmodel @@ -558,7 +558,7 @@ attribute @password(saltLength: Int?, salt: String?) @@@targetField([StringField * * ZenStack uses the Web Crypto API to encrypt and decrypt the field. */ -attribute @encrypted(secret: String) @@@targetField([StringField]) +attribute @encrypted() @@@targetField([StringField]) /** * Indicates that the field should be omitted when read from the generated services. diff --git a/packages/testtools/src/schema.ts b/packages/testtools/src/schema.ts index 4a7b575bd..f25ab11fb 100644 --- a/packages/testtools/src/schema.ts +++ b/packages/testtools/src/schema.ts @@ -241,7 +241,7 @@ export async function loadSchema(schema: string, options?: SchemaLoadOptions) { } ); } else { - run(`npx zenstack generate --no-version-check --no-dependency-check${outputArg}${otherArgs}`, { + run(`encryption_key=c558Gq0YQK2QcqtkMF9BGXHCQn4dMF8w npx zenstack generate --no-version-check --no-dependency-check${outputArg}${otherArgs}`, { NODE_PATH: './node_modules', }); } diff --git a/tests/integration/tests/enhancements/with-encrypted/with-encrypted.test.ts b/tests/integration/tests/enhancements/with-encrypted/with-encrypted.test.ts index 5fc1c065d..e22f30b99 100644 --- a/tests/integration/tests/enhancements/with-encrypted/with-encrypted.test.ts +++ b/tests/integration/tests/enhancements/with-encrypted/with-encrypted.test.ts @@ -13,17 +13,15 @@ describe('Encrypted test', () => { }); it('encrypted tests', async () => { - const ENCRYPTION_KEY = 'c558Gq0YQK2QcqtkMF9BGXHCQn4dMF8w'; - const { enhance } = await loadSchema(` model User { id String @id @default(cuid()) - encrypted_value String @encrypted(secret: "${ENCRYPTION_KEY}") + encrypted_value String @encrypted() @@allow('all', true) }`); - const db = enhance(); + const db = enhance(undefined, { encryption: { encryptionKey: 'c558Gq0YQK2QcqtkMF9BGXHCQn4dMF8w' } }); const create = await db.user.create({ data: { From 688d92d92a6becf816703eaee720c314959d79f4 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Mon, 23 Dec 2024 13:41:14 -0300 Subject: [PATCH 07/20] fix: remove hardcoded encryption key from schema loading command --- packages/testtools/src/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/testtools/src/schema.ts b/packages/testtools/src/schema.ts index f25ab11fb..4a7b575bd 100644 --- a/packages/testtools/src/schema.ts +++ b/packages/testtools/src/schema.ts @@ -241,7 +241,7 @@ export async function loadSchema(schema: string, options?: SchemaLoadOptions) { } ); } else { - run(`encryption_key=c558Gq0YQK2QcqtkMF9BGXHCQn4dMF8w npx zenstack generate --no-version-check --no-dependency-check${outputArg}${otherArgs}`, { + run(`npx zenstack generate --no-version-check --no-dependency-check${outputArg}${otherArgs}`, { NODE_PATH: './node_modules', }); } From aedbd93705a9e197be795251790d1609e0db72c2 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Mon, 23 Dec 2024 13:59:34 -0300 Subject: [PATCH 08/20] feat: implement custom encryption handling in EncryptedHandler --- .../src/enhancements/edge/encrypted.ts | 104 ++++++++++-------- 1 file changed, 61 insertions(+), 43 deletions(-) diff --git a/packages/runtime/src/enhancements/edge/encrypted.ts b/packages/runtime/src/enhancements/edge/encrypted.ts index 4fcb64dd0..3d758edb6 100644 --- a/packages/runtime/src/enhancements/edge/encrypted.ts +++ b/packages/runtime/src/enhancements/edge/encrypted.ts @@ -1,8 +1,15 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { NestedWriteVisitor, enumerate, getModelFields, resolveField, type PrismaWriteActionType } from '../../cross'; -import { DbClientContract } from '../../types'; +import { + FieldInfo, + NestedWriteVisitor, + enumerate, + getModelFields, + resolveField, + type PrismaWriteActionType, +} from '../../cross'; +import { DbClientContract, CustomEncryption, SimpleEncryption } from '../../types'; import { InternalEnhancementOptions } from './create-enhancement'; import { DefaultPrismaProxyHandler, PrismaProxyActions, makeProxy } from './proxy'; import { QueryUtils } from './query-utils'; @@ -33,52 +40,65 @@ const getKey = async (secret: string): Promise => { 'decrypt', ]); }; -const encryptFunc = async (data: string, secret: string): Promise => { - const key = await getKey(secret); - const iv = crypto.getRandomValues(new Uint8Array(12)); - - const encrypted = await crypto.subtle.encrypt( - { - name: 'AES-GCM', - iv, - }, - key, - encoder.encode(data) - ); - // Combine IV and encrypted data into a single array of bytes - const bytes = [...iv, ...new Uint8Array(encrypted)]; +class EncryptedHandler extends DefaultPrismaProxyHandler { + private queryUtils: QueryUtils; - // Convert bytes to base64 string - return btoa(String.fromCharCode(...bytes)); -}; + constructor(prisma: DbClientContract, model: string, options: InternalEnhancementOptions) { + super(prisma, model, options); -const decryptFunc = async (encryptedData: string, secret: string): Promise => { - const key = await getKey(secret); + this.queryUtils = new QueryUtils(prisma, options); + } - // Convert base64 back to bytes - const bytes = Uint8Array.from(atob(encryptedData), (c) => c.charCodeAt(0)); + private isCustomEncryption(encryption: CustomEncryption | SimpleEncryption): encryption is CustomEncryption { + return 'encrypt' in encryption && 'decrypt' in encryption; + } - // First 12 bytes are IV, rest is encrypted data - const decrypted = await crypto.subtle.decrypt( - { - name: 'AES-GCM', - iv: bytes.slice(0, 12), - }, - key, - bytes.slice(12) - ); + private async encrypt(field: FieldInfo, data: string): Promise { + if (this.isCustomEncryption(this.options.encryption!)) { + return this.options.encryption.encrypt(this.model, field, data); + } - return decoder.decode(decrypted); -}; + const key = await getKey(this.options.encryption!.encryptionKey); + const iv = crypto.getRandomValues(new Uint8Array(12)); -class EncryptedHandler extends DefaultPrismaProxyHandler { - private queryUtils: QueryUtils; + const encrypted = await crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv, + }, + key, + encoder.encode(data) + ); - constructor(prisma: DbClientContract, model: string, options: InternalEnhancementOptions) { - super(prisma, model, options); + // Combine IV and encrypted data into a single array of bytes + const bytes = [...iv, ...new Uint8Array(encrypted)]; - this.queryUtils = new QueryUtils(prisma, options); + // Convert bytes to base64 string + return btoa(String.fromCharCode(...bytes)); + } + + private async decrypt(field: FieldInfo, data: string): Promise { + if (this.isCustomEncryption(this.options.encryption!)) { + return this.options.encryption.decrypt(this.model, field, data); + } + + const key = await getKey(this.options.encryption!.encryptionKey); + + // Convert base64 back to bytes + const bytes = Uint8Array.from(atob(data), (c) => c.charCodeAt(0)); + + // First 12 bytes are IV, rest is encrypted data + const decrypted = await crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: bytes.slice(0, 12), + }, + key, + bytes.slice(12) + ); + + return decoder.decode(decrypted); } // base override @@ -115,9 +135,7 @@ class EncryptedHandler extends DefaultPrismaProxyHandler { const shouldDecrypt = fieldInfo.attributes?.find((attr) => attr.name === '@encrypted'); if (shouldDecrypt) { - const descryptSecret = shouldDecrypt.args.find((arg) => arg.name === 'secret')?.value as string; - - entityData[field] = await decryptFunc(entityData[field], descryptSecret); + entityData[field] = await this.decrypt(fieldInfo, entityData[field]); } } } @@ -131,7 +149,7 @@ class EncryptedHandler extends DefaultPrismaProxyHandler { const secret: string = encAttr.args.find((arg) => arg.name === 'secret')?.value as string; - context.parent[field.name] = await encryptFunc(data, secret); + context.parent[field.name] = await this.encrypt(field, data); } }, }); From 8752f0631a3fb7811fced30641eb73b4e8af7e94 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Mon, 23 Dec 2024 14:30:24 -0300 Subject: [PATCH 09/20] fix: update encryption methods to return promises in EncryptedHandler --- packages/runtime/src/enhancements/node/encrypted.ts | 8 ++------ packages/runtime/src/types.ts | 4 ++-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/runtime/src/enhancements/node/encrypted.ts b/packages/runtime/src/enhancements/node/encrypted.ts index 3d758edb6..2b47ed551 100644 --- a/packages/runtime/src/enhancements/node/encrypted.ts +++ b/packages/runtime/src/enhancements/node/encrypted.ts @@ -56,7 +56,7 @@ class EncryptedHandler extends DefaultPrismaProxyHandler { private async encrypt(field: FieldInfo, data: string): Promise { if (this.isCustomEncryption(this.options.encryption!)) { - return this.options.encryption.encrypt(this.model, field, data); + return await this.options.encryption.encrypt(this.model, field, data); } const key = await getKey(this.options.encryption!.encryptionKey); @@ -80,7 +80,7 @@ class EncryptedHandler extends DefaultPrismaProxyHandler { private async decrypt(field: FieldInfo, data: string): Promise { if (this.isCustomEncryption(this.options.encryption!)) { - return this.options.encryption.decrypt(this.model, field, data); + return await this.options.encryption.decrypt(this.model, field, data); } const key = await getKey(this.options.encryption!.encryptionKey); @@ -145,10 +145,6 @@ class EncryptedHandler extends DefaultPrismaProxyHandler { field: async (field, _action, data, context) => { const encAttr = field.attributes?.find((attr) => attr.name === '@encrypted'); if (encAttr && field.type === 'String') { - // encrypt value - - const secret: string = encAttr.args.find((arg) => arg.name === 'secret')?.value as string; - context.parent[field.name] = await this.encrypt(field, data); } }, diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index 6e4e7f2d1..c2fd37134 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -174,8 +174,8 @@ export type ZodSchemas = { }; export type CustomEncryption = { - encrypt: (model: string, field: FieldInfo, plain: string) => string; - decrypt: (model: string, field: FieldInfo, cipher: string) => string; + encrypt: (model: string, field: FieldInfo, plain: string) => Promise; + decrypt: (model: string, field: FieldInfo, cipher: string) => Promise; }; export type SimpleEncryption = { encryptionKey: string }; From 83c242cd6545c5c9a8483de4c5da9404734df8d3 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Mon, 23 Dec 2024 14:30:31 -0300 Subject: [PATCH 10/20] test: add integration tests for custom encryption handling in EncryptedHandler --- .../with-encrypted/with-encrypted.test.ts | 62 ++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/tests/integration/tests/enhancements/with-encrypted/with-encrypted.test.ts b/tests/integration/tests/enhancements/with-encrypted/with-encrypted.test.ts index e22f30b99..383f74f02 100644 --- a/tests/integration/tests/enhancements/with-encrypted/with-encrypted.test.ts +++ b/tests/integration/tests/enhancements/with-encrypted/with-encrypted.test.ts @@ -1,3 +1,4 @@ +import { FieldInfo } from '@zenstackhq/runtime'; import { loadSchema } from '@zenstackhq/testtools'; import path from 'path'; @@ -12,7 +13,7 @@ describe('Encrypted test', () => { process.chdir(origDir); }); - it('encrypted tests', async () => { + it('Simple encryption test', async () => { const { enhance } = await loadSchema(` model User { id String @id @default(cuid()) @@ -21,6 +22,7 @@ describe('Encrypted test', () => { @@allow('all', true) }`); + const sudoDb = enhance(undefined, { kinds: [] }); const db = enhance(undefined, { encryption: { encryptionKey: 'c558Gq0YQK2QcqtkMF9BGXHCQn4dMF8w' } }); const create = await db.user.create({ @@ -36,7 +38,65 @@ describe('Encrypted test', () => { }, }); + const sudoRead = await sudoDb.user.findUnique({ + where: { + id: '1', + }, + }); + + expect(create.encrypted_value).toBe('abc123'); + expect(read.encrypted_value).toBe('abc123'); + expect(sudoRead.encrypted_value).not.toBe('abc123'); + }); + + it('Custom encryption test', async () => { + const { enhance } = await loadSchema(` + model User { + id String @id @default(cuid()) + encrypted_value String @encrypted() + + @@allow('all', true) + }`); + + const sudoDb = enhance(undefined, { kinds: [] }); + const db = enhance(undefined, { + encryption: { + encrypt: async (model: string, field: FieldInfo, data: string) => { + // Add _enc to the end of the input + return data + '_enc'; + }, + decrypt: async (model: string, field: FieldInfo, cipher: string) => { + // Remove _enc from the end of the input explicitly + if (cipher.endsWith('_enc')) { + return cipher.slice(0, -4); // Remove last 4 characters (_enc) + } + + return cipher; + }, + }, + }); + + const create = await db.user.create({ + data: { + id: '1', + encrypted_value: 'abc123', + }, + }); + + const read = await db.user.findUnique({ + where: { + id: '1', + }, + }); + + const sudoRead = await sudoDb.user.findUnique({ + where: { + id: '1', + }, + }); + expect(create.encrypted_value).toBe('abc123'); expect(read.encrypted_value).toBe('abc123'); + expect(sudoRead.encrypted_value).toBe('abc123_enc'); }); }); From d9b95ef70dd02c1cd98ede9617382e8a5a70ed4f Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Tue, 24 Dec 2024 17:04:24 -0300 Subject: [PATCH 11/20] chore: Add symlink --- .../src/enhancements/edge/encrypted.ts | 160 +----------------- 1 file changed, 1 insertion(+), 159 deletions(-) mode change 100644 => 120000 packages/runtime/src/enhancements/edge/encrypted.ts diff --git a/packages/runtime/src/enhancements/edge/encrypted.ts b/packages/runtime/src/enhancements/edge/encrypted.ts deleted file mode 100644 index 3d758edb6..000000000 --- a/packages/runtime/src/enhancements/edge/encrypted.ts +++ /dev/null @@ -1,159 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/no-unused-vars */ - -import { - FieldInfo, - NestedWriteVisitor, - enumerate, - getModelFields, - resolveField, - type PrismaWriteActionType, -} from '../../cross'; -import { DbClientContract, CustomEncryption, SimpleEncryption } from '../../types'; -import { InternalEnhancementOptions } from './create-enhancement'; -import { DefaultPrismaProxyHandler, PrismaProxyActions, makeProxy } from './proxy'; -import { QueryUtils } from './query-utils'; - -/** - * Gets an enhanced Prisma client that supports `@encrypted` attribute. - * - * @private - */ -export function withEncrypted( - prisma: DbClient, - options: InternalEnhancementOptions -): DbClient { - return makeProxy( - prisma, - options.modelMeta, - (_prisma, model) => new EncryptedHandler(_prisma as DbClientContract, model, options), - 'encrypted' - ); -} - -const encoder = new TextEncoder(); -const decoder = new TextDecoder(); - -const getKey = async (secret: string): Promise => { - return crypto.subtle.importKey('raw', encoder.encode(secret).slice(0, 32), 'AES-GCM', false, [ - 'encrypt', - 'decrypt', - ]); -}; - -class EncryptedHandler extends DefaultPrismaProxyHandler { - private queryUtils: QueryUtils; - - constructor(prisma: DbClientContract, model: string, options: InternalEnhancementOptions) { - super(prisma, model, options); - - this.queryUtils = new QueryUtils(prisma, options); - } - - private isCustomEncryption(encryption: CustomEncryption | SimpleEncryption): encryption is CustomEncryption { - return 'encrypt' in encryption && 'decrypt' in encryption; - } - - private async encrypt(field: FieldInfo, data: string): Promise { - if (this.isCustomEncryption(this.options.encryption!)) { - return this.options.encryption.encrypt(this.model, field, data); - } - - const key = await getKey(this.options.encryption!.encryptionKey); - const iv = crypto.getRandomValues(new Uint8Array(12)); - - const encrypted = await crypto.subtle.encrypt( - { - name: 'AES-GCM', - iv, - }, - key, - encoder.encode(data) - ); - - // Combine IV and encrypted data into a single array of bytes - const bytes = [...iv, ...new Uint8Array(encrypted)]; - - // Convert bytes to base64 string - return btoa(String.fromCharCode(...bytes)); - } - - private async decrypt(field: FieldInfo, data: string): Promise { - if (this.isCustomEncryption(this.options.encryption!)) { - return this.options.encryption.decrypt(this.model, field, data); - } - - const key = await getKey(this.options.encryption!.encryptionKey); - - // Convert base64 back to bytes - const bytes = Uint8Array.from(atob(data), (c) => c.charCodeAt(0)); - - // First 12 bytes are IV, rest is encrypted data - const decrypted = await crypto.subtle.decrypt( - { - name: 'AES-GCM', - iv: bytes.slice(0, 12), - }, - key, - bytes.slice(12) - ); - - return decoder.decode(decrypted); - } - - // base override - protected async preprocessArgs(action: PrismaProxyActions, args: any) { - const actionsOfInterest: PrismaProxyActions[] = ['create', 'createMany', 'update', 'updateMany', 'upsert']; - if (args && args.data && actionsOfInterest.includes(action)) { - await this.preprocessWritePayload(this.model, action as PrismaWriteActionType, args); - } - return args; - } - - // base override - protected async processResultEntity(method: PrismaProxyActions, data: T): Promise { - if (!data || typeof data !== 'object') { - return data; - } - - for (const value of enumerate(data)) { - await this.doPostProcess(value, this.model); - } - - return data; - } - - private async doPostProcess(entityData: any, model: string) { - const realModel = this.queryUtils.getDelegateConcreteModel(model, entityData); - - for (const field of getModelFields(entityData)) { - const fieldInfo = await resolveField(this.options.modelMeta, realModel, field); - - if (!fieldInfo) { - continue; - } - - const shouldDecrypt = fieldInfo.attributes?.find((attr) => attr.name === '@encrypted'); - if (shouldDecrypt) { - entityData[field] = await this.decrypt(fieldInfo, entityData[field]); - } - } - } - - private async preprocessWritePayload(model: string, action: PrismaWriteActionType, args: any) { - const visitor = new NestedWriteVisitor(this.options.modelMeta, { - field: async (field, _action, data, context) => { - const encAttr = field.attributes?.find((attr) => attr.name === '@encrypted'); - if (encAttr && field.type === 'String') { - // encrypt value - - const secret: string = encAttr.args.find((arg) => arg.name === 'secret')?.value as string; - - context.parent[field.name] = await this.encrypt(field, data); - } - }, - }); - - await visitor.visit(model, action, args); - } -} diff --git a/packages/runtime/src/enhancements/edge/encrypted.ts b/packages/runtime/src/enhancements/edge/encrypted.ts new file mode 120000 index 000000000..96d88b82d --- /dev/null +++ b/packages/runtime/src/enhancements/edge/encrypted.ts @@ -0,0 +1 @@ +../node/encrypted.ts \ No newline at end of file From 2ea8bd2e5af458f49af99928faa44050fbac3f63 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Tue, 24 Dec 2024 17:18:56 -0300 Subject: [PATCH 12/20] refactor: streamline encryption handling by moving key retrieval and encoder/decoder initialization into the EncryptedHandler class --- .../src/enhancements/node/encrypted.ts | 36 +++++++++---------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/packages/runtime/src/enhancements/node/encrypted.ts b/packages/runtime/src/enhancements/node/encrypted.ts index 2b47ed551..e0fc2e860 100644 --- a/packages/runtime/src/enhancements/node/encrypted.ts +++ b/packages/runtime/src/enhancements/node/encrypted.ts @@ -1,6 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/no-unused-vars */ - import { FieldInfo, NestedWriteVisitor, @@ -31,18 +28,10 @@ export function withEncrypted( ); } -const encoder = new TextEncoder(); -const decoder = new TextDecoder(); - -const getKey = async (secret: string): Promise => { - return crypto.subtle.importKey('raw', encoder.encode(secret).slice(0, 32), 'AES-GCM', false, [ - 'encrypt', - 'decrypt', - ]); -}; - class EncryptedHandler extends DefaultPrismaProxyHandler { private queryUtils: QueryUtils; + private encoder = new TextEncoder(); + private decoder = new TextDecoder(); constructor(prisma: DbClientContract, model: string, options: InternalEnhancementOptions) { super(prisma, model, options); @@ -50,16 +39,23 @@ class EncryptedHandler extends DefaultPrismaProxyHandler { this.queryUtils = new QueryUtils(prisma, options); } + private async getKey(secret: string): Promise { + return crypto.subtle.importKey('raw', this.encoder.encode(secret).slice(0, 32), 'AES-GCM', false, [ + 'encrypt', + 'decrypt', + ]); + } + private isCustomEncryption(encryption: CustomEncryption | SimpleEncryption): encryption is CustomEncryption { return 'encrypt' in encryption && 'decrypt' in encryption; } private async encrypt(field: FieldInfo, data: string): Promise { if (this.isCustomEncryption(this.options.encryption!)) { - return await this.options.encryption.encrypt(this.model, field, data); + return this.options.encryption.encrypt(this.model, field, data); } - const key = await getKey(this.options.encryption!.encryptionKey); + const key = await this.getKey(this.options.encryption!.encryptionKey); const iv = crypto.getRandomValues(new Uint8Array(12)); const encrypted = await crypto.subtle.encrypt( @@ -68,7 +64,7 @@ class EncryptedHandler extends DefaultPrismaProxyHandler { iv, }, key, - encoder.encode(data) + this.encoder.encode(data) ); // Combine IV and encrypted data into a single array of bytes @@ -80,13 +76,13 @@ class EncryptedHandler extends DefaultPrismaProxyHandler { private async decrypt(field: FieldInfo, data: string): Promise { if (this.isCustomEncryption(this.options.encryption!)) { - return await this.options.encryption.decrypt(this.model, field, data); + return this.options.encryption.decrypt(this.model, field, data); } - const key = await getKey(this.options.encryption!.encryptionKey); + const key = await this.getKey(this.options.encryption!.encryptionKey); // Convert base64 back to bytes - const bytes = Uint8Array.from(atob(data), (c) => c.charCodeAt(0)); + const bytes = Uint8Array.from(atob(data)); // First 12 bytes are IV, rest is encrypted data const decrypted = await crypto.subtle.decrypt( @@ -98,7 +94,7 @@ class EncryptedHandler extends DefaultPrismaProxyHandler { bytes.slice(12) ); - return decoder.decode(decrypted); + return this.decoder.decode(decrypted); } // base override From 78046b3c9659747315002e5c177ef116c1c15638 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Tue, 24 Dec 2024 18:00:14 -0300 Subject: [PATCH 13/20] refactor: don't enable `encrypted` enhancement by default --- .../runtime/src/enhancements/node/create-enhancement.ts | 2 +- .../enhancements/with-encrypted/with-encrypted.test.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/enhancements/node/create-enhancement.ts b/packages/runtime/src/enhancements/node/create-enhancement.ts index 871f8a1b4..7cda742eb 100644 --- a/packages/runtime/src/enhancements/node/create-enhancement.ts +++ b/packages/runtime/src/enhancements/node/create-enhancement.ts @@ -21,7 +21,7 @@ import type { PolicyDef } from './types'; /** * All enhancement kinds */ -const ALL_ENHANCEMENTS: EnhancementKind[] = ['password', 'omit', 'policy', 'validation', 'delegate', 'encrypted']; +const ALL_ENHANCEMENTS: EnhancementKind[] = ['password', 'omit', 'policy', 'validation', 'delegate']; /** * Options for {@link createEnhancement} diff --git a/tests/integration/tests/enhancements/with-encrypted/with-encrypted.test.ts b/tests/integration/tests/enhancements/with-encrypted/with-encrypted.test.ts index 383f74f02..362c2310c 100644 --- a/tests/integration/tests/enhancements/with-encrypted/with-encrypted.test.ts +++ b/tests/integration/tests/enhancements/with-encrypted/with-encrypted.test.ts @@ -23,7 +23,10 @@ describe('Encrypted test', () => { }`); const sudoDb = enhance(undefined, { kinds: [] }); - const db = enhance(undefined, { encryption: { encryptionKey: 'c558Gq0YQK2QcqtkMF9BGXHCQn4dMF8w' } }); + const db = enhance(undefined, { + kinds: ['encrypted'], + encryption: { encryptionKey: 'c558Gq0YQK2QcqtkMF9BGXHCQn4dMF8w' }, + }); const create = await db.user.create({ data: { @@ -60,6 +63,7 @@ describe('Encrypted test', () => { const sudoDb = enhance(undefined, { kinds: [] }); const db = enhance(undefined, { + kinds: ['encrypted'], encryption: { encrypt: async (model: string, field: FieldInfo, data: string) => { // Add _enc to the end of the input From 9d16be0dbe81d2514ab83b7a24638e2a9df83abd Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Tue, 24 Dec 2024 18:00:24 -0300 Subject: [PATCH 14/20] refactor: change encryptionKey type from string to Uint8Array in SimpleEncryption --- packages/runtime/src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index c2fd37134..e691fc32c 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -178,4 +178,4 @@ export type CustomEncryption = { decrypt: (model: string, field: FieldInfo, cipher: string) => Promise; }; -export type SimpleEncryption = { encryptionKey: string }; +export type SimpleEncryption = { encryptionKey: Uint8Array }; From 29b7d15f75532198120a1dd56af92bb2f873e90c Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Tue, 24 Dec 2024 18:00:27 -0300 Subject: [PATCH 15/20] refactor: enhance encryption validation and update key handling in EncryptedHandler --- .../src/enhancements/node/encrypted.ts | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/runtime/src/enhancements/node/encrypted.ts b/packages/runtime/src/enhancements/node/encrypted.ts index e0fc2e860..0ff3ea90c 100644 --- a/packages/runtime/src/enhancements/node/encrypted.ts +++ b/packages/runtime/src/enhancements/node/encrypted.ts @@ -1,3 +1,6 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unused-vars */ + import { FieldInfo, NestedWriteVisitor, @@ -37,13 +40,20 @@ class EncryptedHandler extends DefaultPrismaProxyHandler { super(prisma, model, options); this.queryUtils = new QueryUtils(prisma, options); + + if (!options.encryption) throw new Error('Encryption options must be provided'); + + if (this.isCustomEncryption(options.encryption!)) { + if (!options.encryption.encrypt || !options.encryption.decrypt) + throw new Error('Custom encryption must provide encrypt and decrypt functions'); + } else { + if (!options.encryption.encryptionKey) throw new Error('Encryption key must be provided'); + if (options.encryption.encryptionKey.length !== 32) throw new Error('Encryption key must be 32 bytes'); + } } - private async getKey(secret: string): Promise { - return crypto.subtle.importKey('raw', this.encoder.encode(secret).slice(0, 32), 'AES-GCM', false, [ - 'encrypt', - 'decrypt', - ]); + private async getKey(secret: Uint8Array): Promise { + return crypto.subtle.importKey('raw', secret, 'AES-GCM', false, ['encrypt', 'decrypt']); } private isCustomEncryption(encryption: CustomEncryption | SimpleEncryption): encryption is CustomEncryption { @@ -82,7 +92,7 @@ class EncryptedHandler extends DefaultPrismaProxyHandler { const key = await this.getKey(this.options.encryption!.encryptionKey); // Convert base64 back to bytes - const bytes = Uint8Array.from(atob(data)); + const bytes = Uint8Array.from(atob(data), (c) => c.charCodeAt(0)); // First 12 bytes are IV, rest is encrypted data const decrypted = await crypto.subtle.decrypt( From a7169ef055ff1ae2ed1d8b9487d056a5e0128b90 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Tue, 24 Dec 2024 18:14:59 -0300 Subject: [PATCH 16/20] refactor: prevent encryption of null, undefined, or empty string values in EncryptedHandler --- packages/runtime/src/enhancements/node/encrypted.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/runtime/src/enhancements/node/encrypted.ts b/packages/runtime/src/enhancements/node/encrypted.ts index 0ff3ea90c..cb26cfea7 100644 --- a/packages/runtime/src/enhancements/node/encrypted.ts +++ b/packages/runtime/src/enhancements/node/encrypted.ts @@ -149,6 +149,9 @@ class EncryptedHandler extends DefaultPrismaProxyHandler { private async preprocessWritePayload(model: string, action: PrismaWriteActionType, args: any) { const visitor = new NestedWriteVisitor(this.options.modelMeta, { field: async (field, _action, data, context) => { + // Don't encrypt null, undefined or empty string values + if (!data) return; + const encAttr = field.attributes?.find((attr) => attr.name === '@encrypted'); if (encAttr && field.type === 'String') { context.parent[field.name] = await this.encrypt(field, data); From acb2ee23a5c1da3d5fec0f3a9ef339fdbc17f119 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Tue, 24 Dec 2024 18:15:51 -0300 Subject: [PATCH 17/20] refactor: prevent decryption and encryption of null, undefined, or empty string values in EncryptedHandler --- packages/runtime/src/enhancements/node/encrypted.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/enhancements/node/encrypted.ts b/packages/runtime/src/enhancements/node/encrypted.ts index cb26cfea7..c3ff04415 100644 --- a/packages/runtime/src/enhancements/node/encrypted.ts +++ b/packages/runtime/src/enhancements/node/encrypted.ts @@ -141,6 +141,9 @@ class EncryptedHandler extends DefaultPrismaProxyHandler { const shouldDecrypt = fieldInfo.attributes?.find((attr) => attr.name === '@encrypted'); if (shouldDecrypt) { + // Don't decrypt null, undefined or empty string values + if (!entityData[field]) return; + entityData[field] = await this.decrypt(fieldInfo, entityData[field]); } } @@ -151,7 +154,7 @@ class EncryptedHandler extends DefaultPrismaProxyHandler { field: async (field, _action, data, context) => { // Don't encrypt null, undefined or empty string values if (!data) return; - + const encAttr = field.attributes?.find((attr) => attr.name === '@encrypted'); if (encAttr && field.type === 'String') { context.parent[field.name] = await this.encrypt(field, data); From f4dda1881fe7f2adbbcdb7ad01fba9526d5f19ce Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Thu, 26 Dec 2024 12:05:57 -0300 Subject: [PATCH 18/20] refactor: continue instead of return --- packages/runtime/src/enhancements/node/encrypted.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/enhancements/node/encrypted.ts b/packages/runtime/src/enhancements/node/encrypted.ts index c3ff04415..7dff4a954 100644 --- a/packages/runtime/src/enhancements/node/encrypted.ts +++ b/packages/runtime/src/enhancements/node/encrypted.ts @@ -142,7 +142,7 @@ class EncryptedHandler extends DefaultPrismaProxyHandler { const shouldDecrypt = fieldInfo.attributes?.find((attr) => attr.name === '@encrypted'); if (shouldDecrypt) { // Don't decrypt null, undefined or empty string values - if (!entityData[field]) return; + if (!entityData[field]) continue; entityData[field] = await this.decrypt(fieldInfo, entityData[field]); } From 4e5a2bee3f60cde8326db4bd0396e3a1c289363a Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Fri, 27 Dec 2024 18:43:40 -0300 Subject: [PATCH 19/20] refactor: add 'encrypted' enhancement kind to ALL_ENHANCEMENTS --- packages/runtime/src/enhancements/node/create-enhancement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/enhancements/node/create-enhancement.ts b/packages/runtime/src/enhancements/node/create-enhancement.ts index 7cda742eb..871f8a1b4 100644 --- a/packages/runtime/src/enhancements/node/create-enhancement.ts +++ b/packages/runtime/src/enhancements/node/create-enhancement.ts @@ -21,7 +21,7 @@ import type { PolicyDef } from './types'; /** * All enhancement kinds */ -const ALL_ENHANCEMENTS: EnhancementKind[] = ['password', 'omit', 'policy', 'validation', 'delegate']; +const ALL_ENHANCEMENTS: EnhancementKind[] = ['password', 'omit', 'policy', 'validation', 'delegate', 'encrypted']; /** * Options for {@link createEnhancement} From fa5c0658b4fea182383fb0ca700bdb69f47ad916 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Sun, 29 Dec 2024 07:55:45 -0300 Subject: [PATCH 20/20] refactor: improve error handling for encryption and decryption in EncryptedHandler --- packages/runtime/src/enhancements/node/encrypted.ts | 12 ++++++++++-- .../with-encrypted/with-encrypted.test.ts | 4 +++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/enhancements/node/encrypted.ts b/packages/runtime/src/enhancements/node/encrypted.ts index 7dff4a954..c6d6fc873 100644 --- a/packages/runtime/src/enhancements/node/encrypted.ts +++ b/packages/runtime/src/enhancements/node/encrypted.ts @@ -144,7 +144,11 @@ class EncryptedHandler extends DefaultPrismaProxyHandler { // Don't decrypt null, undefined or empty string values if (!entityData[field]) continue; - entityData[field] = await this.decrypt(fieldInfo, entityData[field]); + try { + entityData[field] = await this.decrypt(fieldInfo, entityData[field]); + } catch (error) { + console.warn('Decryption failed, keeping original value:', error); + } } } } @@ -157,7 +161,11 @@ class EncryptedHandler extends DefaultPrismaProxyHandler { const encAttr = field.attributes?.find((attr) => attr.name === '@encrypted'); if (encAttr && field.type === 'String') { - context.parent[field.name] = await this.encrypt(field, data); + try { + context.parent[field.name] = await this.encrypt(field, data); + } catch (error) { + throw new Error(`Encryption failed for field ${field.name}: ${error}`); + } } }, }); diff --git a/tests/integration/tests/enhancements/with-encrypted/with-encrypted.test.ts b/tests/integration/tests/enhancements/with-encrypted/with-encrypted.test.ts index 362c2310c..1e0544c0b 100644 --- a/tests/integration/tests/enhancements/with-encrypted/with-encrypted.test.ts +++ b/tests/integration/tests/enhancements/with-encrypted/with-encrypted.test.ts @@ -23,9 +23,11 @@ describe('Encrypted test', () => { }`); const sudoDb = enhance(undefined, { kinds: [] }); + const encryptionKey = new Uint8Array(Buffer.from('AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=', 'base64')); + const db = enhance(undefined, { kinds: ['encrypted'], - encryption: { encryptionKey: 'c558Gq0YQK2QcqtkMF9BGXHCQn4dMF8w' }, + encryption: { encryptionKey }, }); const create = await db.user.create({