import { randomBytes, createCipheriv, createDecipheriv, createHash, pbkdf2Sync, randomUUID, } from 'crypto'; import { genSalt, hash, compare } from 'bcrypt'; import { deflateSync, inflateSync } from 'zlib'; import { Env } from './env'; import { createLogger } from './logger'; const logger = createLogger('crypto'); const saltRounds = 10; function base64UrlSafe(data: string): string { return Buffer.from(data) .toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); } function fromUrlSafeBase64(data: string): string { // Add padding if needed const padding = data.length % 4; const paddedData = padding ? data + '='.repeat(4 - padding) : data; return Buffer.from( paddedData.replace(/-/g, '+').replace(/_/g, '/'), 'base64' ).toString('utf-8'); } const compressData = (data: string): Buffer => { return deflateSync(Buffer.from(data, 'utf-8'), { level: 9, }); }; const decompressData = (data: Buffer): string => { return inflateSync(data).toString('utf-8'); }; const encryptData = ( secretKey: Buffer, data: Buffer ): { iv: string; data: string } => { // Then encrypt the compressed data const iv = randomBytes(16); const cipher = createCipheriv('aes-256-cbc', secretKey, iv); const encryptedData = Buffer.concat([cipher.update(data), cipher.final()]); return { iv: iv.toString('base64'), data: encryptedData.toString('base64'), }; }; const decryptData = ( secretKey: Buffer, encryptedData: Buffer, iv: Buffer ): Buffer => { const decipher = createDecipheriv('aes-256-cbc', secretKey, iv); // Decrypt the data const decryptedData = Buffer.concat([ decipher.update(encryptedData), decipher.final(), ]); return decryptedData; }; type SuccessResponse = { success: true; data: string; error: null; }; type ErrorResponse = { success: false; error: string; data: null; }; export type Response = SuccessResponse | ErrorResponse; export function isEncrypted(data: string): boolean { try { // parse the data as json const json = JSON.parse(fromUrlSafeBase64(data)); return json.type === 'aioEncrypt'; } catch (error) { return false; } } /** * Encrypts a string using AES-256-CBC encryption, returns a string in the format "iv:encrypted" where * iv and encrypted are url encoded. * @param data Data to encrypt * @param secretKey Secret key used for encryption * @returns Encrypted data or error message */ export function encryptString(data: string, secretKey?: Buffer): Response { if (!secretKey) { secretKey = Buffer.from(Env.SECRET_KEY, 'hex'); } try { const compressed = compressData(data); const { iv, data: encrypted } = encryptData(secretKey, compressed); return { success: true, data: base64UrlSafe( JSON.stringify({ iv, encrypted, type: 'aioEncrypt' }) ), error: null, }; } catch (error: any) { logger.error(`Failed to encrypt data: ${error.message}`); return { success: false, error: error.message, data: null, }; } } /** * Decrypts a string using AES-256-CBC encryption * @param data Encrypted data to decrypt * @param secretKey Secret key used for encryption * @returns Decrypted data or error message */ export function decryptString(data: string, secretKey?: Buffer): Response { if (!secretKey) { secretKey = Buffer.from(Env.SECRET_KEY, 'hex'); } try { if (!isEncrypted(data)) { throw new Error('The data was not in an expected encrypted format'); } const json = JSON.parse(fromUrlSafeBase64(data)); const iv = Buffer.from(json.iv, 'base64'); const encrypted = Buffer.from(json.encrypted, 'base64'); const decrypted = decryptData(secretKey, encrypted, iv); const decompressed = decompressData(decrypted); return { success: true, data: decompressed, error: null, }; } catch (error: any) { logger.error(`Failed to decrypt data: ${error.message}`); return { success: false, error: error.message, data: null, }; } } export function getSimpleTextHash(text: string): string { return createHash('sha256').update(text).digest('hex'); } /** * Creates a secure hash of text using PBKDF2 * @param text Text to hash * @returns Object containing the hash and salt used */ export async function getTextHash(text: string): Promise { return await hash(text, await genSalt(saltRounds)); } /** * Verifies if the provided text matches a previously generated hash * @param text Text to verify * @param storedHash Previously generated hash * @returns Boolean indicating if the text matches the hash */ export async function verifyHash( text: string, storedHash: string ): Promise { return compare(text, storedHash); } /** * Derives a 64 character hex string from a password using PBKDF2 * @param password Password to derive key from * @param salt Optional salt, will be generated if not provided * @returns Object containing the key and salt used */ export async function deriveKey( password: string, salt?: string ): Promise<{ key: Buffer; salt: string }> { salt = salt || (await genSalt(saltRounds)); const key = pbkdf2Sync( Buffer.from(password, 'utf-8'), Buffer.from(salt, 'hex'), 100000, 32, 'sha512' ); return { key, salt }; } export function generateUUID(): string { return randomUUID(); }