-
-
Notifications
You must be signed in to change notification settings - Fork 116
feat: Add @encrypted
enhancer
#1922
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 4 commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
23a06cc
feat: add encrypted kind
genu 7784099
chore: add encrypt function
genu f8ee204
test: add integration tests for encrypted model functionality
genu 6bff7f4
test: Add test
genu b86e814
fix: require encryption options for @encrypted enhancement
genu e0789b7
feat: enhance encryption handling in EncryptedHandler and update sche…
genu 688d92d
fix: remove hardcoded encryption key from schema loading command
genu aedbd93
feat: implement custom encryption handling in EncryptedHandler
genu 8752f06
fix: update encryption methods to return promises in EncryptedHandler
genu 83c242c
test: add integration tests for custom encryption handling in Encrypt…
genu d9b95ef
chore: Add symlink
genu 2ea8bd2
refactor: streamline encryption handling by moving key retrieval and …
genu 78046b3
refactor: don't enable `encrypted` enhancement by default
genu 9d16be0
refactor: change encryptionKey type from string to Uint8Array in Simp…
genu 29b7d15
refactor: enhance encryption validation and update key handling in En…
genu a7169ef
refactor: prevent encryption of null, undefined, or empty string valu…
genu acb2ee2
refactor: prevent decryption and encryption of null, undefined, or em…
genu f4dda18
refactor: continue instead of return
genu 4e5a2be
refactor: add 'encrypted' enhancement kind to ALL_ENHANCEMENTS
genu fa5c065
refactor: improve error handling for encryption and decryption in Enc…
genu File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<DbClient extends object = any>( | ||
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<CryptoKey> => { | ||
return crypto.subtle.importKey('raw', encoder.encode(secret).slice(0, 32), 'AES-GCM', false, [ | ||
'encrypt', | ||
'decrypt', | ||
]); | ||
}; | ||
const encryptFunc = async (data: string, secret: string): Promise<string> => { | ||
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<string> => { | ||
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<T>(method: PrismaProxyActions, data: T): Promise<T> { | ||
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); | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<DbClient extends object = any>( | ||
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<CryptoKey> => { | ||
return crypto.subtle.importKey('raw', encoder.encode(secret).slice(0, 32), 'AES-GCM', false, [ | ||
'encrypt', | ||
'decrypt', | ||
]); | ||
}; | ||
const encryptFunc = async (data: string, secret: string): Promise<string> => { | ||
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<string> => { | ||
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<T>(method: PrismaProxyActions, data: T): Promise<T> { | ||
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) => { | ||
ymc9 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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); | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
44 changes: 44 additions & 0 deletions
44
tests/integration/tests/enhancements/with-encrypted/with-encrypted.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
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 ENCRYPTION_KEY = 'c558Gq0YQK2QcqtkMF9BGXHCQn4dMF8w'; | ||
|
||
const { enhance } = await loadSchema(` | ||
model User { | ||
id String @id @default(cuid()) | ||
encrypted_value String @encrypted(secret: "${ENCRYPTION_KEY}") | ||
|
||
@@allow('all', true) | ||
}`); | ||
|
||
const db = enhance(); | ||
|
||
const create = await db.user.create({ | ||
data: { | ||
id: '1', | ||
encrypted_value: 'abc123', | ||
}, | ||
}); | ||
|
||
const read = await db.user.findUnique({ | ||
where: { | ||
id: '1', | ||
}, | ||
}); | ||
|
||
expect(create.encrypted_value).toBe('abc123'); | ||
expect(read.encrypted_value).toBe('abc123'); | ||
}); | ||
}); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.