Skip to content

Commit 4605278

Browse files
authored
refactor(encryption): extract standalone encrypter/decrypter (#1945)
1 parent 96d0ce5 commit 4605278

File tree

4 files changed

+178
-131
lines changed

4 files changed

+178
-131
lines changed

packages/runtime/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@
8080
"types": "./zod-utils.d.ts",
8181
"default": "./zod-utils.js"
8282
},
83+
"./encryption": {
84+
"types": "./encryption/index.d.ts",
85+
"default": "./encryption/index.js"
86+
},
8387
"./package.json": {
8488
"default": "./package.json"
8589
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { _decrypt, _encrypt, ENCRYPTION_KEY_BYTES, getKeyDigest, loadKey } from './utils';
2+
3+
/**
4+
* Default encrypter
5+
*/
6+
export class Encrypter {
7+
private key: CryptoKey | undefined;
8+
private keyDigest: string | undefined;
9+
10+
constructor(private readonly encryptionKey: Uint8Array) {
11+
if (encryptionKey.length !== ENCRYPTION_KEY_BYTES) {
12+
throw new Error(`Encryption key must be ${ENCRYPTION_KEY_BYTES} bytes`);
13+
}
14+
}
15+
16+
/**
17+
* Encrypts the given data
18+
*/
19+
async encrypt(data: string): Promise<string> {
20+
if (!this.key) {
21+
this.key = await loadKey(this.encryptionKey, ['encrypt']);
22+
}
23+
24+
if (!this.keyDigest) {
25+
this.keyDigest = await getKeyDigest(this.encryptionKey);
26+
}
27+
28+
return _encrypt(data, this.key, this.keyDigest);
29+
}
30+
}
31+
32+
/**
33+
* Default decrypter
34+
*/
35+
export class Decrypter {
36+
private keys: Array<{ key: CryptoKey; digest: string }> = [];
37+
38+
constructor(private readonly decryptionKeys: Uint8Array[]) {
39+
if (decryptionKeys.length === 0) {
40+
throw new Error('At least one decryption key must be provided');
41+
}
42+
43+
for (const key of decryptionKeys) {
44+
if (key.length !== ENCRYPTION_KEY_BYTES) {
45+
throw new Error(`Decryption key must be ${ENCRYPTION_KEY_BYTES} bytes`);
46+
}
47+
}
48+
}
49+
50+
/**
51+
* Decrypts the given data
52+
*/
53+
async decrypt(data: string): Promise<string> {
54+
if (this.keys.length === 0) {
55+
this.keys = await Promise.all(
56+
this.decryptionKeys.map(async (key) => ({
57+
key: await loadKey(key, ['decrypt']),
58+
digest: await getKeyDigest(key),
59+
}))
60+
);
61+
}
62+
63+
return _decrypt(data, async (digest) =>
64+
this.keys.filter((entry) => entry.digest === digest).map((entry) => entry.key)
65+
);
66+
}
67+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { z } from 'zod';
2+
3+
export const ENCRYPTER_VERSION = 1;
4+
export const ENCRYPTION_KEY_BYTES = 32;
5+
export const IV_BYTES = 12;
6+
export const ALGORITHM = 'AES-GCM';
7+
export const KEY_DIGEST_BYTES = 8;
8+
9+
const encoder = new TextEncoder();
10+
const decoder = new TextDecoder();
11+
12+
const encryptionMetaSchema = z.object({
13+
// version
14+
v: z.number(),
15+
// algorithm
16+
a: z.string(),
17+
// key digest
18+
k: z.string(),
19+
});
20+
21+
export async function loadKey(key: Uint8Array, keyUsages: KeyUsage[]): Promise<CryptoKey> {
22+
return crypto.subtle.importKey('raw', key, ALGORITHM, false, keyUsages);
23+
}
24+
25+
export async function getKeyDigest(key: Uint8Array) {
26+
const rawDigest = await crypto.subtle.digest('SHA-256', key);
27+
return new Uint8Array(rawDigest.slice(0, KEY_DIGEST_BYTES)).reduce(
28+
(acc, byte) => acc + byte.toString(16).padStart(2, '0'),
29+
''
30+
);
31+
}
32+
33+
export async function _encrypt(data: string, key: CryptoKey, keyDigest: string): Promise<string> {
34+
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
35+
const encrypted = await crypto.subtle.encrypt(
36+
{
37+
name: ALGORITHM,
38+
iv,
39+
},
40+
key,
41+
encoder.encode(data)
42+
);
43+
44+
// combine IV and encrypted data into a single array of bytes
45+
const cipherBytes = [...iv, ...new Uint8Array(encrypted)];
46+
47+
// encryption metadata
48+
const meta = { v: ENCRYPTER_VERSION, a: ALGORITHM, k: keyDigest };
49+
50+
// convert concatenated result to base64 string
51+
return `${btoa(JSON.stringify(meta))}.${btoa(String.fromCharCode(...cipherBytes))}`;
52+
}
53+
54+
export async function _decrypt(data: string, findKey: (digest: string) => Promise<CryptoKey[]>): Promise<string> {
55+
const [metaText, cipherText] = data.split('.');
56+
if (!metaText || !cipherText) {
57+
throw new Error('Malformed encrypted data');
58+
}
59+
60+
let metaObj: unknown;
61+
try {
62+
metaObj = JSON.parse(atob(metaText));
63+
} catch (error) {
64+
throw new Error('Malformed metadata');
65+
}
66+
67+
// parse meta
68+
const { a: algorithm, k: keyDigest } = encryptionMetaSchema.parse(metaObj);
69+
70+
// find a matching decryption key
71+
const keys = await findKey(keyDigest);
72+
if (keys.length === 0) {
73+
throw new Error('No matching decryption key found');
74+
}
75+
76+
// convert base64 back to bytes
77+
const bytes = Uint8Array.from(atob(cipherText), (c) => c.charCodeAt(0));
78+
79+
// extract IV from the head
80+
const iv = bytes.slice(0, IV_BYTES);
81+
const cipher = bytes.slice(IV_BYTES);
82+
let lastError: unknown;
83+
84+
for (const key of keys) {
85+
let decrypted: ArrayBuffer;
86+
try {
87+
decrypted = await crypto.subtle.decrypt({ name: algorithm, iv }, key, cipher);
88+
} catch (err) {
89+
lastError = err;
90+
continue;
91+
}
92+
return decoder.decode(decrypted);
93+
}
94+
95+
throw lastError;
96+
}

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

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

4-
import { z } from 'zod';
54
import { ACTIONS_WITH_WRITE_PAYLOAD } from '../../constants';
65
import {
76
FieldInfo,
@@ -11,6 +10,7 @@ import {
1110
resolveField,
1211
type PrismaWriteActionType,
1312
} from '../../cross';
13+
import { Decrypter, Encrypter } from '../../encryption';
1414
import { CustomEncryption, DbClientContract, SimpleEncryption } from '../../types';
1515
import { InternalEnhancementOptions } from './create-enhancement';
1616
import { Logger } from './logger';
@@ -36,27 +36,12 @@ export function withEncrypted<DbClient extends object = any>(
3636

3737
class EncryptedHandler extends DefaultPrismaProxyHandler {
3838
private queryUtils: QueryUtils;
39-
private encoder = new TextEncoder();
40-
private decoder = new TextDecoder();
4139
private logger: Logger;
4240
private encryptionKey: CryptoKey | undefined;
4341
private encryptionKeyDigest: string | undefined;
4442
private decryptionKeys: Array<{ key: CryptoKey; digest: string }> = [];
45-
private encryptionMetaSchema = z.object({
46-
// version
47-
v: z.number(),
48-
// algorithm
49-
a: z.string(),
50-
// key digest
51-
k: z.string(),
52-
});
53-
54-
// constants
55-
private readonly ENCRYPTION_KEY_BYTES = 32;
56-
private readonly IV_BYTES = 12;
57-
private readonly ALGORITHM = 'AES-GCM';
58-
private readonly ENCRYPTER_VERSION = 1;
59-
private readonly KEY_DIGEST_BYTES = 8;
43+
private encrypter: Encrypter | undefined;
44+
private decrypter: Decrypter | undefined;
6045

6146
constructor(prisma: DbClientContract, model: string, options: InternalEnhancementOptions) {
6247
super(prisma, model, options);
@@ -76,138 +61,33 @@ class EncryptedHandler extends DefaultPrismaProxyHandler {
7661
if (!options.encryption.encryptionKey) {
7762
throw this.queryUtils.unknownError('Encryption key must be provided');
7863
}
79-
if (options.encryption.encryptionKey.length !== this.ENCRYPTION_KEY_BYTES) {
80-
throw this.queryUtils.unknownError(`Encryption key must be ${this.ENCRYPTION_KEY_BYTES} bytes`);
81-
}
64+
65+
this.encrypter = new Encrypter(options.encryption.encryptionKey);
66+
this.decrypter = new Decrypter([
67+
options.encryption.encryptionKey,
68+
...(options.encryption.decryptionKeys || []),
69+
]);
8270
}
8371
}
8472

8573
private isCustomEncryption(encryption: CustomEncryption | SimpleEncryption): encryption is CustomEncryption {
8674
return 'encrypt' in encryption && 'decrypt' in encryption;
8775
}
8876

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

144-
const key = await this.getEncryptionKey();
145-
const iv = crypto.getRandomValues(new Uint8Array(this.IV_BYTES));
146-
const encrypted = await crypto.subtle.encrypt(
147-
{
148-
name: this.ALGORITHM,
149-
iv,
150-
},
151-
key,
152-
this.encoder.encode(data)
153-
);
154-
155-
// combine IV and encrypted data into a single array of bytes
156-
const cipherBytes = [...iv, ...new Uint8Array(encrypted)];
157-
158-
// encryption metadata
159-
const meta = { v: this.ENCRYPTER_VERSION, a: this.ALGORITHM, k: await this.getEncryptionKeyDigest() };
160-
161-
// convert concatenated result to base64 string
162-
return `${btoa(JSON.stringify(meta))}.${btoa(String.fromCharCode(...cipherBytes))}`;
82+
return this.encrypter!.encrypt(data);
16383
}
16484

16585
private async decrypt(field: FieldInfo, data: string): Promise<string> {
16686
if (this.isCustomEncryption(this.options.encryption!)) {
16787
return this.options.encryption.decrypt(this.model, field, data);
16888
}
16989

170-
const [metaText, cipherText] = data.split('.');
171-
if (!metaText || !cipherText) {
172-
throw new Error('Malformed encrypted data');
173-
}
174-
175-
let metaObj: unknown;
176-
try {
177-
metaObj = JSON.parse(atob(metaText));
178-
} catch (error) {
179-
throw new Error('Malformed metadata');
180-
}
181-
182-
// parse meta
183-
const { a: algorithm, k: keyDigest } = this.encryptionMetaSchema.parse(metaObj);
184-
185-
// find a matching decryption key
186-
const keys = await this.findDecryptionKeys(keyDigest);
187-
if (keys.length === 0) {
188-
throw new Error('No matching decryption key found');
189-
}
190-
191-
// convert base64 back to bytes
192-
const bytes = Uint8Array.from(atob(cipherText), (c) => c.charCodeAt(0));
193-
194-
// extract IV from the head
195-
const iv = bytes.slice(0, this.IV_BYTES);
196-
const cipher = bytes.slice(this.IV_BYTES);
197-
let lastError: unknown;
198-
199-
for (const key of keys) {
200-
let decrypted: ArrayBuffer;
201-
try {
202-
decrypted = await crypto.subtle.decrypt({ name: algorithm, iv }, key, cipher);
203-
} catch (err) {
204-
lastError = err;
205-
continue;
206-
}
207-
return this.decoder.decode(decrypted);
208-
}
209-
210-
throw lastError;
90+
return this.decrypter!.decrypt(data);
21191
}
21292

21393
// base override

0 commit comments

Comments
 (0)