diff --git a/packages/language/res/stdlib.zmodel b/packages/language/res/stdlib.zmodel index 4f473ed78..958f91a9c 100644 --- a/packages/language/res/stdlib.zmodel +++ b/packages/language/res/stdlib.zmodel @@ -669,3 +669,11 @@ attribute @meta(_ name: String, _ value: Any) * Marks an attribute as deprecated. */ attribute @@@deprecated(_ message: String) + +/** + * Indicates that the field should be encrypted when storing in the database and decrypted when read. + * Only applicable to String fields. The encryption uses AES-256-GCM via the Web Crypto API. + * + * To use this attribute, you must configure encryption options when creating the ZenStackClient. + */ +attribute @encrypted() @@@targetField([StringField]) diff --git a/packages/plugins/encryption/eslint.config.js b/packages/plugins/encryption/eslint.config.js new file mode 100644 index 000000000..c42cc2f70 --- /dev/null +++ b/packages/plugins/encryption/eslint.config.js @@ -0,0 +1,4 @@ +import base from '@zenstackhq/eslint-config'; + +/** @type {import('eslint').Linter.Config[]} */ +export default [...base]; diff --git a/packages/plugins/encryption/package.json b/packages/plugins/encryption/package.json new file mode 100644 index 000000000..4a3d2ceca --- /dev/null +++ b/packages/plugins/encryption/package.json @@ -0,0 +1,48 @@ +{ + "name": "@zenstackhq/plugin-encryption", + "version": "3.3.2", + "description": "ZenStack Encryption Plugin - Automatic field encryption/decryption for @encrypted fields", + "type": "module", + "scripts": { + "build": "tsc --noEmit && tsup-node", + "watch": "tsup-node --watch", + "lint": "eslint src --ext ts", + "pack": "pnpm pack" + }, + "keywords": [ + "zenstack", + "encryption", + "aes", + "crypto" + ], + "author": "ZenStack Team", + "license": "MIT", + "files": [ + "dist" + ], + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": { + "import": "./package.json", + "require": "./package.json" + } + }, + "dependencies": { + "@zenstackhq/orm": "workspace:*", + "zod": "catalog:" + }, + "devDependencies": { + "@zenstackhq/eslint-config": "workspace:*", + "@zenstackhq/typescript-config": "workspace:*", + "@zenstackhq/vitest-config": "workspace:*" + } +} diff --git a/packages/plugins/encryption/src/decrypter.ts b/packages/plugins/encryption/src/decrypter.ts new file mode 100644 index 000000000..8c08c1aed --- /dev/null +++ b/packages/plugins/encryption/src/decrypter.ts @@ -0,0 +1,38 @@ +import { _decrypt, ENCRYPTION_KEY_BYTES, getKeyDigest, loadKey } from './utils.js'; + +/** + * Default decrypter with support for key rotation + */ +export class Decrypter { + private keys: Array<{ key: CryptoKey; digest: string }> = []; + + constructor(private readonly decryptionKeys: Uint8Array[]) { + if (decryptionKeys.length === 0) { + throw new Error('At least one decryption key must be provided'); + } + + for (const key of decryptionKeys) { + if (key.length !== ENCRYPTION_KEY_BYTES) { + throw new Error(`Decryption key must be ${ENCRYPTION_KEY_BYTES} bytes`); + } + } + } + + /** + * Decrypts the given data + */ + async decrypt(data: string): Promise { + if (this.keys.length === 0) { + this.keys = await Promise.all( + this.decryptionKeys.map(async (key) => ({ + key: await loadKey(key, ['decrypt']), + digest: await getKeyDigest(key), + })), + ); + } + + return _decrypt(data, async (digest) => + this.keys.filter((entry) => entry.digest === digest).map((entry) => entry.key), + ); + } +} diff --git a/packages/plugins/encryption/src/encrypter.ts b/packages/plugins/encryption/src/encrypter.ts new file mode 100644 index 000000000..6929b4c53 --- /dev/null +++ b/packages/plugins/encryption/src/encrypter.ts @@ -0,0 +1,30 @@ +import { _encrypt, ENCRYPTION_KEY_BYTES, getKeyDigest, loadKey } from './utils.js'; + +/** + * Default encrypter using AES-256-GCM + */ +export class Encrypter { + private key: CryptoKey | undefined; + private keyDigest: string | undefined; + + constructor(private readonly encryptionKey: Uint8Array) { + if (encryptionKey.length !== ENCRYPTION_KEY_BYTES) { + throw new Error(`Encryption key must be ${ENCRYPTION_KEY_BYTES} bytes`); + } + } + + /** + * Encrypts the given data + */ + async encrypt(data: string): Promise { + if (!this.key) { + this.key = await loadKey(this.encryptionKey, ['encrypt']); + } + + if (!this.keyDigest) { + this.keyDigest = await getKeyDigest(this.encryptionKey); + } + + return _encrypt(data, this.key, this.keyDigest); + } +} diff --git a/packages/plugins/encryption/src/index.ts b/packages/plugins/encryption/src/index.ts new file mode 100644 index 000000000..afb930da6 --- /dev/null +++ b/packages/plugins/encryption/src/index.ts @@ -0,0 +1,6 @@ +export { Decrypter } from './decrypter.js'; +export { Encrypter } from './encrypter.js'; +export { createEncryptionPlugin } from './plugin.js'; +export type { CustomEncryption, EncryptionConfig, SimpleEncryption } from './types.js'; +export { isCustomEncryption } from './types.js'; +export { ENCRYPTION_KEY_BYTES } from './utils.js'; diff --git a/packages/plugins/encryption/src/plugin.ts b/packages/plugins/encryption/src/plugin.ts new file mode 100644 index 000000000..1d6631cc7 --- /dev/null +++ b/packages/plugins/encryption/src/plugin.ts @@ -0,0 +1,326 @@ +import { definePlugin } from '@zenstackhq/orm'; +import type { FieldDef, ModelDef, SchemaDef } from '@zenstackhq/orm/schema'; +import { Decrypter } from './decrypter.js'; +import { Encrypter } from './encrypter.js'; +import type { CustomEncryption, EncryptionConfig, SimpleEncryption } from './types.js'; +import { isCustomEncryption } from './types.js'; + +const ENCRYPTED_ATTRIBUTE = '@encrypted'; + +/** + * Check if a field has the @encrypted attribute + */ +function isEncryptedField(field: FieldDef): boolean { + return field.attributes?.some((attr) => attr.name === ENCRYPTED_ATTRIBUTE) ?? false; +} + +/** + * Check if a model has any encrypted fields + */ +function hasEncryptedFields(model: ModelDef): boolean { + return Object.values(model.fields).some(isEncryptedField); +} + +/** + * Creates an encryption plugin for ZenStack ORM + * + * @param config Encryption configuration (simple or custom) + * @returns A runtime plugin that handles field encryption/decryption + */ +export function createEncryptionPlugin(config: EncryptionConfig) { + let encrypter: Encrypter | undefined; + let decrypter: Decrypter | undefined; + let customEncryption: CustomEncryption | undefined; + + if (isCustomEncryption(config)) { + customEncryption = config; + } else { + const simpleConfig = config as SimpleEncryption; + encrypter = new Encrypter(simpleConfig.encryptionKey); + const allDecryptionKeys = [simpleConfig.encryptionKey, ...(simpleConfig.decryptionKeys ?? [])]; + decrypter = new Decrypter(allDecryptionKeys); + } + + async function encryptValue(model: string, field: FieldDef, value: string): Promise { + if (customEncryption) { + return customEncryption.encrypt(model, field, value); + } + return encrypter!.encrypt(value); + } + + async function decryptValue(model: string, field: FieldDef, value: string): Promise { + if (customEncryption) { + return customEncryption.decrypt(model, field, value); + } + return decrypter!.decrypt(value); + } + + /** + * Recursively encrypt fields in write data + */ + async function encryptWriteData( + schema: SchemaDef, + modelName: string, + data: Record, + ): Promise { + const model = schema.models[modelName]; + if (!model) return; + + for (const [fieldName, value] of Object.entries(data)) { + if (value === null || value === undefined || value === '') { + continue; + } + + const field = model.fields[fieldName]; + if (!field) continue; + + // Handle encrypted string fields + if (isEncryptedField(field) && typeof value === 'string') { + data[fieldName] = await encryptValue(modelName, field, value); + continue; + } + + // Handle relation fields (nested writes) + if (field.relation && typeof value === 'object') { + const relatedModel = field.type; + await encryptNestedWrites(schema, relatedModel, value as Record); + } + } + } + + /** + * Handle nested write operations (create, update, connect, etc.) + */ + async function encryptNestedWrites( + schema: SchemaDef, + modelName: string, + data: Record, + ): Promise { + // Handle create + const createData = data['create']; + if (createData) { + if (Array.isArray(createData)) { + for (const item of createData) { + await encryptWriteData(schema, modelName, item as Record); + } + } else { + await encryptWriteData(schema, modelName, createData as Record); + } + } + + // Handle createMany + const createManyData = data['createMany']; + if (createManyData && typeof createManyData === 'object') { + const createManyItems = (createManyData as Record)['data']; + if (Array.isArray(createManyItems)) { + for (const item of createManyItems) { + await encryptWriteData(schema, modelName, item as Record); + } + } + } + + // Handle update + const updateData = data['update']; + if (updateData) { + if (Array.isArray(updateData)) { + for (const item of updateData) { + const updateItem = item as Record; + const itemData = updateItem['data']; + if (itemData) { + await encryptWriteData(schema, modelName, itemData as Record); + } + } + } else { + const updateObj = updateData as Record; + const nestedData = updateObj['data']; + if (nestedData) { + await encryptWriteData(schema, modelName, nestedData as Record); + } else { + await encryptWriteData(schema, modelName, updateObj); + } + } + } + + // Handle updateMany + const updateManyData = data['updateMany']; + if (updateManyData) { + if (Array.isArray(updateManyData)) { + for (const item of updateManyData) { + const updateItem = item as Record; + const itemData = updateItem['data']; + if (itemData) { + await encryptWriteData(schema, modelName, itemData as Record); + } + } + } else { + const updateObj = updateManyData as Record; + const nestedData = updateObj['data']; + if (nestedData) { + await encryptWriteData(schema, modelName, nestedData as Record); + } + } + } + + // Handle upsert + const upsertData = data['upsert']; + if (upsertData) { + if (Array.isArray(upsertData)) { + for (const item of upsertData) { + const upsertItem = item as Record; + const createPart = upsertItem['create']; + const updatePart = upsertItem['update']; + if (createPart) { + await encryptWriteData(schema, modelName, createPart as Record); + } + if (updatePart) { + await encryptWriteData(schema, modelName, updatePart as Record); + } + } + } else { + const upsertObj = upsertData as Record; + const createPart = upsertObj['create']; + const updatePart = upsertObj['update']; + if (createPart) { + await encryptWriteData(schema, modelName, createPart as Record); + } + if (updatePart) { + await encryptWriteData(schema, modelName, updatePart as Record); + } + } + } + + // Handle connectOrCreate + const connectOrCreateData = data['connectOrCreate']; + if (connectOrCreateData) { + if (Array.isArray(connectOrCreateData)) { + for (const item of connectOrCreateData) { + const cocItem = item as Record; + const createPart = cocItem['create']; + if (createPart) { + await encryptWriteData(schema, modelName, createPart as Record); + } + } + } else { + const cocObj = connectOrCreateData as Record; + const createPart = cocObj['create']; + if (createPart) { + await encryptWriteData(schema, modelName, createPart as Record); + } + } + } + } + + /** + * Recursively decrypt fields in result data + */ + async function decryptResultData( + schema: SchemaDef, + modelName: string, + data: Record, + ): Promise { + const model = schema.models[modelName]; + if (!model) return; + + for (const [fieldName, value] of Object.entries(data)) { + if (value === null || value === undefined || value === '') { + continue; + } + + const field = model.fields[fieldName]; + if (!field) continue; + + // Handle encrypted string fields + if (isEncryptedField(field) && typeof value === 'string') { + try { + data[fieldName] = await decryptValue(modelName, field, value); + } catch (error) { + // If decryption fails, log warning and keep original value + console.warn(`Failed to decrypt field ${modelName}.${fieldName}:`, error); + } + continue; + } + + // Handle relation fields (nested data) + if (field.relation && value !== null) { + const relatedModel = field.type; + if (Array.isArray(value)) { + for (const item of value) { + if (typeof item === 'object' && item !== null) { + await decryptResultData(schema, relatedModel, item as Record); + } + } + } else if (typeof value === 'object') { + await decryptResultData(schema, relatedModel, value as Record); + } + } + } + } + + return definePlugin({ + id: 'encryption', + name: 'Encryption Plugin', + description: 'Automatically encrypts and decrypts fields marked with @encrypted', + + onQuery: async (ctx) => { + const { model, operation, args, proceed, client } = ctx; + const schema = (client as any).schema as SchemaDef; + const modelDef = schema.models[model]; + + // Check if this model has any encrypted fields + if (!modelDef || !hasEncryptedFields(modelDef)) { + return proceed(args); + } + + // Clone args to avoid mutating original + const processedArgs = args ? JSON.parse(JSON.stringify(args)) : undefined; + + // Handle write operations - encrypt data before writing + if ( + operation === 'create' || + operation === 'update' || + operation === 'upsert' || + operation === 'createMany' || + operation === 'updateMany' || + operation === 'createManyAndReturn' + ) { + if (processedArgs?.data) { + if (Array.isArray(processedArgs.data)) { + for (const item of processedArgs.data) { + await encryptWriteData(schema, model, item); + } + } else { + await encryptWriteData(schema, model, processedArgs.data); + } + } + + // Handle upsert create/update + if (operation === 'upsert') { + if (processedArgs?.create) { + await encryptWriteData(schema, model, processedArgs.create); + } + if (processedArgs?.update) { + await encryptWriteData(schema, model, processedArgs.update); + } + } + } + + // Execute the query + const result = await proceed(processedArgs); + + // Handle read operations - decrypt data after reading + if (result !== null && result !== undefined) { + if (Array.isArray(result)) { + for (const item of result) { + if (typeof item === 'object' && item !== null) { + await decryptResultData(schema, model, item as Record); + } + } + } else if (typeof result === 'object') { + await decryptResultData(schema, model, result as Record); + } + } + + return result; + }, + }); +} diff --git a/packages/plugins/encryption/src/types.ts b/packages/plugins/encryption/src/types.ts new file mode 100644 index 000000000..d908c0d7a --- /dev/null +++ b/packages/plugins/encryption/src/types.ts @@ -0,0 +1,52 @@ +import type { FieldDef } from '@zenstackhq/orm/schema'; + +/** + * Simple encryption configuration using built-in AES-256-GCM encryption + */ +export type SimpleEncryption = { + /** + * The encryption key (must be 32 bytes / 256 bits) + */ + encryptionKey: Uint8Array; + + /** + * Additional decryption keys for key rotation support. + * When decrypting, all keys (encryptionKey + decryptionKeys) are tried. + */ + decryptionKeys?: Uint8Array[]; +}; + +/** + * Custom encryption configuration for user-provided encryption handlers + */ +export type CustomEncryption = { + /** + * Custom encryption function + * @param model The model name + * @param field The field definition + * @param plain The plaintext value to encrypt + * @returns The encrypted value + */ + encrypt: (model: string, field: FieldDef, plain: string) => Promise; + + /** + * Custom decryption function + * @param model The model name + * @param field The field definition + * @param cipher The encrypted value to decrypt + * @returns The decrypted value + */ + decrypt: (model: string, field: FieldDef, cipher: string) => Promise; +}; + +/** + * Encryption configuration - either simple (built-in) or custom + */ +export type EncryptionConfig = SimpleEncryption | CustomEncryption; + +/** + * Type guard to check if encryption config is custom + */ +export function isCustomEncryption(config: EncryptionConfig): config is CustomEncryption { + return 'encrypt' in config && 'decrypt' in config; +} diff --git a/packages/plugins/encryption/src/utils.ts b/packages/plugins/encryption/src/utils.ts new file mode 100644 index 000000000..b7dc2faaf --- /dev/null +++ b/packages/plugins/encryption/src/utils.ts @@ -0,0 +1,112 @@ +import { z } from 'zod'; + +export const ENCRYPTER_VERSION = 1; +export const ENCRYPTION_KEY_BYTES = 32; +export const IV_BYTES = 12; +export const ALGORITHM = 'AES-GCM'; +export const KEY_DIGEST_BYTES = 8; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +const encryptionMetaSchema = z.object({ + // version + v: z.number(), + // algorithm + a: z.string(), + // key digest + k: z.string(), +}); + +/** + * Load a raw encryption key into a CryptoKey object + */ +export async function loadKey(key: Uint8Array, keyUsages: KeyUsage[]): Promise { + // Convert to ArrayBuffer for crypto.subtle compatibility + const keyBuffer = key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer; + return crypto.subtle.importKey('raw', keyBuffer, ALGORITHM, false, keyUsages); +} + +/** + * Get a digest of the encryption key for identification + */ +export async function getKeyDigest(key: Uint8Array): Promise { + // Convert to ArrayBuffer for crypto.subtle compatibility + const keyBuffer = key.buffer.slice(key.byteOffset, key.byteOffset + key.byteLength) as ArrayBuffer; + const rawDigest = await crypto.subtle.digest('SHA-256', keyBuffer); + return new Uint8Array(rawDigest.slice(0, KEY_DIGEST_BYTES)).reduce( + (acc, byte) => acc + byte.toString(16).padStart(2, '0'), + '', + ); +} + +/** + * Encrypt data using AES-GCM + */ +export async function _encrypt(data: string, key: CryptoKey, keyDigest: string): Promise { + const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES)); + const encrypted = await crypto.subtle.encrypt( + { + name: ALGORITHM, + iv, + }, + key, + encoder.encode(data), + ); + + // combine IV and encrypted data into a single array of bytes + const cipherBytes = [...iv, ...new Uint8Array(encrypted)]; + + // encryption metadata + const meta = { v: ENCRYPTER_VERSION, a: ALGORITHM, k: keyDigest }; + + // convert concatenated result to base64 string + return `${btoa(JSON.stringify(meta))}.${btoa(String.fromCharCode(...cipherBytes))}`; +} + +/** + * Decrypt data using AES-GCM + */ +export async function _decrypt(data: string, findKey: (digest: string) => Promise): Promise { + const [metaText, cipherText] = data.split('.'); + if (!metaText || !cipherText) { + throw new Error('Malformed encrypted data'); + } + + let metaObj: unknown; + try { + metaObj = JSON.parse(atob(metaText)); + } catch { + throw new Error('Malformed metadata'); + } + + // parse meta + const { a: algorithm, k: keyDigest } = encryptionMetaSchema.parse(metaObj); + + // find a matching decryption key + const keys = await findKey(keyDigest); + if (keys.length === 0) { + throw new Error('No matching decryption key found'); + } + + // convert base64 back to bytes + const bytes = Uint8Array.from(atob(cipherText), (c) => c.charCodeAt(0)); + + // extract IV from the head + const iv = bytes.slice(0, IV_BYTES); + const cipher = bytes.slice(IV_BYTES); + let lastError: unknown; + + for (const key of keys) { + let decrypted: ArrayBuffer; + try { + decrypted = await crypto.subtle.decrypt({ name: algorithm, iv }, key, cipher); + } catch (err) { + lastError = err; + continue; + } + return decoder.decode(decrypted); + } + + throw lastError; +} diff --git a/packages/plugins/encryption/tsconfig.json b/packages/plugins/encryption/tsconfig.json new file mode 100644 index 000000000..652305611 --- /dev/null +++ b/packages/plugins/encryption/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@zenstackhq/typescript-config/base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/plugins/encryption/tsup.config.ts b/packages/plugins/encryption/tsup.config.ts new file mode 100644 index 000000000..4b7a3428c --- /dev/null +++ b/packages/plugins/encryption/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: { + index: 'src/index.ts', + }, + outDir: 'dist', + splitting: false, + sourcemap: true, + dts: true, + format: ['cjs', 'esm'], +}); diff --git a/tests/e2e/orm/client-api/encrypted.test.ts b/tests/e2e/orm/client-api/encrypted.test.ts new file mode 100644 index 000000000..2fd666a0a --- /dev/null +++ b/tests/e2e/orm/client-api/encrypted.test.ts @@ -0,0 +1,305 @@ +import type { ClientContract } from '@zenstackhq/orm'; +import { createEncryptionPlugin, ENCRYPTION_KEY_BYTES } from '@zenstackhq/plugin-encryption'; +import { createTestClient } from '@zenstackhq/testtools'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const schema = ` +datasource db { + provider = "sqlite" + url = "file:./dev.db" +} + +model User { + id String @id @default(cuid()) + email String @unique + name String? + secretToken String @encrypted + posts Post[] +} + +model Post { + id String @id @default(cuid()) + title String + content String? + encrypted String @encrypted + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + authorId String +} +`; + +// Generate a 32-byte key for AES-256 +const encryptionKey = new Uint8Array(ENCRYPTION_KEY_BYTES); +crypto.getRandomValues(encryptionKey); + +describe('Client encrypted field tests', () => { + let client: ClientContract; + + beforeEach(async () => { + const encryptionPlugin = createEncryptionPlugin({ encryptionKey }); + client = await createTestClient(schema, { + plugins: [encryptionPlugin], + }); + }); + + afterEach(async () => { + await client?.$disconnect(); + }); + + it('encrypts and decrypts a single field on create', async () => { + const user = await client.user.create({ + data: { + email: 'test@test.com', + secretToken: 'my-secret-token', + }, + }); + + expect(user).toMatchObject({ + id: expect.any(String), + email: 'test@test.com', + secretToken: 'my-secret-token', + }); + + // Verify the data is encrypted in the database by reading raw + const rawResult = await client.$qb.selectFrom('User').selectAll().execute(); + expect(rawResult).toHaveLength(1); + // The raw value should NOT be the plaintext + expect(rawResult[0].secretToken).not.toBe('my-secret-token'); + // It should be a base64 encoded string with metadata + expect(rawResult[0].secretToken).toContain('.'); + }); + + it('encrypts and decrypts a single field on findUnique', async () => { + const created = await client.user.create({ + data: { + email: 'test@test.com', + secretToken: 'my-secret-token', + }, + }); + + const found = await client.user.findUnique({ + where: { id: created.id }, + }); + + expect(found).toMatchObject({ + id: created.id, + email: 'test@test.com', + secretToken: 'my-secret-token', + }); + }); + + it('encrypts and decrypts a single field on findMany', async () => { + await client.user.create({ + data: { email: 'test1@test.com', secretToken: 'secret-1' }, + }); + await client.user.create({ + data: { email: 'test2@test.com', secretToken: 'secret-2' }, + }); + + const users = await client.user.findMany(); + + expect(users).toHaveLength(2); + expect(users[0].secretToken).toBe('secret-1'); + expect(users[1].secretToken).toBe('secret-2'); + }); + + it('encrypts field on update', async () => { + const user = await client.user.create({ + data: { + email: 'test@test.com', + secretToken: 'original-secret', + }, + }); + + const updated = await client.user.update({ + where: { id: user.id }, + data: { secretToken: 'updated-secret' }, + }); + + expect(updated.secretToken).toBe('updated-secret'); + + // Verify via raw query that it's encrypted differently + const rawResult = await client.$qb.selectFrom('User').selectAll().execute(); + expect(rawResult[0].secretToken).not.toBe('updated-secret'); + }); + + it('handles null values gracefully', async () => { + // Create user with non-nullable encrypted field using a value + const user = await client.user.create({ + data: { + email: 'test@test.com', + secretToken: '', + }, + }); + + expect(user.secretToken).toBe(''); + }); + + it('handles nested relations with encrypted fields', async () => { + const user = await client.user.create({ + data: { + email: 'test@test.com', + secretToken: 'user-secret', + posts: { + create: { + title: 'Test Post', + encrypted: 'post-secret', + }, + }, + }, + include: { posts: true }, + }); + + expect(user.secretToken).toBe('user-secret'); + expect(user.posts).toHaveLength(1); + expect(user.posts[0].encrypted).toBe('post-secret'); + }); + + it('handles multiple encrypted fields in nested query results', async () => { + await client.user.create({ + data: { + email: 'test@test.com', + secretToken: 'user-secret', + posts: { + create: [ + { title: 'Post 1', encrypted: 'secret-1' }, + { title: 'Post 2', encrypted: 'secret-2' }, + ], + }, + }, + }); + + const user = await client.user.findFirst({ + include: { posts: true }, + }); + + expect(user?.secretToken).toBe('user-secret'); + expect(user?.posts).toHaveLength(2); + expect(user?.posts.map((p: any) => p.encrypted).sort()).toEqual(['secret-1', 'secret-2']); + }); + + it('works with upsert create', async () => { + const user = await client.user.upsert({ + where: { email: 'new@test.com' }, + create: { + email: 'new@test.com', + secretToken: 'new-secret', + }, + update: { + secretToken: 'updated-secret', + }, + }); + + expect(user.secretToken).toBe('new-secret'); + }); + + it('works with upsert update', async () => { + await client.user.create({ + data: { email: 'existing@test.com', secretToken: 'original' }, + }); + + const user = await client.user.upsert({ + where: { email: 'existing@test.com' }, + create: { + email: 'existing@test.com', + secretToken: 'new-secret', + }, + update: { + secretToken: 'updated-secret', + }, + }); + + expect(user.secretToken).toBe('updated-secret'); + }); + + it('works with createMany', async () => { + await client.user.createMany({ + data: [ + { email: 'user1@test.com', secretToken: 'secret-1' }, + { email: 'user2@test.com', secretToken: 'secret-2' }, + ], + }); + + const users = await client.user.findMany({ + orderBy: { email: 'asc' }, + }); + + expect(users).toHaveLength(2); + expect(users[0].secretToken).toBe('secret-1'); + expect(users[1].secretToken).toBe('secret-2'); + }); +}); + +describe('Encryption key rotation', () => { + it('can decrypt with old key after rotation', async () => { + // Use same old and new keys + const oldKey = new Uint8Array(ENCRYPTION_KEY_BYTES); + crypto.getRandomValues(oldKey); + + const newKey = new Uint8Array(ENCRYPTION_KEY_BYTES); + crypto.getRandomValues(newKey); + + // Create client with old key and create data + const oldPlugin = createEncryptionPlugin({ encryptionKey: oldKey }); + const client = await createTestClient(schema, { + plugins: [oldPlugin], + }); + + const user = await client.user.create({ + data: { email: 'test@test.com', secretToken: 'my-secret' }, + }); + expect(user.secretToken).toBe('my-secret'); + + // Get the raw encrypted value from the database + const rawBefore = await client.$qb.selectFrom('User').selectAll().execute(); + const encryptedValue = rawBefore[0].secretToken as string; + expect(encryptedValue).not.toBe('my-secret'); + expect(encryptedValue).toContain('.'); // Has metadata separator + + // Create a new plugin with new encryption key but supporting old decryption key + const newPlugin = createEncryptionPlugin({ + encryptionKey: newKey, + decryptionKeys: [oldKey], // Include old key for decryption + }); + + // Use the same client but with new plugin to verify key rotation + const client2 = client.$use(newPlugin); + + // Should still be able to read the old data (decrypted with old key) + const found = await client2.user.findFirst(); + expect(found?.secretToken).toBe('my-secret'); + + await client.$disconnect(); + }); +}); + +describe('Custom encryption handler', () => { + it('supports custom encryption functions', async () => { + const customPlugin = createEncryptionPlugin({ + encrypt: async (model, field, plain) => { + // Simple base64 encoding for testing + return `custom:${Buffer.from(plain).toString('base64')}`; + }, + decrypt: async (model, field, cipher) => { + // Decode custom format + const base64 = cipher.replace('custom:', ''); + return Buffer.from(base64, 'base64').toString(); + }, + }); + + const client = await createTestClient(schema, { + plugins: [customPlugin], + }); + + const user = await client.user.create({ + data: { email: 'test@test.com', secretToken: 'custom-secret' }, + }); + + expect(user.secretToken).toBe('custom-secret'); + + // Verify custom format in database + const rawResult = await client.$qb.selectFrom('User').selectAll().execute(); + expect(rawResult[0].secretToken).toMatch(/^custom:/); + + await client.$disconnect(); + }); +}); diff --git a/tests/e2e/orm/schemas/encrypted/schema.zmodel b/tests/e2e/orm/schemas/encrypted/schema.zmodel new file mode 100644 index 000000000..a4f5bfdd1 --- /dev/null +++ b/tests/e2e/orm/schemas/encrypted/schema.zmodel @@ -0,0 +1,21 @@ +datasource db { + provider = "sqlite" + url = "file:./dev.db" +} + +model User { + id String @id @default(cuid()) + email String @unique + name String? + secretToken String @encrypted + posts Post[] +} + +model Post { + id String @id @default(cuid()) + title String + content String? + encrypted String @encrypted + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + authorId String +} diff --git a/tests/e2e/package.json b/tests/e2e/package.json index 0bda86c1e..5e1add8af 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -17,6 +17,7 @@ "@zenstackhq/cli": "workspace:*", "@zenstackhq/language": "workspace:*", "@zenstackhq/orm": "workspace:*", + "@zenstackhq/plugin-encryption": "workspace:*", "@zenstackhq/plugin-policy": "workspace:*", "@zenstackhq/schema": "workspace:*", "@zenstackhq/sdk": "workspace:*",