diff --git a/desktop/src/lib/secure-storage.ts b/desktop/src/lib/secure-storage.ts index d882ede..75c1bfa 100644 --- a/desktop/src/lib/secure-storage.ts +++ b/desktop/src/lib/secure-storage.ts @@ -2,20 +2,36 @@ * ZCLAW Secure Storage * * Provides secure credential storage using the OS keyring/keychain. - * Falls back to localStorage when not running in Tauri or if keyring is unavailable. + * Falls back to encrypted localStorage when not running in Tauri or if keyring is unavailable. * * Platform support: * - Windows: DPAPI * - macOS: Keychain * - Linux: Secret Service API (gnome-keyring, kwallet, etc.) + * - Fallback: AES-GCM encrypted localStorage */ import { invoke } from '@tauri-apps/api/core'; import { isTauriRuntime } from './tauri-gateway'; +import { + arrayToBase64, + base64ToArray, + deriveKey, + encrypt, + decrypt, + generateMasterKey, +} from './crypto-utils'; // Cache for keyring availability check let keyringAvailable: boolean | null = null; +// Encryption constants for localStorage fallback +const ENCRYPTED_PREFIX = 'enc_'; +const MASTER_KEY_NAME = 'zclaw-master-key'; + +// Cache for the derived crypto key +let cachedCryptoKey: CryptoKey | null = null; + /** * Check if secure storage (keyring) is available */ @@ -41,7 +57,7 @@ export async function isSecureStorageAvailable(): Promise { /** * Secure storage interface - * Uses OS keyring when available, falls back to localStorage + * Uses OS keyring when available, falls back to encrypted localStorage */ export const secureStorage = { /** @@ -59,16 +75,16 @@ export const secureStorage = { } else { await invoke('secure_store_delete', { key }); } - // Also write to localStorage as backup/migration support - writeLocalStorageBackup(key, trimmedValue); + // Also write encrypted backup to localStorage for migration support + await writeEncryptedLocalStorage(key, trimmedValue); return; } catch (error) { - console.warn('[SecureStorage] Failed to use keyring, falling back to localStorage:', error); + console.warn('[SecureStorage] Failed to use keyring, falling back to encrypted localStorage:', error); } } - // Fallback to localStorage - writeLocalStorageBackup(key, trimmedValue); + // Fallback to encrypted localStorage + await writeEncryptedLocalStorage(key, trimmedValue); }, /** @@ -83,15 +99,15 @@ export const secureStorage = { if (value !== null && value !== undefined && value !== '') { return value; } - // If keyring returned empty, try localStorage fallback for migration - return readLocalStorageBackup(key); + // If keyring returned empty, try encrypted localStorage fallback for migration + return await readEncryptedLocalStorage(key); } catch (error) { - console.warn('[SecureStorage] Failed to read from keyring, trying localStorage:', error); + console.warn('[SecureStorage] Failed to read from keyring, trying encrypted localStorage:', error); } } - // Fallback to localStorage - return readLocalStorageBackup(key); + // Fallback to encrypted localStorage + return await readEncryptedLocalStorage(key); }, /** @@ -121,7 +137,116 @@ export const secureStorage = { /** * localStorage backup functions for migration and fallback + * Now with AES-GCM encryption for non-Tauri environments */ + +/** + * Get or create the master encryption key for localStorage fallback + */ +async function getOrCreateMasterKey(): Promise { + if (cachedCryptoKey) { + return cachedCryptoKey; + } + + let masterKeyRaw = localStorage.getItem(MASTER_KEY_NAME); + + if (!masterKeyRaw) { + masterKeyRaw = generateMasterKey(); + localStorage.setItem(MASTER_KEY_NAME, masterKeyRaw); + } + + cachedCryptoKey = await deriveKey(masterKeyRaw); + return cachedCryptoKey; +} + +/** + * Check if a stored value is encrypted (has iv and data fields) + */ +function isEncrypted(value: string): boolean { + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed.iv === 'string' && typeof parsed.data === 'string'; + } catch { + return false; + } +} + +/** + * Write encrypted data to localStorage + */ +async function writeEncryptedLocalStorage(key: string, value: string): Promise { + try { + const encryptedKey = ENCRYPTED_PREFIX + key; + + if (!value) { + localStorage.removeItem(encryptedKey); + // Also remove legacy unencrypted key + localStorage.removeItem(key); + return; + } + + try { + const cryptoKey = await getOrCreateMasterKey(); + const encrypted = await encrypt(value, cryptoKey); + localStorage.setItem(encryptedKey, JSON.stringify(encrypted)); + // Remove legacy unencrypted key if it exists + localStorage.removeItem(key); + } catch (error) { + console.error('[SecureStorage] Encryption failed:', error); + // Fallback to plaintext if encryption fails (should not happen) + localStorage.setItem(key, value); + } + } catch { + // Ignore localStorage failures + } +} + +/** + * Read and decrypt data from localStorage + * Supports both encrypted and legacy unencrypted formats + */ +async function readEncryptedLocalStorage(key: string): Promise { + try { + // Try encrypted key first + const encryptedKey = ENCRYPTED_PREFIX + key; + const encryptedRaw = localStorage.getItem(encryptedKey); + + if (encryptedRaw && isEncrypted(encryptedRaw)) { + try { + const cryptoKey = await getOrCreateMasterKey(); + const encrypted = JSON.parse(encryptedRaw); + return await decrypt(encrypted, cryptoKey); + } catch (error) { + console.error('[SecureStorage] Decryption failed:', error); + // Fall through to try legacy key + } + } + + // Try legacy unencrypted key for backward compatibility + const legacyValue = localStorage.getItem(key); + if (legacyValue !== null) { + return legacyValue; + } + + return null; + } catch { + return null; + } +} + +/** + * Clear data from localStorage (both encrypted and legacy) + */ +function clearLocalStorageBackup(key: string): void { + try { + localStorage.removeItem(ENCRYPTED_PREFIX + key); + localStorage.removeItem(key); + } catch { + // Ignore localStorage failures + } +} + +// Keep synchronous versions for backward compatibility (deprecated) function writeLocalStorageBackup(key: string, value: string): void { try { if (value) { @@ -142,14 +267,6 @@ function readLocalStorageBackup(key: string): string | null { } } -function clearLocalStorageBackup(key: string): void { - try { - localStorage.removeItem(key); - } catch { - // Ignore localStorage failures - } -} - /** * Synchronous versions for compatibility with existing code * These use localStorage only and are provided for gradual migration diff --git a/desktop/tests/lib/secure-storage.test.ts b/desktop/tests/lib/secure-storage.test.ts new file mode 100644 index 0000000..34ca9e3 --- /dev/null +++ b/desktop/tests/lib/secure-storage.test.ts @@ -0,0 +1,187 @@ +/** + * Tests for secure-storage.ts + * + * These tests verify that credentials are encrypted when stored in localStorage + * (fallback mode when OS keyring is unavailable). + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Mock Tauri runtime to return false (non-Tauri environment) +vi.mock('../../src/lib/tauri-gateway', () => ({ + isTauriRuntime: () => false, +})); + +// Import after mocking +import { secureStorage } from '../../src/lib/secure-storage'; + +describe('secureStorage', () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + }); + + describe('encryption fallback', () => { + it('should encrypt data when storing to localStorage', async () => { + const key = 'test-key'; + const value = 'secret-value'; + + await secureStorage.set(key, value); + + // Check that localStorage doesn't contain plaintext + const encryptedKey = 'enc_' + key; + const stored = localStorage.getItem(encryptedKey); + expect(stored).not.toBeNull(); + expect(stored).not.toBe(value); + expect(stored).not.toContain('secret-value'); + + // Should be JSON with iv and data fields + const parsed = JSON.parse(stored!); + expect(parsed).toHaveProperty('iv'); + expect(parsed).toHaveProperty('data'); + }); + + it('should decrypt data when retrieving from localStorage', async () => { + const key = 'test-key'; + const value = 'secret-value'; + + await secureStorage.set(key, value); + const retrieved = await secureStorage.get(key); + + expect(retrieved).toBe(value); + }); + + it('should handle special characters in values', async () => { + const key = 'test-key'; + const value = 'p@ssw0rd!#$%^&*(){}[]|\\:";\'<>?,./~`'; + + await secureStorage.set(key, value); + const retrieved = await secureStorage.get(key); + + expect(retrieved).toBe(value); + }); + + it('should handle Unicode characters in values', async () => { + const key = 'test-key'; + const value = '密码测试123テスト🔑🔐'; + + await secureStorage.set(key, value); + const retrieved = await secureStorage.get(key); + + expect(retrieved).toBe(value); + }); + + it('should handle empty string by removing the key', async () => { + const key = 'test-key'; + const value = 'initial-value'; + + await secureStorage.set(key, value); + expect(await secureStorage.get(key)).toBe(value); + + // Setting empty string should remove the key + await secureStorage.set(key, ''); + const encryptedKey = 'enc_' + key; + expect(localStorage.getItem(encryptedKey)).toBeNull(); + expect(await secureStorage.get(key)).toBeNull(); + }); + + it('should handle null by removing the key', async () => { + const key = 'test-key'; + const value = 'initial-value'; + + await secureStorage.set(key, value); + expect(await secureStorage.get(key)).toBe(value); + + // Delete should remove the key + await secureStorage.delete(key); + const encryptedKey = 'enc_' + key; + expect(localStorage.getItem(encryptedKey)).toBeNull(); + }); + }); + + describe('backward compatibility', () => { + it('should read unencrypted legacy data', async () => { + const key = 'legacy-key'; + const value = 'legacy-value'; + + // Simulate legacy unencrypted storage + localStorage.setItem(key, value); + + const retrieved = await secureStorage.get(key); + expect(retrieved).toBe(value); + }); + + it('should migrate unencrypted data to encrypted on next set', async () => { + const key = 'legacy-key'; + const value = 'legacy-value'; + const newValue = 'new-encrypted-value'; + + // Simulate legacy unencrypted storage + localStorage.setItem(key, value); + + // Read should return legacy value + const retrieved = await secureStorage.get(key); + expect(retrieved).toBe(value); + + // Write should encrypt the new value + await secureStorage.set(key, newValue); + + // Legacy key should be removed, encrypted key should exist + expect(localStorage.getItem(key)).toBeNull(); + const encryptedKey = 'enc_' + key; + const stored = localStorage.getItem(encryptedKey); + expect(stored).not.toBeNull(); + expect(stored).not.toContain(newValue); + + // Should retrieve the new encrypted value + expect(await secureStorage.get(key)).toBe(newValue); + }); + }); + + describe('encryption strength', () => { + it('should use different IV for each encryption', async () => { + const key = 'test-key'; + const value = 'same-value'; + + await secureStorage.set(key, value); + const encrypted1 = localStorage.getItem('enc_' + key); + + await secureStorage.set(key, value); + const encrypted2 = localStorage.getItem('enc_' + key); + + // Both should be encrypted versions of the same value + expect(encrypted1).not.toBe(encrypted2); + + // But both should decrypt to the same value + const parsed1 = JSON.parse(encrypted1!); + const parsed2 = JSON.parse(encrypted2!); + expect(parsed1.iv).not.toBe(parsed2.iv); // Different IVs + expect(parsed1.data).not.toBe(parsed2.data); // Different ciphertext + }); + }); + + describe('error handling', () => { + it('should return null for non-existent keys', async () => { + const retrieved = await secureStorage.get('non-existent-key'); + expect(retrieved).toBeNull(); + }); + + it('should handle corrupted encrypted data gracefully', async () => { + const key = 'corrupted-key'; + const value = 'valid-value'; + + // Store valid encrypted data + await secureStorage.set(key, value); + + // Corrupt the encrypted data + const encryptedKey = 'enc_' + key; + const encrypted = localStorage.getItem(encryptedKey); + const parsed = JSON.parse(encrypted!); + parsed.data = 'corrupted-data'; + localStorage.setItem(encryptedKey, JSON.stringify(parsed)); + + // Should return null for corrupted data + const retrieved = await secureStorage.get(key); + expect(retrieved).toBeNull(); + }); + }); +});