From 3743206c482b0bc9ea5aae7db2ceeddedf1023ff Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Wed, 4 Feb 2026 12:32:36 +0000 Subject: [PATCH] feat: implement V2 encryption and decryption methods in Encryptor class --- backend/src/helpers/encryption/encryptor.ts | 568 ++++++++++-------- backend/test/ava-tests/encryptor.test.ts | 200 ++++++ .../src/helpers/encryption/encryptor.ts | 111 +++- 3 files changed, 612 insertions(+), 267 deletions(-) create mode 100644 backend/test/ava-tests/encryptor.test.ts diff --git a/backend/src/helpers/encryption/encryptor.ts b/backend/src/helpers/encryption/encryptor.ts index 7bcae1a7b..5a8cb460f 100644 --- a/backend/src/helpers/encryption/encryptor.ts +++ b/backend/src/helpers/encryption/encryptor.ts @@ -1,3 +1,4 @@ +/** biome-ignore-all lint/complexity/noStaticOnlyClass: */ import argon2 from 'argon2'; import bcrypt from 'bcrypt'; import crypto, { createHmac, randomBytes, scrypt } from 'crypto'; @@ -6,251 +7,326 @@ import { ConnectionEntity } from '../../entities/connection/connection.entity.js import { EncryptionAlgorithmEnum } from '../../enums/index.js'; import { Constants } from '../constants/constants.js'; +const ENCRYPTION_VERSION_PREFIX = '$v2:k1$'; +const ENCRYPTION_ALGORITHM = 'aes-256-gcm'; +const KDF_ITERATIONS = 100000; +const SALT_LENGTH = 32; +const IV_LENGTH = 16; +const KEY_LENGTH = 32; + export class Encryptor { - static getPrivateKey(): string { - return process.env.PRIVATE_KEY; - } - - static encryptData(data: string): string { - try { - const privateKey = Encryptor.getPrivateKey(); - return CryptoJS.AES.encrypt(data, privateKey).toString(); - } catch (e) { - console.log('-> Encryption error', e); - return data; - } - } - - static decryptData(encryptedData: string): string { - try { - const privateKey = Encryptor.getPrivateKey(); - const bytes = CryptoJS.AES.decrypt(encryptedData, privateKey); - return bytes.toString(CryptoJS.enc.Utf8); - } catch (e) { - throw new Error('Data decryption failed with error: ' + e); - } - } - - static encryptDataMasterPwd(data: string, masterPwd: string): string { - return CryptoJS.AES.encrypt(data, masterPwd).toString(); - } - - static decryptDataMasterPwd(encryptedData: string, masterPwd: string): string { - try { - const bytes = CryptoJS.AES.decrypt(encryptedData, masterPwd); - return bytes.toString(CryptoJS.enc.Utf8); - } catch (e) { - throw new Error('Data decryption with master password failed with error: ' + e); - } - } - - static encryptConnectionCredentials(connection: ConnectionEntity, masterPwd: string): ConnectionEntity { - if (connection.username) connection.username = Encryptor.encryptDataMasterPwd(connection.username, masterPwd); - if (connection.database) connection.database = Encryptor.encryptDataMasterPwd(connection.database, masterPwd); - if (connection.password) connection.password = Encryptor.encryptDataMasterPwd(connection.password, masterPwd); - if (connection.authSource) connection.authSource = Encryptor.encryptDataMasterPwd(connection.authSource, masterPwd); - if (connection.ssh) { - if (connection.privateSSHKey) - connection.privateSSHKey = Encryptor.encryptDataMasterPwd(connection.privateSSHKey, masterPwd); - if (connection.sshHost) connection.sshHost = Encryptor.encryptDataMasterPwd(connection.sshHost, masterPwd); - if (connection.sshUsername) connection.sshUsername = Encryptor.encryptDataMasterPwd(connection.sshUsername, masterPwd); - } - if (connection.ssl) { - if (connection.cert) connection.cert = Encryptor.encryptDataMasterPwd(connection.cert, masterPwd); - } - return connection; - } - - //todo types - static decryptConnectionCredentials(connection: ConnectionEntity, masterPwd: string): ConnectionEntity { - if (connection.username) connection.username = Encryptor.decryptDataMasterPwd(connection.username, masterPwd); - if (connection.database) connection.database = Encryptor.decryptDataMasterPwd(connection.database, masterPwd); - if (connection.password) { - connection.password = Encryptor.decryptDataMasterPwd(connection.password, masterPwd); - } - if (connection.authSource) connection.authSource = Encryptor.decryptDataMasterPwd(connection.authSource, masterPwd); - if (connection.ssh) { - if (connection.privateSSHKey) - connection.privateSSHKey = Encryptor.decryptDataMasterPwd(connection.privateSSHKey, masterPwd); - if (connection.sshHost) connection.sshHost = Encryptor.decryptDataMasterPwd(connection.sshHost, masterPwd); - if (connection.sshUsername) connection.sshUsername = Encryptor.decryptDataMasterPwd(connection.sshUsername, masterPwd); - } - if (connection.ssl) { - if (connection.cert) connection.cert = Encryptor.decryptDataMasterPwd(connection.cert, masterPwd); - } - return connection; - } - - static hashDataHMAC(dataToHash: string): string { - const privateKey = Encryptor.getPrivateKey(); - const hmac = createHmac('sha256', privateKey); - hmac.update(dataToHash); - return hmac.digest('hex'); - } - - static hashDataHMACexternalKey(key: string, dataToHash: string): string { - const hmac = createHmac('sha256', key); - hmac.update(dataToHash); - return hmac.digest('hex'); - } - - static getUserIntercomHash(userId: string): string | null { - const intercomKey = process.env.INTERCOM_KEY; - if (!intercomKey) { - return null; - } - try { - const hmac = createHmac('sha256', intercomKey); - hmac.update(userId); - return hmac.digest('hex'); - } catch (e) { - console.error(e); - return null; - } - } - - static async processDataWithAlgorithm(data: string, alg: EncryptionAlgorithmEnum): Promise { - if (!alg) { - return data; - } - try { - let hash; - switch (alg) { - case EncryptionAlgorithmEnum.sha1: - hash = CryptoJS.SHA1(data); - return hash.toString(CryptoJS.enc.Hex); - - case EncryptionAlgorithmEnum.sha3: - hash = CryptoJS.SHA3(data); - return hash.toString(CryptoJS.enc.Hex); - - case EncryptionAlgorithmEnum.sha256: - hash = CryptoJS.SHA256(data); - return hash.toString(CryptoJS.enc.Hex); - - case EncryptionAlgorithmEnum.sha224: - hash = CryptoJS.SHA224(data); - return hash.toString(CryptoJS.enc.Hex); - - case EncryptionAlgorithmEnum.sha512: - hash = CryptoJS.SHA512(data); - return hash.toString(CryptoJS.enc.Hex); - - case EncryptionAlgorithmEnum.sha384: - hash = CryptoJS.SHA384(data); - return hash.toString(CryptoJS.enc.Hex); - - case EncryptionAlgorithmEnum.pbkdf2: { - const salt = CryptoJS.lib.WordArray.random(128 / 8); - return CryptoJS.PBKDF2(data, salt, { - keySize: 256 / 32, - }).toString(); - } - - case EncryptionAlgorithmEnum.bcrypt: { - const bSalt = await bcrypt.genSalt(); - return await bcrypt.hash(data, bSalt); - } - - case EncryptionAlgorithmEnum.argon2: - return await argon2.hash(data); - - case EncryptionAlgorithmEnum.scrypt: - return await Encryptor.scryptHash(data); - - default: - return data; - } - } catch (_e) { - return data; - } - } - - static async scryptHash(data: string): Promise { - return new Promise((resolve, reject) => { - const salt = randomBytes(16).toString('hex'); - scrypt(data, salt, 64, (err, derivedData) => { - if (err) reject(err); - resolve(salt + ':' + derivedData.toString('hex')); - }); - }); - } - - static async hashUserPassword(password: string): Promise { - if (!password || password.length <= 0) return password; - return new Promise((resolve, reject) => { - const salt = crypto.randomBytes(Constants.PASSWORD_SALT_LENGTH).toString(Constants.BYTE_TO_STRING_ENCODING); - crypto.pbkdf2( - password, - salt, - Constants.PASSWORD_HASH_ITERATIONS, - Constants.PASSWORD_LENGTH, - Constants.DIGEST, - (error, hash) => { - if (error) { - reject(error); - } else { - const result = - 'pbkdf2$' + - Constants.PASSWORD_HASH_ITERATIONS + - '$' + - hash.toString(Constants.BYTE_TO_STRING_ENCODING) + - '$' + - salt; - resolve(result); - } - }, - ); - }); - } - - static async verifyUserPassword(receivedPassword: string, hashedPassword: string): Promise { - return new Promise((resolve, reject) => { - try { - const passwordHashParts: Array = hashedPassword.split('$'); - const alg = passwordHashParts[0]; - const iterations = parseInt(passwordHashParts[1], 10); - const passwordHash = passwordHashParts[2]; - const salt = passwordHashParts[3]; - if (passwordHashParts.length !== 4 || alg !== 'pbkdf2' || iterations !== Constants.PASSWORD_HASH_ITERATIONS) { - resolve(false); - } - crypto.pbkdf2( - receivedPassword, - salt, - iterations, - Constants.PASSWORD_LENGTH, - Constants.DIGEST, - (error, hash) => { - if (error) { - reject(error); - } else { - try { - const hashToString = hash.toString(Constants.BYTE_TO_STRING_ENCODING); - const result = crypto.timingSafeEqual(Buffer.from(passwordHash), Buffer.from(hashToString)); - resolve(result); - } catch (_e) { - resolve(false); - } - } - }, - ); - } catch (_e) { - resolve(false); - } - }); - } - - static generateRandomString(size = 20): string { - return crypto.randomBytes(size).toString('hex'); - } - - static generateUUID(): string { - return crypto.randomUUID(); - } - - static generateApiKey(): string { - const generatedString = crypto.randomBytes(36).toString('hex'); - return `sk_${generatedString}`; - } + static getPrivateKey(): string { + return process.env.PRIVATE_KEY; + } + + private static deriveKey(passphrase: string, salt: Buffer): Buffer { + return crypto.pbkdf2Sync(passphrase, salt, KDF_ITERATIONS, KEY_LENGTH, 'sha256'); + } + + private static encryptDataV2(data: string, passphrase: string): string { + const salt = randomBytes(SALT_LENGTH); + const iv = randomBytes(IV_LENGTH); + const key = Encryptor.deriveKey(passphrase, salt); + + const cipher = crypto.createCipheriv(ENCRYPTION_ALGORITHM, key, iv); + const encrypted = Buffer.concat([cipher.update(data, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + + const saltB64 = salt.toString('base64'); + const ivB64 = iv.toString('base64'); + const authTagB64 = authTag.toString('base64'); + const encryptedB64 = encrypted.toString('base64'); + + return `${ENCRYPTION_VERSION_PREFIX}${saltB64}.${ivB64}.${authTagB64}.${encryptedB64}`; + } + + private static decryptDataV2(encryptedData: string, passphrase: string): string { + const dataWithoutPrefix = encryptedData.substring(ENCRYPTION_VERSION_PREFIX.length); + const parts = dataWithoutPrefix.split('.'); + if (parts.length !== 4) { + throw new Error('Invalid V2 encrypted data format'); + } + const [saltB64, ivB64, authTagB64, encryptedB64] = parts; + const salt = Buffer.from(saltB64, 'base64'); + const iv = Buffer.from(ivB64, 'base64'); + const authTag = Buffer.from(authTagB64, 'base64'); + const encrypted = Buffer.from(encryptedB64, 'base64'); + const key = Encryptor.deriveKey(passphrase, salt); + const decipher = crypto.createDecipheriv(ENCRYPTION_ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); + return decrypted.toString('utf8'); + } + + private static decryptDataV1Legacy(encryptedData: string, passphrase: string): string { + const bytes = CryptoJS.AES.decrypt(encryptedData, passphrase); + return bytes.toString(CryptoJS.enc.Utf8); + } + + private static isV2Format(encryptedData: string): boolean { + return encryptedData.startsWith('$v2:'); + } + + static encryptData(data: string): string { + if (data === null || data === undefined) { + return data; + } + try { + const privateKey = Encryptor.getPrivateKey(); + return Encryptor.encryptDataV2(data, privateKey); + } catch (e) { + console.log('-> Encryption error', e); + return data; + } + } + + static decryptData(encryptedData: string): string { + if (encryptedData === null || encryptedData === undefined) { + return encryptedData; + } + try { + const privateKey = Encryptor.getPrivateKey(); + + if (Encryptor.isV2Format(encryptedData)) { + return Encryptor.decryptDataV2(encryptedData, privateKey); + } + + return Encryptor.decryptDataV1Legacy(encryptedData, privateKey); + } catch (e) { + throw new Error('Data decryption failed with error: ' + e); + } + } + + static encryptDataMasterPwd(data: string, masterPwd: string): string { + if (data === null || data === undefined) { + return data; + } + return Encryptor.encryptDataV2(data, masterPwd); + } + + static decryptDataMasterPwd(encryptedData: string, masterPwd: string): string { + if (encryptedData === null || encryptedData === undefined) { + return encryptedData; + } + try { + if (Encryptor.isV2Format(encryptedData)) { + return Encryptor.decryptDataV2(encryptedData, masterPwd); + } + + return Encryptor.decryptDataV1Legacy(encryptedData, masterPwd); + } catch (e) { + throw new Error('Data decryption with master password failed with error: ' + e); + } + } + + static encryptConnectionCredentials(connection: ConnectionEntity, masterPwd: string): ConnectionEntity { + if (connection.username) connection.username = Encryptor.encryptDataMasterPwd(connection.username, masterPwd); + if (connection.database) connection.database = Encryptor.encryptDataMasterPwd(connection.database, masterPwd); + if (connection.password) connection.password = Encryptor.encryptDataMasterPwd(connection.password, masterPwd); + if (connection.authSource) connection.authSource = Encryptor.encryptDataMasterPwd(connection.authSource, masterPwd); + if (connection.ssh) { + if (connection.privateSSHKey) + connection.privateSSHKey = Encryptor.encryptDataMasterPwd(connection.privateSSHKey, masterPwd); + if (connection.sshHost) connection.sshHost = Encryptor.encryptDataMasterPwd(connection.sshHost, masterPwd); + if (connection.sshUsername) + connection.sshUsername = Encryptor.encryptDataMasterPwd(connection.sshUsername, masterPwd); + } + if (connection.ssl) { + if (connection.cert) connection.cert = Encryptor.encryptDataMasterPwd(connection.cert, masterPwd); + } + return connection; + } + + static decryptConnectionCredentials(connection: ConnectionEntity, masterPwd: string): ConnectionEntity { + if (connection.username) connection.username = Encryptor.decryptDataMasterPwd(connection.username, masterPwd); + if (connection.database) connection.database = Encryptor.decryptDataMasterPwd(connection.database, masterPwd); + if (connection.password) { + connection.password = Encryptor.decryptDataMasterPwd(connection.password, masterPwd); + } + if (connection.authSource) connection.authSource = Encryptor.decryptDataMasterPwd(connection.authSource, masterPwd); + if (connection.ssh) { + if (connection.privateSSHKey) + connection.privateSSHKey = Encryptor.decryptDataMasterPwd(connection.privateSSHKey, masterPwd); + if (connection.sshHost) connection.sshHost = Encryptor.decryptDataMasterPwd(connection.sshHost, masterPwd); + if (connection.sshUsername) + connection.sshUsername = Encryptor.decryptDataMasterPwd(connection.sshUsername, masterPwd); + } + if (connection.ssl) { + if (connection.cert) connection.cert = Encryptor.decryptDataMasterPwd(connection.cert, masterPwd); + } + return connection; + } + + static hashDataHMAC(dataToHash: string): string { + const privateKey = Encryptor.getPrivateKey(); + const hmac = createHmac('sha256', privateKey); + hmac.update(dataToHash); + return hmac.digest('hex'); + } + + static hashDataHMACexternalKey(key: string, dataToHash: string): string { + const hmac = createHmac('sha256', key); + hmac.update(dataToHash); + return hmac.digest('hex'); + } + + static getUserIntercomHash(userId: string): string | null { + const intercomKey = process.env.INTERCOM_KEY; + if (!intercomKey) { + return null; + } + try { + const hmac = createHmac('sha256', intercomKey); + hmac.update(userId); + return hmac.digest('hex'); + } catch (e) { + console.error(e); + return null; + } + } + + static async processDataWithAlgorithm(data: string, alg: EncryptionAlgorithmEnum): Promise { + if (!alg) { + return data; + } + try { + let hash; + switch (alg) { + case EncryptionAlgorithmEnum.sha1: + hash = CryptoJS.SHA1(data); + return hash.toString(CryptoJS.enc.Hex); + + case EncryptionAlgorithmEnum.sha3: + hash = CryptoJS.SHA3(data); + return hash.toString(CryptoJS.enc.Hex); + + case EncryptionAlgorithmEnum.sha256: + hash = CryptoJS.SHA256(data); + return hash.toString(CryptoJS.enc.Hex); + + case EncryptionAlgorithmEnum.sha224: + hash = CryptoJS.SHA224(data); + return hash.toString(CryptoJS.enc.Hex); + + case EncryptionAlgorithmEnum.sha512: + hash = CryptoJS.SHA512(data); + return hash.toString(CryptoJS.enc.Hex); + + case EncryptionAlgorithmEnum.sha384: + hash = CryptoJS.SHA384(data); + return hash.toString(CryptoJS.enc.Hex); + + case EncryptionAlgorithmEnum.pbkdf2: { + const salt = CryptoJS.lib.WordArray.random(128 / 8); + return CryptoJS.PBKDF2(data, salt, { + keySize: 256 / 32, + }).toString(); + } + + case EncryptionAlgorithmEnum.bcrypt: { + const bSalt = await bcrypt.genSalt(); + return await bcrypt.hash(data, bSalt); + } + + case EncryptionAlgorithmEnum.argon2: + return await argon2.hash(data); + + case EncryptionAlgorithmEnum.scrypt: + return await Encryptor.scryptHash(data); + + default: + return data; + } + } catch (_e) { + return data; + } + } + + static async scryptHash(data: string): Promise { + return new Promise((resolve, reject) => { + const salt = randomBytes(16).toString('hex'); + scrypt(data, salt, 64, (err, derivedData) => { + if (err) reject(err); + resolve(salt + ':' + derivedData.toString('hex')); + }); + }); + } + + static async hashUserPassword(password: string): Promise { + if (!password || password.length <= 0) return password; + return new Promise((resolve, reject) => { + const salt = crypto.randomBytes(Constants.PASSWORD_SALT_LENGTH).toString(Constants.BYTE_TO_STRING_ENCODING); + crypto.pbkdf2( + password, + salt, + Constants.PASSWORD_HASH_ITERATIONS, + Constants.PASSWORD_LENGTH, + Constants.DIGEST, + (error, hash) => { + if (error) { + reject(error); + } else { + const result = + 'pbkdf2$' + + Constants.PASSWORD_HASH_ITERATIONS + + '$' + + hash.toString(Constants.BYTE_TO_STRING_ENCODING) + + '$' + + salt; + resolve(result); + } + }, + ); + }); + } + + static async verifyUserPassword(receivedPassword: string, hashedPassword: string): Promise { + return new Promise((resolve, reject) => { + try { + const passwordHashParts: Array = hashedPassword.split('$'); + const alg = passwordHashParts[0]; + const iterations = parseInt(passwordHashParts[1], 10); + const passwordHash = passwordHashParts[2]; + const salt = passwordHashParts[3]; + if (passwordHashParts.length !== 4 || alg !== 'pbkdf2' || iterations !== Constants.PASSWORD_HASH_ITERATIONS) { + resolve(false); + } + crypto.pbkdf2( + receivedPassword, + salt, + iterations, + Constants.PASSWORD_LENGTH, + Constants.DIGEST, + (error, hash) => { + if (error) { + reject(error); + } else { + try { + const hashToString = hash.toString(Constants.BYTE_TO_STRING_ENCODING); + const result = crypto.timingSafeEqual(Buffer.from(passwordHash), Buffer.from(hashToString)); + resolve(result); + } catch (_e) { + resolve(false); + } + } + }, + ); + } catch (_e) { + resolve(false); + } + }); + } + + static generateRandomString(size = 20): string { + return crypto.randomBytes(size).toString('hex'); + } + + static generateUUID(): string { + return crypto.randomUUID(); + } + + static generateApiKey(): string { + const generatedString = crypto.randomBytes(36).toString('hex'); + return `sk_${generatedString}`; + } } diff --git a/backend/test/ava-tests/encryptor.test.ts b/backend/test/ava-tests/encryptor.test.ts new file mode 100644 index 000000000..c00b4f586 --- /dev/null +++ b/backend/test/ava-tests/encryptor.test.ts @@ -0,0 +1,200 @@ +import test from 'ava'; +import CryptoJS from 'crypto-js'; + +process.env.PRIVATE_KEY = 'test-private-key-12345'; + +const { Encryptor } = await import('../../src/helpers/encryption/encryptor.js'); + +const TEST_DATA = 'Hello, World! This is sensitive data.'; +const TEST_MASTER_PWD = 'my-secure-master-password-123'; +const TEST_SPECIAL_CHARS = 'Special chars: !@#$%^&*()_+-=[]{}|;\':",./<>?`~ and unicode: 你好世界 🔐'; +const TEST_LONG_DATA = 'A'.repeat(10000); +const TEST_EMPTY_STRING = ''; + +test('encryptData: should encrypt and produce V2 format with version prefix', (t) => { + const encrypted = Encryptor.encryptData(TEST_DATA); + + t.true(encrypted.startsWith('$v2:k1$'), 'Should start with V2 version prefix'); + t.true(encrypted.includes('.'), 'Should contain dot separators'); + + const parts = encrypted.substring('$v2:k1$'.length).split('.'); + t.is(parts.length, 4, 'Should have 4 parts: salt, iv, authTag, ciphertext'); +}); + +test('encryptData + decryptData: should round-trip correctly with V2 format', (t) => { + const encrypted = Encryptor.encryptData(TEST_DATA); + const decrypted = Encryptor.decryptData(encrypted); + + t.is(decrypted, TEST_DATA); +}); + +test('encryptDataMasterPwd + decryptDataMasterPwd: should round-trip correctly', (t) => { + const encrypted = Encryptor.encryptDataMasterPwd(TEST_DATA, TEST_MASTER_PWD); + const decrypted = Encryptor.decryptDataMasterPwd(encrypted, TEST_MASTER_PWD); + + t.is(decrypted, TEST_DATA); + t.true(encrypted.startsWith('$v2:k1$'), 'Should use V2 format'); +}); + +test('encryptData: each encryption should produce unique ciphertext (random salt/IV)', (t) => { + const encrypted1 = Encryptor.encryptData(TEST_DATA); + const encrypted2 = Encryptor.encryptData(TEST_DATA); + + t.not(encrypted1, encrypted2, 'Same plaintext should produce different ciphertext'); + + t.is(Encryptor.decryptData(encrypted1), TEST_DATA); + t.is(Encryptor.decryptData(encrypted2), TEST_DATA); +}); + +test('encryptData: should handle special characters and unicode', (t) => { + const encrypted = Encryptor.encryptData(TEST_SPECIAL_CHARS); + const decrypted = Encryptor.decryptData(encrypted); + + t.is(decrypted, TEST_SPECIAL_CHARS); +}); + +test('encryptData: should handle long data', (t) => { + const encrypted = Encryptor.encryptData(TEST_LONG_DATA); + const decrypted = Encryptor.decryptData(encrypted); + + t.is(decrypted, TEST_LONG_DATA); + t.is(decrypted.length, 10000); +}); + +test('encryptData: should handle empty string', (t) => { + const encrypted = Encryptor.encryptData(TEST_EMPTY_STRING); + const decrypted = Encryptor.decryptData(encrypted); + + t.is(decrypted, TEST_EMPTY_STRING); +}); + +test('decryptData: should decrypt legacy V1 (CryptoJS) encrypted data', (t) => { + const legacyEncrypted = CryptoJS.AES.encrypt(TEST_DATA, process.env.PRIVATE_KEY).toString(); + t.false(legacyEncrypted.startsWith('$v2:'), 'Legacy format should not have V2 prefix'); + + const decrypted = Encryptor.decryptData(legacyEncrypted); + + t.is(decrypted, TEST_DATA, 'Should decrypt legacy V1 data correctly'); +}); + +test('decryptDataMasterPwd: should decrypt legacy V1 (CryptoJS) encrypted data', (t) => { + const legacyEncrypted = CryptoJS.AES.encrypt(TEST_DATA, TEST_MASTER_PWD).toString(); + t.false(legacyEncrypted.startsWith('$v2:'), 'Legacy format should not have V2 prefix'); + + const decrypted = Encryptor.decryptDataMasterPwd(legacyEncrypted, TEST_MASTER_PWD); + + t.is(decrypted, TEST_DATA, 'Should decrypt legacy V1 data correctly'); +}); + +test('decryptData: should handle legacy V1 data with special characters', (t) => { + const legacyEncrypted = CryptoJS.AES.encrypt(TEST_SPECIAL_CHARS, process.env.PRIVATE_KEY).toString(); + const decrypted = Encryptor.decryptData(legacyEncrypted); + + t.is(decrypted, TEST_SPECIAL_CHARS); +}); + +test('decryptData: should throw error for invalid V2 format', (t) => { + const invalidV2 = '$v2:k1$invaliddata'; + + const error = t.throws(() => { + Encryptor.decryptData(invalidV2); + }); + + t.true(error.message.includes('Invalid V2 encrypted data format') || error.message.includes('decryption failed')); +}); + +test('decryptData: should throw error for corrupted V2 ciphertext', (t) => { + // Create valid encrypted data then corrupt it + const encrypted = Encryptor.encryptData(TEST_DATA); + const corrupted = encrypted.slice(0, -5) + 'XXXXX'; + + const error = t.throws(() => { + Encryptor.decryptData(corrupted); + }); + + t.truthy(error); +}); + +test('decryptDataMasterPwd: should throw error with wrong password', (t) => { + const encrypted = Encryptor.encryptDataMasterPwd(TEST_DATA, TEST_MASTER_PWD); + + const error = t.throws(() => { + Encryptor.decryptDataMasterPwd(encrypted, 'wrong-password'); + }); + + t.truthy(error); +}); + +test('lazy re-encryption: V1 data decrypted then re-encrypted becomes V2', (t) => { + // Step 1: Create V1 encrypted data (simulating production data) + const v1Encrypted = CryptoJS.AES.encrypt(TEST_DATA, process.env.PRIVATE_KEY).toString(); + t.false(v1Encrypted.startsWith('$v2:'), 'Should be V1 format'); + + // Step 2: Decrypt using new method (backward compatible) + const decrypted = Encryptor.decryptData(v1Encrypted); + t.is(decrypted, TEST_DATA); + + // Step 3: Re-encrypt using new method (always V2) + const v2Encrypted = Encryptor.encryptData(decrypted); + t.true(v2Encrypted.startsWith('$v2:k1$'), 'Should now be V2 format'); + + // Step 4: Verify V2 encrypted data can be decrypted + const decryptedAgain = Encryptor.decryptData(v2Encrypted); + t.is(decryptedAgain, TEST_DATA); +}); + +test('generateRandomString: should generate hex string of correct length', (t) => { + const str1 = Encryptor.generateRandomString(); + const str2 = Encryptor.generateRandomString(30); + + t.is(str1.length, 40, 'Default size 20 bytes = 40 hex chars'); + t.is(str2.length, 60, 'Size 30 bytes = 60 hex chars'); + t.not(str1, str2, 'Should be random'); +}); + +test('generateUUID: should generate valid UUID format', (t) => { + const uuid = Encryptor.generateUUID(); + + t.regex(uuid, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); +}); + +test('generateApiKey: should generate key with sk_ prefix', (t) => { + const apiKey = Encryptor.generateApiKey(); + + t.true(apiKey.startsWith('sk_')); + t.is(apiKey.length, 75, 'sk_ (3) + 72 hex chars (36 bytes)'); +}); + +test('hashDataHMAC: should produce consistent hash for same input', (t) => { + const hash1 = Encryptor.hashDataHMAC('test-data'); + const hash2 = Encryptor.hashDataHMAC('test-data'); + + t.is(hash1, hash2); + t.is(hash1.length, 64, 'SHA-256 produces 64 hex chars'); +}); + +test('hashDataHMAC: should produce different hash for different input', (t) => { + const hash1 = Encryptor.hashDataHMAC('test-data-1'); + const hash2 = Encryptor.hashDataHMAC('test-data-2'); + + t.not(hash1, hash2); +}); + +test('hashUserPassword + verifyUserPassword: should hash and verify correctly', async (t) => { + const password = 'my-secure-password-123'; + const hashedPassword = await Encryptor.hashUserPassword(password); + + t.true(hashedPassword.startsWith('pbkdf2$')); + + const isValid = await Encryptor.verifyUserPassword(password, hashedPassword); + t.true(isValid); + + const isInvalid = await Encryptor.verifyUserPassword('wrong-password', hashedPassword); + t.false(isInvalid); +}); + +test('hashUserPassword: should return empty string for empty password', async (t) => { + const result = await Encryptor.hashUserPassword(''); + + t.is(result, ''); +}); diff --git a/rocketadmin-agent/src/helpers/encryption/encryptor.ts b/rocketadmin-agent/src/helpers/encryption/encryptor.ts index 35c7b499d..d5983e17d 100644 --- a/rocketadmin-agent/src/helpers/encryption/encryptor.ts +++ b/rocketadmin-agent/src/helpers/encryption/encryptor.ts @@ -1,27 +1,96 @@ +/** biome-ignore-all lint/complexity/noStaticOnlyClass: */ +import crypto, { randomBytes } from 'crypto'; import CryptoJS from 'crypto-js'; import argon2 from 'argon2'; import { Messages } from '../../text/messages.js'; +const ENCRYPTION_VERSION_PREFIX = '$v2:k1$'; +const ENCRYPTION_ALGORITHM = 'aes-256-gcm'; +const KDF_ITERATIONS = 100000; +const SALT_LENGTH = 32; +const IV_LENGTH = 16; +const KEY_LENGTH = 32; + export class Encryptor { - public static encryptDataMasterPwd(data: string, masterPwd: string): string { - return CryptoJS.AES.encrypt(data, masterPwd).toString(); - } - - public static decryptDataMasterPwd(encryptedData: string, masterPwd: string): string { - const bytes = CryptoJS.AES.decrypt(encryptedData, masterPwd); - return bytes.toString(CryptoJS.enc.Utf8); - } - - public static async hashPassword(password: string): Promise { - return await argon2.hash(password); - } - - public static async verifyPassword(hash: string, password: string): Promise { - try { - return await argon2.verify(hash, password); - } catch (_err) { - console.log(Messages.CORRUPTED_DATA); - process.exit(0); - } - } + private static deriveKey(passphrase: string, salt: Buffer): Buffer { + return crypto.pbkdf2Sync(passphrase, salt, KDF_ITERATIONS, KEY_LENGTH, 'sha256'); + } + + private static encryptDataV2(data: string, passphrase: string): string { + const salt = randomBytes(SALT_LENGTH); + const iv = randomBytes(IV_LENGTH); + const key = Encryptor.deriveKey(passphrase, salt); + + const cipher = crypto.createCipheriv(ENCRYPTION_ALGORITHM, key, iv); + const encrypted = Buffer.concat([cipher.update(data, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + + const saltB64 = salt.toString('base64'); + const ivB64 = iv.toString('base64'); + const authTagB64 = authTag.toString('base64'); + const encryptedB64 = encrypted.toString('base64'); + return `${ENCRYPTION_VERSION_PREFIX}${saltB64}.${ivB64}.${authTagB64}.${encryptedB64}`; + } + + private static decryptDataV2(encryptedData: string, passphrase: string): string { + const dataWithoutPrefix = encryptedData.substring(ENCRYPTION_VERSION_PREFIX.length); + const parts = dataWithoutPrefix.split('.'); + + if (parts.length !== 4) { + throw new Error('Invalid V2 encrypted data format'); + } + + const [saltB64, ivB64, authTagB64, encryptedB64] = parts; + + const salt = Buffer.from(saltB64, 'base64'); + const iv = Buffer.from(ivB64, 'base64'); + const authTag = Buffer.from(authTagB64, 'base64'); + const encrypted = Buffer.from(encryptedB64, 'base64'); + const key = Encryptor.deriveKey(passphrase, salt); + const decipher = crypto.createDecipheriv(ENCRYPTION_ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); + return decrypted.toString('utf8'); + } + + private static decryptDataV1Legacy(encryptedData: string, passphrase: string): string { + const bytes = CryptoJS.AES.decrypt(encryptedData, passphrase); + return bytes.toString(CryptoJS.enc.Utf8); + } + + private static isV2Format(encryptedData: string): boolean { + return encryptedData.startsWith('$v2:'); + } + + public static encryptDataMasterPwd(data: string, masterPwd: string): string { + if (data === null || data === undefined) { + return data; + } + return Encryptor.encryptDataV2(data, masterPwd); + } + + public static decryptDataMasterPwd(encryptedData: string, masterPwd: string): string { + if (encryptedData === null || encryptedData === undefined) { + return encryptedData; + } + if (Encryptor.isV2Format(encryptedData)) { + return Encryptor.decryptDataV2(encryptedData, masterPwd); + } + + return Encryptor.decryptDataV1Legacy(encryptedData, masterPwd); + } + + public static async hashPassword(password: string): Promise { + return await argon2.hash(password); + } + + public static async verifyPassword(hash: string, password: string): Promise { + try { + return await argon2.verify(hash, password); + } catch (_err) { + console.log(Messages.CORRUPTED_DATA); + process.exit(0); + } + } }