|
1 | 1 | /* eslint-disable @typescript-eslint/no-explicit-any */
|
2 | 2 | /* eslint-disable @typescript-eslint/no-unused-vars */
|
3 | 3 |
|
| 4 | +import { z } from 'zod'; |
4 | 5 | import {
|
5 | 6 | FieldInfo,
|
6 | 7 | NestedWriteVisitor,
|
@@ -37,79 +38,175 @@ class EncryptedHandler extends DefaultPrismaProxyHandler {
|
37 | 38 | private encoder = new TextEncoder();
|
38 | 39 | private decoder = new TextDecoder();
|
39 | 40 | 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; |
40 | 59 |
|
41 | 60 | constructor(prisma: DbClientContract, model: string, options: InternalEnhancementOptions) {
|
42 | 61 | super(prisma, model, options);
|
43 | 62 |
|
44 | 63 | this.queryUtils = new QueryUtils(prisma, options);
|
45 | 64 | this.logger = new Logger(prisma);
|
46 | 65 |
|
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 | + } |
48 | 69 |
|
49 | 70 | if (this.isCustomEncryption(options.encryption!)) {
|
50 |
| - if (!options.encryption.encrypt || !options.encryption.decrypt) |
| 71 | + if (!options.encryption.encrypt || !options.encryption.decrypt) { |
51 | 72 | throw this.queryUtils.unknownError('Custom encryption must provide encrypt and decrypt functions');
|
| 73 | + } |
52 | 74 | } else {
|
53 |
| - if (!options.encryption.encryptionKey) |
| 75 | + if (!options.encryption.encryptionKey) { |
54 | 76 | 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 | + } |
57 | 81 | }
|
58 | 82 | }
|
59 | 83 |
|
60 |
| - private async getKey(secret: Uint8Array): Promise<CryptoKey> { |
61 |
| - return crypto.subtle.importKey('raw', secret, 'AES-GCM', false, ['encrypt', 'decrypt']); |
62 |
| - } |
63 |
| - |
64 | 84 | private isCustomEncryption(encryption: CustomEncryption | SimpleEncryption): encryption is CustomEncryption {
|
65 | 85 | return 'encrypt' in encryption && 'decrypt' in encryption;
|
66 | 86 | }
|
67 | 87 |
|
| 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 | + |
68 | 138 | private async encrypt(field: FieldInfo, data: string): Promise<string> {
|
69 | 139 | if (this.isCustomEncryption(this.options.encryption!)) {
|
70 | 140 | return this.options.encryption.encrypt(this.model, field, data);
|
71 | 141 | }
|
72 | 142 |
|
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)); |
76 | 145 | const encrypted = await crypto.subtle.encrypt(
|
77 | 146 | {
|
78 |
| - name: 'AES-GCM', |
| 147 | + name: this.ALGORITHM, |
79 | 148 | iv,
|
80 | 149 | },
|
81 | 150 | key,
|
82 | 151 | this.encoder.encode(data)
|
83 | 152 | );
|
84 | 153 |
|
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() }; |
87 | 159 |
|
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))}`; |
90 | 162 | }
|
91 | 163 |
|
92 | 164 | private async decrypt(field: FieldInfo, data: string): Promise<string> {
|
93 | 165 | if (this.isCustomEncryption(this.options.encryption!)) {
|
94 | 166 | return this.options.encryption.decrypt(this.model, field, data);
|
95 | 167 | }
|
96 | 168 |
|
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 | + } |
98 | 173 |
|
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 | + } |
101 | 180 |
|
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 | + } |
111 | 208 |
|
112 |
| - return this.decoder.decode(decrypted); |
| 209 | + throw lastError; |
113 | 210 | }
|
114 | 211 |
|
115 | 212 | // base override
|
@@ -138,7 +235,7 @@ class EncryptedHandler extends DefaultPrismaProxyHandler {
|
138 | 235 | const realModel = this.queryUtils.getDelegateConcreteModel(model, entityData);
|
139 | 236 |
|
140 | 237 | 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 |
142 | 239 | if (!entityData[field]) continue;
|
143 | 240 |
|
144 | 241 | const fieldInfo = await resolveField(this.options.modelMeta, realModel, field);
|
@@ -169,7 +266,7 @@ class EncryptedHandler extends DefaultPrismaProxyHandler {
|
169 | 266 | private async preprocessWritePayload(model: string, action: PrismaWriteActionType, args: any) {
|
170 | 267 | const visitor = new NestedWriteVisitor(this.options.modelMeta, {
|
171 | 268 | 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 |
173 | 270 | if (!data) return;
|
174 | 271 |
|
175 | 272 | const encAttr = field.attributes?.find((attr) => attr.name === '@encrypted');
|
|
0 commit comments