diff --git a/src/utils/helpers/CryptoUtils.ts b/src/utils/helpers/CryptoUtils.ts index 33624bb..6d57eef 100644 --- a/src/utils/helpers/CryptoUtils.ts +++ b/src/utils/helpers/CryptoUtils.ts @@ -234,14 +234,91 @@ export class CryptoUtils { return computed === hash; } + /** + * Encrypt a string using AES-256-GCM + * @param str - Plaintext string to encrypt + * @param key - Secret key string + * @returns Base64 encoded string containing [iv:12bytes][ciphertext] + * @security Uses AES-GCM with a random 96-bit IV for each encryption + */ + static async encrypt(str: string, key: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(str); + + // Derive a 256-bit key from the input string using SHA-256 + const keyHash = await this.hashBytes(key); + + const cryptoKey = await globalThis.crypto.subtle.importKey( + "raw", + keyHash, + { name: "AES-GCM" } as any, + false, + ["encrypt"] + ); + + const iv = globalThis.crypto.getRandomValues(new Uint8Array(12)); + const encrypted = await globalThis.crypto.subtle.encrypt( + { name: "AES-GCM", iv } as any, + cryptoKey, + data + ); + + const result = new Uint8Array(iv.length + encrypted.byteLength); + result.set(iv); + result.set(new Uint8Array(encrypted), iv.length); + + return this.uint8ArrayToBase64(result); + } + + /** + * Decrypt a string using AES-256-GCM + * @param encryptedBase64 - Base64 encoded string containing [iv:12bytes][ciphertext] + * @param key - Secret key string + * @returns Decrypted plaintext string + * @throws {Error} If decryption fails + */ + static async decrypt( + encryptedBase64: string, + key: string + ): Promise { + const combined = this.base64ToUint8Array(encryptedBase64); + if (combined.length < 13) { + throw new Error("Invalid encrypted data: too short"); + } + + const iv = combined.slice(0, 12); + const ciphertext = combined.slice(12); + + const keyHash = await this.hashBytes(key); + + const cryptoKey = await globalThis.crypto.subtle.importKey( + "raw", + keyHash, + { name: "AES-GCM" } as any, + false, + ["decrypt"] + ); + + try { + const decrypted = await globalThis.crypto.subtle.decrypt( + { name: "AES-GCM", iv } as any, + cryptoKey, + ciphertext + ); + + return new TextDecoder().decode(decrypted); + } catch (error) { + throw new Error(`Decryption failed: ${error}`); + } + } + /** * Encrypt string (simple XOR - NOT for production) - * For production, use proper encryption libraries like TweetNaCl or libsodium - * @deprecated XOR encryption is NOT cryptographically secure. Use proper encryption libraries. + * @deprecated XOR encryption is NOT cryptographically secure. Use encrypt() instead. */ static xorEncrypt(str: string, key: string): string { console.warn( - "WARNING: XOR encryption is not cryptographically secure. Use proper encryption libraries like TweetNaCl or libsodium." + "WARNING: XOR encryption is not cryptographically secure. Use encrypt() for a secure alternative." ); let result = ""; for (let i = 0; i < str.length; i++) { @@ -254,11 +331,11 @@ export class CryptoUtils { /** * Decrypt string (simple XOR - NOT for production) - * @deprecated XOR encryption is NOT cryptographically secure. Use proper encryption libraries. + * @deprecated XOR encryption is NOT cryptographically secure. Use decrypt() instead. */ static xorDecrypt(encrypted: string, key: string): string { console.warn( - "WARNING: XOR encryption is not cryptographically secure. Use proper encryption libraries like TweetNaCl or libsodium." + "WARNING: XOR encryption is not cryptographically secure. Use decrypt() for a secure alternative." ); const str = this.base64Decode(encrypted); let result = ""; @@ -270,6 +347,47 @@ export class CryptoUtils { return result; } + /** + * Internal helper to hash a string and return bytes + */ + private static async hashBytes(str: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(str); + const hashBuffer = await globalThis.crypto.subtle.digest( + "SHA-256", + data + ); + return new Uint8Array(hashBuffer); + } + + /** + * Internal helper for bytes to base64 + */ + private static uint8ArrayToBase64(bytes: Uint8Array): string { + if (typeof Buffer !== "undefined") { + return Buffer.from(bytes).toString("base64"); + } + const binaryString = Array.from(bytes, (byte) => + String.fromCharCode(byte) + ).join(""); + return globalThis.btoa(binaryString); + } + + /** + * Internal helper for base64 to bytes + */ + private static base64ToUint8Array(base64: string): Uint8Array { + if (typeof Buffer !== "undefined") { + return new Uint8Array(Buffer.from(base64, "base64")); + } + const binaryString = globalThis.atob(base64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; + } + /** * Generate random bytes */ diff --git a/tests/crypto-utils.test.ts b/tests/crypto-utils.test.ts index c175983..1384a5c 100644 --- a/tests/crypto-utils.test.ts +++ b/tests/crypto-utils.test.ts @@ -80,6 +80,17 @@ test("CryptoUtils.verifyHash works correctly", async () => { assert.equal(await CryptoUtils.verifyHash(data, "wrong-hash"), false); }); +test("CryptoUtils.encrypt and decrypt", async () => { + const original = "secret message"; + const key = "pass"; + + const encrypted = await CryptoUtils.encrypt(original, key); + assert.notEqual(encrypted, original); + + const decrypted = await CryptoUtils.decrypt(encrypted, key); + assert.equal(decrypted, original); +}); + test("CryptoUtils.xorEncrypt and xorDecrypt", () => { const original = "secret message"; const key = "pass"; @@ -91,6 +102,35 @@ test("CryptoUtils.xorEncrypt and xorDecrypt", () => { assert.equal(decrypted, original); }); +test("CryptoUtils.encrypt is non-deterministic (uses random IV)", async () => { + const original = "secret message"; + const key = "pass"; + + const encrypted1 = await CryptoUtils.encrypt(original, key); + const encrypted2 = await CryptoUtils.encrypt(original, key); + + assert.notEqual(encrypted1, encrypted2); + + const decrypted1 = await CryptoUtils.decrypt(encrypted1, key); + const decrypted2 = await CryptoUtils.decrypt(encrypted2, key); + + assert.equal(decrypted1, original); + assert.equal(decrypted2, original); +}); + +test("CryptoUtils.decrypt fails with wrong key", async () => { + const original = "secret message"; + const key = "pass"; + const wrongKey = "wrong"; + + const encrypted = await CryptoUtils.encrypt(original, key); + + await assert.rejects( + async () => await CryptoUtils.decrypt(encrypted, wrongKey), + /Decryption failed/ + ); +}); + test("CryptoUtils.randomBytes generates Uint8Array", () => { const bytes = CryptoUtils.randomBytes(16); assert.ok(bytes instanceof Uint8Array); @@ -243,9 +283,9 @@ test("CryptoUtils.hmac with default algorithm", async () => { assert.match(hmac, /^[0-9a-f]+$/); }); -test("CryptoUtils.xorEncrypt handles empty strings", () => { - const encrypted = CryptoUtils.xorEncrypt("", "key"); - const decrypted = CryptoUtils.xorDecrypt(encrypted, "key"); +test("CryptoUtils.encrypt handles empty strings", async () => { + const encrypted = await CryptoUtils.encrypt("", "key"); + const decrypted = await CryptoUtils.decrypt(encrypted, "key"); assert.equal(decrypted, ""); }); @@ -282,12 +322,12 @@ test("CryptoUtils.randomBytes generates different values", () => { assert.notDeepEqual(bytes1, bytes2); }); -test("CryptoUtils.xorEncrypt with long key", () => { +test("CryptoUtils.encrypt with long key", async () => { const original = "short"; const key = "very-long-encryption-key-that-exceeds-message-length"; - const encrypted = CryptoUtils.xorEncrypt(original, key); - const decrypted = CryptoUtils.xorDecrypt(encrypted, key); + const encrypted = await CryptoUtils.encrypt(original, key); + const decrypted = await CryptoUtils.decrypt(encrypted, key); assert.equal(decrypted, original); });