Skip to content

refactor(encryption): extract standalone encrypter/decrypter #1945

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 1 commit into from
Jan 7, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/runtime/package.json
Original file line number Diff line number Diff line change
@@ -80,6 +80,10 @@
"types": "./zod-utils.d.ts",
"default": "./zod-utils.js"
},
"./encryption": {
"types": "./encryption/index.d.ts",
"default": "./encryption/index.js"
},
"./package.json": {
"default": "./package.json"
}
67 changes: 67 additions & 0 deletions packages/runtime/src/encryption/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { _decrypt, _encrypt, ENCRYPTION_KEY_BYTES, getKeyDigest, loadKey } from './utils';

/**
* Default encrypter
*/
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<string> {
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);
}
}

/**
* Default decrypter
*/
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<string> {
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)
);
}
}
96 changes: 96 additions & 0 deletions packages/runtime/src/encryption/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
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(),
});

export async function loadKey(key: Uint8Array, keyUsages: KeyUsage[]): Promise<CryptoKey> {
return crypto.subtle.importKey('raw', key, ALGORITHM, false, keyUsages);
}

export async function getKeyDigest(key: Uint8Array) {
const rawDigest = await crypto.subtle.digest('SHA-256', key);
return new Uint8Array(rawDigest.slice(0, KEY_DIGEST_BYTES)).reduce(
(acc, byte) => acc + byte.toString(16).padStart(2, '0'),
''
);
}

export async function _encrypt(data: string, key: CryptoKey, keyDigest: string): Promise<string> {
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))}`;
}

export async function _decrypt(data: string, findKey: (digest: string) => Promise<CryptoKey[]>): Promise<string> {
const [metaText, cipherText] = data.split('.');
if (!metaText || !cipherText) {
throw new Error('Malformed encrypted data');
}

let metaObj: unknown;
try {
metaObj = JSON.parse(atob(metaText));
} catch (error) {
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;
}
142 changes: 11 additions & 131 deletions packages/runtime/src/enhancements/node/encryption.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unused-vars */

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

class EncryptedHandler extends DefaultPrismaProxyHandler {
private queryUtils: QueryUtils;
private encoder = new TextEncoder();
private decoder = new TextDecoder();
private logger: Logger;
private encryptionKey: CryptoKey | undefined;
private encryptionKeyDigest: string | undefined;
private decryptionKeys: Array<{ key: CryptoKey; digest: string }> = [];
private encryptionMetaSchema = z.object({
// version
v: z.number(),
// algorithm
a: z.string(),
// key digest
k: z.string(),
});

// constants
private readonly ENCRYPTION_KEY_BYTES = 32;
private readonly IV_BYTES = 12;
private readonly ALGORITHM = 'AES-GCM';
private readonly ENCRYPTER_VERSION = 1;
private readonly KEY_DIGEST_BYTES = 8;
private encrypter: Encrypter | undefined;
private decrypter: Decrypter | undefined;
Comment on lines +43 to +44
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Ensure 'this.encrypter' is defined before usage to prevent runtime errors

In cases where custom encryption is used, this.encrypter remains undefined, but in the encrypt method, this.encrypter!.encrypt(data) is called without checking if this.encrypter is defined. This could lead to a runtime error if this.encrypter is undefined. Consider adding a check to ensure this.encrypter is defined before using it or refactor the code to guarantee that this.encrypter is always initialized when not using custom encryption.

Apply this diff to check if this.encrypter is defined:

+        if (!this.encrypter) {
+            throw new Error('Encrypter is not initialized');
+        }
         return this.encrypter.encrypt(data);

Also applies to: 82-82

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Ensure 'this.decrypter' is defined before usage to prevent runtime errors

Similarly, when custom encryption is employed, this.decrypter remains undefined. In the decrypt method, calling this.decrypter!.decrypt(data) without verifying that this.decrypter is defined may result in a runtime error. Please ensure this.decrypter is defined before usage or modify the code to handle scenarios where custom encryption is used.

Apply this diff to check if this.decrypter is defined:

+        if (!this.decrypter) {
+            throw new Error('Decrypter is not initialized');
+        }
         return this.decrypter.decrypt(data);

Also applies to: 90-90


constructor(prisma: DbClientContract, model: string, options: InternalEnhancementOptions) {
super(prisma, model, options);
@@ -76,138 +61,33 @@ class EncryptedHandler extends DefaultPrismaProxyHandler {
if (!options.encryption.encryptionKey) {
throw this.queryUtils.unknownError('Encryption key must be provided');
}
if (options.encryption.encryptionKey.length !== this.ENCRYPTION_KEY_BYTES) {
throw this.queryUtils.unknownError(`Encryption key must be ${this.ENCRYPTION_KEY_BYTES} bytes`);
}

this.encrypter = new Encrypter(options.encryption.encryptionKey);
this.decrypter = new Decrypter([
options.encryption.encryptionKey,
...(options.encryption.decryptionKeys || []),
]);
}
}

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

private async loadKey(key: Uint8Array, keyUsages: KeyUsage[]): Promise<CryptoKey> {
return crypto.subtle.importKey('raw', key, this.ALGORITHM, false, keyUsages);
}

private async computeKeyDigest(key: Uint8Array) {
const rawDigest = await crypto.subtle.digest('SHA-256', key);
return new Uint8Array(rawDigest.slice(0, this.KEY_DIGEST_BYTES)).reduce(
(acc, byte) => acc + byte.toString(16).padStart(2, '0'),
''
);
}

private async getEncryptionKey(): Promise<CryptoKey> {
if (this.isCustomEncryption(this.options.encryption!)) {
throw new Error('Unexpected custom encryption settings');
}
if (!this.encryptionKey) {
this.encryptionKey = await this.loadKey(this.options.encryption!.encryptionKey, ['encrypt', 'decrypt']);
}
return this.encryptionKey;
}

private async getEncryptionKeyDigest() {
if (this.isCustomEncryption(this.options.encryption!)) {
throw new Error('Unexpected custom encryption settings');
}
if (!this.encryptionKeyDigest) {
this.encryptionKeyDigest = await this.computeKeyDigest(this.options.encryption!.encryptionKey);
}
return this.encryptionKeyDigest;
}

private async findDecryptionKeys(keyDigest: string): Promise<CryptoKey[]> {
if (this.isCustomEncryption(this.options.encryption!)) {
throw new Error('Unexpected custom encryption settings');
}

if (this.decryptionKeys.length === 0) {
const keys = [this.options.encryption!.encryptionKey, ...(this.options.encryption!.decryptionKeys || [])];
this.decryptionKeys = await Promise.all(
keys.map(async (key) => ({
key: await this.loadKey(key, ['decrypt']),
digest: await this.computeKeyDigest(key),
}))
);
}

return this.decryptionKeys.filter((entry) => entry.digest === keyDigest).map((entry) => entry.key);
}

private async encrypt(field: FieldInfo, data: string): Promise<string> {
if (this.isCustomEncryption(this.options.encryption!)) {
return this.options.encryption.encrypt(this.model, field, data);
}

const key = await this.getEncryptionKey();
const iv = crypto.getRandomValues(new Uint8Array(this.IV_BYTES));
const encrypted = await crypto.subtle.encrypt(
{
name: this.ALGORITHM,
iv,
},
key,
this.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: this.ENCRYPTER_VERSION, a: this.ALGORITHM, k: await this.getEncryptionKeyDigest() };

// convert concatenated result to base64 string
return `${btoa(JSON.stringify(meta))}.${btoa(String.fromCharCode(...cipherBytes))}`;
return this.encrypter!.encrypt(data);
}

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

const [metaText, cipherText] = data.split('.');
if (!metaText || !cipherText) {
throw new Error('Malformed encrypted data');
}

let metaObj: unknown;
try {
metaObj = JSON.parse(atob(metaText));
} catch (error) {
throw new Error('Malformed metadata');
}

// parse meta
const { a: algorithm, k: keyDigest } = this.encryptionMetaSchema.parse(metaObj);

// find a matching decryption key
const keys = await this.findDecryptionKeys(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, this.IV_BYTES);
const cipher = bytes.slice(this.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 this.decoder.decode(decrypted);
}

throw lastError;
return this.decrypter!.decrypt(data);
}

// base override