Skip to content

Commit 7ed9841

Browse files
authored
feat(encryption): support providing multiple decryption keys for key rotation (#1942)
1 parent 00c1982 commit 7ed9841

File tree

3 files changed

+231
-38
lines changed

3 files changed

+231
-38
lines changed

packages/runtime/src/enhancements/node/encryption.ts

Lines changed: 129 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
22
/* eslint-disable @typescript-eslint/no-unused-vars */
33

4+
import { z } from 'zod';
45
import {
56
FieldInfo,
67
NestedWriteVisitor,
@@ -37,79 +38,175 @@ class EncryptedHandler extends DefaultPrismaProxyHandler {
3738
private encoder = new TextEncoder();
3839
private decoder = new TextDecoder();
3940
private logger: Logger;
41+
private encryptionKey: CryptoKey | undefined;
42+
private encryptionKeyDigest: string | undefined;
43+
private decryptionKeys: Array<{ key: CryptoKey; digest: string }> = [];
44+
private encryptionMetaSchema = z.object({
45+
// version
46+
v: z.number(),
47+
// algorithm
48+
a: z.string(),
49+
// key digest
50+
k: z.string(),
51+
});
52+
53+
// constants
54+
private readonly ENCRYPTION_KEY_BYTES = 32;
55+
private readonly IV_BYTES = 12;
56+
private readonly ALGORITHM = 'AES-GCM';
57+
private readonly ENCRYPTER_VERSION = 1;
58+
private readonly KEY_DIGEST_BYTES = 8;
4059

4160
constructor(prisma: DbClientContract, model: string, options: InternalEnhancementOptions) {
4261
super(prisma, model, options);
4362

4463
this.queryUtils = new QueryUtils(prisma, options);
4564
this.logger = new Logger(prisma);
4665

47-
if (!options.encryption) throw this.queryUtils.unknownError('Encryption options must be provided');
66+
if (!options.encryption) {
67+
throw this.queryUtils.unknownError('Encryption options must be provided');
68+
}
4869

4970
if (this.isCustomEncryption(options.encryption!)) {
50-
if (!options.encryption.encrypt || !options.encryption.decrypt)
71+
if (!options.encryption.encrypt || !options.encryption.decrypt) {
5172
throw this.queryUtils.unknownError('Custom encryption must provide encrypt and decrypt functions');
73+
}
5274
} else {
53-
if (!options.encryption.encryptionKey)
75+
if (!options.encryption.encryptionKey) {
5476
throw this.queryUtils.unknownError('Encryption key must be provided');
55-
if (options.encryption.encryptionKey.length !== 32)
56-
throw this.queryUtils.unknownError('Encryption key must be 32 bytes');
77+
}
78+
if (options.encryption.encryptionKey.length !== this.ENCRYPTION_KEY_BYTES) {
79+
throw this.queryUtils.unknownError(`Encryption key must be ${this.ENCRYPTION_KEY_BYTES} bytes`);
80+
}
5781
}
5882
}
5983

60-
private async getKey(secret: Uint8Array): Promise<CryptoKey> {
61-
return crypto.subtle.importKey('raw', secret, 'AES-GCM', false, ['encrypt', 'decrypt']);
62-
}
63-
6484
private isCustomEncryption(encryption: CustomEncryption | SimpleEncryption): encryption is CustomEncryption {
6585
return 'encrypt' in encryption && 'decrypt' in encryption;
6686
}
6787

88+
private async loadKey(key: Uint8Array, keyUsages: KeyUsage[]): Promise<CryptoKey> {
89+
return crypto.subtle.importKey('raw', key, this.ALGORITHM, false, keyUsages);
90+
}
91+
92+
private async computeKeyDigest(key: Uint8Array) {
93+
const rawDigest = await crypto.subtle.digest('SHA-256', key);
94+
return new Uint8Array(rawDigest.slice(0, this.KEY_DIGEST_BYTES)).reduce(
95+
(acc, byte) => acc + byte.toString(16).padStart(2, '0'),
96+
''
97+
);
98+
}
99+
100+
private async getEncryptionKey(): Promise<CryptoKey> {
101+
if (this.isCustomEncryption(this.options.encryption!)) {
102+
throw new Error('Unexpected custom encryption settings');
103+
}
104+
if (!this.encryptionKey) {
105+
this.encryptionKey = await this.loadKey(this.options.encryption!.encryptionKey, ['encrypt', 'decrypt']);
106+
}
107+
return this.encryptionKey;
108+
}
109+
110+
private async getEncryptionKeyDigest() {
111+
if (this.isCustomEncryption(this.options.encryption!)) {
112+
throw new Error('Unexpected custom encryption settings');
113+
}
114+
if (!this.encryptionKeyDigest) {
115+
this.encryptionKeyDigest = await this.computeKeyDigest(this.options.encryption!.encryptionKey);
116+
}
117+
return this.encryptionKeyDigest;
118+
}
119+
120+
private async findDecryptionKeys(keyDigest: string): Promise<CryptoKey[]> {
121+
if (this.isCustomEncryption(this.options.encryption!)) {
122+
throw new Error('Unexpected custom encryption settings');
123+
}
124+
125+
if (this.decryptionKeys.length === 0) {
126+
const keys = [this.options.encryption!.encryptionKey, ...(this.options.encryption!.decryptionKeys || [])];
127+
this.decryptionKeys = await Promise.all(
128+
keys.map(async (key) => ({
129+
key: await this.loadKey(key, ['decrypt']),
130+
digest: await this.computeKeyDigest(key),
131+
}))
132+
);
133+
}
134+
135+
return this.decryptionKeys.filter((entry) => entry.digest === keyDigest).map((entry) => entry.key);
136+
}
137+
68138
private async encrypt(field: FieldInfo, data: string): Promise<string> {
69139
if (this.isCustomEncryption(this.options.encryption!)) {
70140
return this.options.encryption.encrypt(this.model, field, data);
71141
}
72142

73-
const key = await this.getKey(this.options.encryption!.encryptionKey);
74-
const iv = crypto.getRandomValues(new Uint8Array(12));
75-
143+
const key = await this.getEncryptionKey();
144+
const iv = crypto.getRandomValues(new Uint8Array(this.IV_BYTES));
76145
const encrypted = await crypto.subtle.encrypt(
77146
{
78-
name: 'AES-GCM',
147+
name: this.ALGORITHM,
79148
iv,
80149
},
81150
key,
82151
this.encoder.encode(data)
83152
);
84153

85-
// Combine IV and encrypted data into a single array of bytes
86-
const bytes = [...iv, ...new Uint8Array(encrypted)];
154+
// combine IV and encrypted data into a single array of bytes
155+
const cipherBytes = [...iv, ...new Uint8Array(encrypted)];
156+
157+
// encryption metadata
158+
const meta = { v: this.ENCRYPTER_VERSION, a: this.ALGORITHM, k: await this.getEncryptionKeyDigest() };
87159

88-
// Convert bytes to base64 string
89-
return btoa(String.fromCharCode(...bytes));
160+
// convert concatenated result to base64 string
161+
return `${btoa(JSON.stringify(meta))}.${btoa(String.fromCharCode(...cipherBytes))}`;
90162
}
91163

92164
private async decrypt(field: FieldInfo, data: string): Promise<string> {
93165
if (this.isCustomEncryption(this.options.encryption!)) {
94166
return this.options.encryption.decrypt(this.model, field, data);
95167
}
96168

97-
const key = await this.getKey(this.options.encryption!.encryptionKey);
169+
const [metaText, cipherText] = data.split('.');
170+
if (!metaText || !cipherText) {
171+
throw new Error('Malformed encrypted data');
172+
}
98173

99-
// Convert base64 back to bytes
100-
const bytes = Uint8Array.from(atob(data), (c) => c.charCodeAt(0));
174+
let metaObj: unknown;
175+
try {
176+
metaObj = JSON.parse(atob(metaText));
177+
} catch (error) {
178+
throw new Error('Malformed metadata');
179+
}
101180

102-
// First 12 bytes are IV, rest is encrypted data
103-
const decrypted = await crypto.subtle.decrypt(
104-
{
105-
name: 'AES-GCM',
106-
iv: bytes.slice(0, 12),
107-
},
108-
key,
109-
bytes.slice(12)
110-
);
181+
// parse meta
182+
const { a: algorithm, k: keyDigest } = this.encryptionMetaSchema.parse(metaObj);
183+
184+
// find a matching decryption key
185+
const keys = await this.findDecryptionKeys(keyDigest);
186+
if (keys.length === 0) {
187+
throw new Error('No matching decryption key found');
188+
}
189+
190+
// convert base64 back to bytes
191+
const bytes = Uint8Array.from(atob(cipherText), (c) => c.charCodeAt(0));
192+
193+
// extract IV from the head
194+
const iv = bytes.slice(0, this.IV_BYTES);
195+
const cipher = bytes.slice(this.IV_BYTES);
196+
let lastError: unknown;
197+
198+
for (const key of keys) {
199+
let decrypted: ArrayBuffer;
200+
try {
201+
decrypted = await crypto.subtle.decrypt({ name: algorithm, iv }, key, cipher);
202+
} catch (err) {
203+
lastError = err;
204+
continue;
205+
}
206+
return this.decoder.decode(decrypted);
207+
}
111208

112-
return this.decoder.decode(decrypted);
209+
throw lastError;
113210
}
114211

115212
// base override
@@ -138,7 +235,7 @@ class EncryptedHandler extends DefaultPrismaProxyHandler {
138235
const realModel = this.queryUtils.getDelegateConcreteModel(model, entityData);
139236

140237
for (const field of getModelFields(entityData)) {
141-
// Don't decrypt null, undefined or empty string values
238+
// don't decrypt null, undefined or empty string values
142239
if (!entityData[field]) continue;
143240

144241
const fieldInfo = await resolveField(this.options.modelMeta, realModel, field);
@@ -169,7 +266,7 @@ class EncryptedHandler extends DefaultPrismaProxyHandler {
169266
private async preprocessWritePayload(model: string, action: PrismaWriteActionType, args: any) {
170267
const visitor = new NestedWriteVisitor(this.options.modelMeta, {
171268
field: async (field, _action, data, context) => {
172-
// Don't encrypt null, undefined or empty string values
269+
// don't encrypt null, undefined or empty string values
173270
if (!data) return;
174271

175272
const encAttr = field.attributes?.find((attr) => attr.name === '@encrypted');

packages/runtime/src/types.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,9 +173,38 @@ export type ZodSchemas = {
173173
input?: Record<string, Record<string, z.ZodSchema>>;
174174
};
175175

176+
/**
177+
* Simple encryption settings for processing fields marked with `@encrypted`.
178+
*/
179+
export type SimpleEncryption = {
180+
/**
181+
* The encryption key.
182+
*/
183+
encryptionKey: Uint8Array;
184+
185+
/**
186+
* Optional list of all decryption keys that were previously used to encrypt the data
187+
* , for supporting key rotation. The `encryptionKey` field value is automatically
188+
* included for decryption.
189+
*
190+
* When the encrypted data is persisted, a metadata object containing the digest of the
191+
* encryption key is stored alongside the data. This digest is used to quickly determine
192+
* the correct decryption key to use when reading the data.
193+
*/
194+
decryptionKeys?: Uint8Array[];
195+
};
196+
197+
/**
198+
* Custom encryption settings for processing fields marked with `@encrypted`.
199+
*/
176200
export type CustomEncryption = {
201+
/**
202+
* Encryption function.
203+
*/
177204
encrypt: (model: string, field: FieldInfo, plain: string) => Promise<string>;
205+
206+
/**
207+
* Decryption function
208+
*/
178209
decrypt: (model: string, field: FieldInfo, cipher: string) => Promise<string>;
179210
};
180-
181-
export type SimpleEncryption = { encryptionKey: Uint8Array };

tests/integration/tests/enhancements/with-encrypted/with-encrypted.test.ts

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,13 +143,12 @@ describe('Encrypted test', () => {
143143
});
144144

145145
it('Custom encryption test', async () => {
146-
const { enhance } = await loadSchema(`
146+
const { enhance, prisma } = await loadSchema(`
147147
model User {
148148
id String @id @default(cuid())
149149
encrypted_value String @encrypted()
150150
}`);
151151

152-
const sudoDb = enhance(undefined, { kinds: [] });
153152
const db = enhance(undefined, {
154153
kinds: ['encryption'],
155154
encryption: {
@@ -181,15 +180,83 @@ describe('Encrypted test', () => {
181180
},
182181
});
183182

184-
const sudoRead = await sudoDb.user.findUnique({
183+
const rawRead = await prisma.user.findUnique({
185184
where: {
186185
id: '1',
187186
},
188187
});
189188

190189
expect(create.encrypted_value).toBe('abc123');
191190
expect(read.encrypted_value).toBe('abc123');
192-
expect(sudoRead.encrypted_value).toBe('abc123_enc');
191+
expect(rawRead.encrypted_value).toBe('abc123_enc');
192+
});
193+
194+
it('Works with multiple decryption keys', async () => {
195+
const { enhanceRaw: enhance, prisma } = await loadSchema(
196+
`
197+
model User {
198+
id String @id @default(cuid())
199+
secret String @encrypted()
200+
}`
201+
);
202+
203+
const key1 = crypto.getRandomValues(new Uint8Array(32));
204+
const key2 = crypto.getRandomValues(new Uint8Array(32));
205+
206+
const db1 = enhance(prisma, undefined, {
207+
kinds: ['encryption'],
208+
encryption: { encryptionKey: key1 },
209+
});
210+
const user1 = await db1.user.create({ data: { secret: 'user1' } });
211+
212+
const db2 = enhance(prisma, undefined, {
213+
kinds: ['encryption'],
214+
encryption: { encryptionKey: key2 },
215+
});
216+
const user2 = await db2.user.create({ data: { secret: 'user2' } });
217+
218+
const dbAll = enhance(prisma, undefined, {
219+
kinds: ['encryption'],
220+
encryption: { encryptionKey: crypto.getRandomValues(new Uint8Array(32)), decryptionKeys: [key1, key2] },
221+
});
222+
const allUsers = await dbAll.user.findMany();
223+
expect(allUsers).toEqual(expect.arrayContaining([user1, user2]));
224+
225+
const dbWithEncryptionKeyExplicitlyProvided = enhance(prisma, undefined, {
226+
kinds: ['encryption'],
227+
encryption: { encryptionKey: key1, decryptionKeys: [key1, key2] },
228+
});
229+
await expect(dbWithEncryptionKeyExplicitlyProvided.user.findMany()).resolves.toEqual(
230+
expect.arrayContaining([user1, user2])
231+
);
232+
233+
const dbWithDuplicatedKeys = enhance(prisma, undefined, {
234+
kinds: ['encryption'],
235+
encryption: { encryptionKey: key1, decryptionKeys: [key1, key1, key2, key2] },
236+
});
237+
await expect(dbWithDuplicatedKeys.user.findMany()).resolves.toEqual(expect.arrayContaining([user1, user2]));
238+
239+
const dbWithInvalidKeys = enhance(prisma, undefined, {
240+
kinds: ['encryption'],
241+
encryption: { encryptionKey: key1, decryptionKeys: [key2, crypto.getRandomValues(new Uint8Array(32))] },
242+
});
243+
await expect(dbWithInvalidKeys.user.findMany()).resolves.toEqual(expect.arrayContaining([user1, user2]));
244+
245+
const dbWithMissingKeys = enhance(prisma, undefined, {
246+
kinds: ['encryption'],
247+
encryption: { encryptionKey: key2 },
248+
});
249+
const found = await dbWithMissingKeys.user.findMany();
250+
expect(found).not.toContainEqual(user1);
251+
expect(found).toContainEqual(user2);
252+
253+
const dbWithAllWrongKeys = enhance(prisma, undefined, {
254+
kinds: ['encryption'],
255+
encryption: { encryptionKey: crypto.getRandomValues(new Uint8Array(32)) },
256+
});
257+
const found1 = await dbWithAllWrongKeys.user.findMany();
258+
expect(found1).not.toContainEqual(user1);
259+
expect(found1).not.toContainEqual(user2);
193260
});
194261

195262
it('Only supports string fields', async () => {

0 commit comments

Comments
 (0)