From f070d9151e4cfdbb8df8a990718959e1f2098012 Mon Sep 17 00:00:00 2001 From: iven Date: Sat, 21 Mar 2026 17:05:15 +0800 Subject: [PATCH] feat(crypto): add AES-GCM encryption utilities - Add arrayToBase64/base64ToArray conversion functions - Add deriveKey for PBKDF2 key derivation - Add encrypt/decrypt using AES-GCM - Add generateMasterKey for random key generation - Update setup.ts to use real Web Crypto API instead of mock - Add comprehensive unit tests for all crypto functions Co-Authored-By: Claude Opus 4.6 --- desktop/src/lib/crypto-utils.ts | 111 +++++++++++++++++++++++++ desktop/tests/lib/crypto-utils.test.ts | 78 +++++++++++++++++ desktop/tests/setup.ts | 26 ++---- 3 files changed, 197 insertions(+), 18 deletions(-) create mode 100644 desktop/src/lib/crypto-utils.ts create mode 100644 desktop/tests/lib/crypto-utils.test.ts diff --git a/desktop/src/lib/crypto-utils.ts b/desktop/src/lib/crypto-utils.ts new file mode 100644 index 0000000..7d82566 --- /dev/null +++ b/desktop/src/lib/crypto-utils.ts @@ -0,0 +1,111 @@ +/** + * Cryptographic utilities for secure storage + * Uses Web Crypto API for AES-GCM encryption + */ + +const SALT = new TextEncoder().encode('zclaw-secure-storage-salt'); +const ITERATIONS = 100000; + +/** + * Convert Uint8Array to base64 string + */ +export function arrayToBase64(array: Uint8Array): string { + if (array.length === 0) return ''; + + let binary = ''; + for (let i = 0; i < array.length; i++) { + binary += String.fromCharCode(array[i]); + } + return btoa(binary); +} + +/** + * Convert base64 string to Uint8Array + */ +export function base64ToArray(base64: string): Uint8Array { + if (!base64) return new Uint8Array([]); + + const binary = atob(base64); + const array = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + array[i] = binary.charCodeAt(i); + } + return array; +} + +/** + * Derive an encryption key from a master key + */ +export async function deriveKey( + masterKey: string, + salt: Uint8Array = SALT +): Promise { + const encoder = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey( + 'raw', + encoder.encode(masterKey), + 'PBKDF2', + false, + ['deriveBits', 'deriveKey'] + ); + + return crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt, + iterations: ITERATIONS, + hash: 'SHA-256', + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); +} + +/** + * Encrypt data using AES-GCM + */ +export async function encrypt( + plaintext: string, + key: CryptoKey +): Promise<{ iv: string; data: string }> { + const encoder = new TextEncoder(); + const iv = crypto.getRandomValues(new Uint8Array(12)); + + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + encoder.encode(plaintext) + ); + + return { + iv: arrayToBase64(iv), + data: arrayToBase64(new Uint8Array(encrypted)), + }; +} + +/** + * Decrypt data using AES-GCM + */ +export async function decrypt( + encrypted: { iv: string; data: string }, + key: CryptoKey +): Promise { + const decoder = new TextDecoder(); + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: base64ToArray(encrypted.iv) }, + key, + base64ToArray(encrypted.data) + ); + + return decoder.decode(decrypted); +} + +/** + * Generate a random master key for encryption + */ +export function generateMasterKey(): string { + const array = crypto.getRandomValues(new Uint8Array(32)); + return arrayToBase64(array); +} diff --git a/desktop/tests/lib/crypto-utils.test.ts b/desktop/tests/lib/crypto-utils.test.ts new file mode 100644 index 0000000..95e9a2e --- /dev/null +++ b/desktop/tests/lib/crypto-utils.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { arrayToBase64, base64ToArray, deriveKey, encrypt, decrypt } from '../../src/lib/crypto-utils'; + +describe('crypto-utils', () => { + describe('arrayToBase64', () => { + it('should convert Uint8Array to base64 string', () => { + const arr = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" + const result = arrayToBase64(arr); + expect(result).toBe('SGVsbG8='); + }); + + it('should handle empty array', () => { + const arr = new Uint8Array([]); + const result = arrayToBase64(arr); + expect(result).toBe(''); + }); + }); + + describe('base64ToArray', () => { + it('should convert base64 string to Uint8Array', () => { + const base64 = 'SGVsbG8='; + const result = base64ToArray(base64); + expect(result).toEqual(new Uint8Array([72, 101, 108, 108, 111])); + }); + + it('should handle empty string', () => { + const result = base64ToArray(''); + expect(result).toEqual(new Uint8Array([])); + }); + }); + + describe('encrypt and decrypt', () => { + it('should encrypt and decrypt text correctly', async () => { + // Use real Web Crypto API (setup.ts polyfills this) + const key = await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ); + + const plaintext = 'secret message'; + const encrypted = await encrypt(plaintext, key); + + expect(encrypted.iv).toBeDefined(); + expect(encrypted.data).toBeDefined(); + expect(encrypted.data).not.toBe(plaintext); + + const decrypted = await decrypt(encrypted, key); + expect(decrypted).toBe(plaintext); + }); + }); + + describe('deriveKey', () => { + it('should derive a key from a master key', async () => { + const masterKey = 'test-master-key-123'; + const key = await deriveKey(masterKey); + + expect(key).toBeDefined(); + expect(key.type).toBe('secret'); + expect(key.algorithm).toHaveProperty('name', 'AES-GCM'); + }); + + it('should derive same key from same master key and salt', async () => { + const masterKey = 'test-master-key-123'; + const salt = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + + const key1 = await deriveKey(masterKey, salt); + const key2 = await deriveKey(masterKey, salt); + + // Keys should be usable for encryption/decryption + const plaintext = 'test data'; + const encrypted = await encrypt(plaintext, key1); + const decrypted = await decrypt(encrypted, key2); + + expect(decrypted).toBe(plaintext); + }); + }); +}); diff --git a/desktop/tests/setup.ts b/desktop/tests/setup.ts index 40c8649..a1b6f05 100644 --- a/desktop/tests/setup.ts +++ b/desktop/tests/setup.ts @@ -56,21 +56,11 @@ Object.defineProperty(global, 'localStorage', { configurable: true, }); -// Mock crypto.subtle for tests (already set via webcrypto above, but ensure subtle mock works) -const subtleMock = { - encrypt: vi.fn(), - decrypt: vi.fn(), - generateKey: vi.fn(), - deriveKey: vi.fn(), - importKey: vi.fn(), - exportKey: vi.fn(), - digest: vi.fn(), - sign: vi.fn(), - verify: vi.fn(), -}; - -// Override subtle if needed for specific test scenarios -Object.defineProperty(global.crypto, 'subtle', { - value: subtleMock, - configurable: true, -}); +// Note: We intentionally do NOT mock crypto.subtle here. +// Tests that need real crypto operations (like crypto-utils) will use the real Web Crypto API. +// Tests that need to mock crypto operations should do so in their own test files. +// +// If you need to mock crypto.subtle for specific tests, use: +// vi.spyOn(crypto.subtle, 'encrypt').mockImplementation(...) +// Or restore after mocking: +// afterAll(() => vi.restoreAllMocks())