feat: production readiness improvements
## Error Handling - Add GlobalErrorBoundary with error classification and recovery - Add custom error types (SecurityError, ConnectionError, TimeoutError) - Fix ErrorAlert component syntax errors ## Offline Mode - Add offlineStore for offline state management - Implement message queue with localStorage persistence - Add exponential backoff reconnection (1s→60s) - Add OfflineIndicator component with status display - Queue messages when offline, auto-retry on reconnect ## Security Hardening - Add AES-256-GCM encryption for chat history storage - Add secure API key storage with OS keychain integration - Add security audit logging system - Add XSS prevention and input validation utilities - Add rate limiting and token generation helpers ## CI/CD (Gitea Actions) - Add .gitea/workflows/ci.yml for continuous integration - Add .gitea/workflows/release.yml for release automation - Support Windows Tauri build and release ## UI Components - Add LoadingSpinner, LoadingOverlay, LoadingDots components - Add MessageSkeleton, ConversationListSkeleton skeletons - Add EmptyMessages, EmptyConversations empty states - Integrate loading states in ChatArea and ConversationList ## E2E Tests - Fix WebSocket mock for streaming response tests - Fix approval endpoint route matching - Add store state exposure for testing - All 19 core-features tests now passing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
412
desktop/src/lib/encrypted-chat-storage.ts
Normal file
412
desktop/src/lib/encrypted-chat-storage.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* Encrypted Chat History Storage
|
||||
*
|
||||
* Provides encrypted persistence for chat messages and conversations.
|
||||
* Uses AES-256-GCM encryption with the secure storage infrastructure.
|
||||
*
|
||||
* Security features:
|
||||
* - All chat data encrypted at rest
|
||||
* - Master key stored in OS keychain when available
|
||||
* - Automatic key derivation with key rotation support
|
||||
* - Secure backup to encrypted localStorage
|
||||
*/
|
||||
|
||||
import {
|
||||
deriveKey,
|
||||
encryptObject,
|
||||
decryptObject,
|
||||
generateMasterKey,
|
||||
hashSha256,
|
||||
isValidEncryptedData,
|
||||
clearKeyCache,
|
||||
} from './crypto-utils';
|
||||
import { secureStorage, isSecureStorageAvailable } from './secure-storage';
|
||||
|
||||
// Storage keys
|
||||
const CHAT_DATA_KEY = 'zclaw_chat_data';
|
||||
const CHAT_KEY_IDENTIFIER = 'zclaw_chat_master_key';
|
||||
const CHAT_KEY_HASH_KEY = 'zclaw_chat_key_hash';
|
||||
const ENCRYPTED_PREFIX = 'enc_chat_';
|
||||
|
||||
// Encryption version for future migrations
|
||||
const STORAGE_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Storage metadata for integrity verification
|
||||
*/
|
||||
interface StorageMetadata {
|
||||
version: number;
|
||||
keyHash: string;
|
||||
createdAt: number;
|
||||
lastAccessedAt: number;
|
||||
encryptedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypted storage container
|
||||
*/
|
||||
interface EncryptedContainer {
|
||||
metadata: StorageMetadata;
|
||||
data: string; // Encrypted payload
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached crypto key for chat encryption
|
||||
*/
|
||||
let cachedChatKey: CryptoKey | null = null;
|
||||
let keyHash: string | null = null;
|
||||
|
||||
/**
|
||||
* Get or initialize the master encryption key for chat storage
|
||||
* Uses OS keychain when available, falls back to encrypted localStorage
|
||||
*/
|
||||
async function getOrCreateMasterKey(): Promise<string> {
|
||||
// Try to get existing key from secure storage
|
||||
const existingKey = await secureStorage.get(CHAT_KEY_IDENTIFIER);
|
||||
if (existingKey) {
|
||||
return existingKey;
|
||||
}
|
||||
|
||||
// Generate new master key
|
||||
const newKey = generateMasterKey();
|
||||
|
||||
// Store in secure storage (keychain or encrypted localStorage)
|
||||
await secureStorage.set(CHAT_KEY_IDENTIFIER, newKey);
|
||||
|
||||
// Store hash for integrity verification
|
||||
const keyHashValue = await hashSha256(newKey);
|
||||
localStorage.setItem(CHAT_KEY_HASH_KEY, keyHashValue);
|
||||
|
||||
console.log('[EncryptedChatStorage] Generated new master key');
|
||||
return newKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the derived encryption key for chat data
|
||||
*/
|
||||
async function getChatEncryptionKey(): Promise<CryptoKey> {
|
||||
if (cachedChatKey && keyHash) {
|
||||
// Verify key hash matches
|
||||
const storedHash = localStorage.getItem(CHAT_KEY_HASH_KEY);
|
||||
if (storedHash === keyHash) {
|
||||
return cachedChatKey;
|
||||
}
|
||||
// Hash mismatch - clear cache and re-derive
|
||||
console.warn('[EncryptedChatStorage] Key hash mismatch, re-deriving key');
|
||||
cachedChatKey = null;
|
||||
keyHash = null;
|
||||
}
|
||||
|
||||
const masterKey = await getOrCreateMasterKey();
|
||||
cachedChatKey = await deriveKey(masterKey);
|
||||
keyHash = await hashSha256(masterKey);
|
||||
|
||||
return cachedChatKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize encrypted chat storage
|
||||
* Called during app startup
|
||||
*/
|
||||
export async function initializeEncryptedChatStorage(): Promise<void> {
|
||||
try {
|
||||
// Pre-load the encryption key
|
||||
await getChatEncryptionKey();
|
||||
|
||||
// Check if we have existing encrypted data to migrate
|
||||
const legacyData = localStorage.getItem('zclaw-chat-storage');
|
||||
if (legacyData && !localStorage.getItem(ENCRYPTED_PREFIX + 'migrated')) {
|
||||
await migrateFromLegacyStorage(legacyData);
|
||||
localStorage.setItem(ENCRYPTED_PREFIX + 'migrated', 'true');
|
||||
console.log('[EncryptedChatStorage] Migrated legacy data');
|
||||
}
|
||||
|
||||
console.log('[EncryptedChatStorage] Initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('[EncryptedChatStorage] Initialization failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate data from legacy unencrypted storage
|
||||
*/
|
||||
async function migrateFromLegacyStorage(legacyData: string): Promise<void> {
|
||||
try {
|
||||
const parsed = JSON.parse(legacyData);
|
||||
if (parsed?.state?.conversations) {
|
||||
await saveConversations(parsed.state.conversations);
|
||||
console.log(`[EncryptedChatStorage] Migrated ${parsed.state.conversations.length} conversations`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[EncryptedChatStorage] Migration failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save conversations to encrypted storage
|
||||
*
|
||||
* @param conversations - Array of conversation objects
|
||||
*/
|
||||
export async function saveConversations(conversations: unknown[]): Promise<void> {
|
||||
if (!conversations || conversations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const key = await getChatEncryptionKey();
|
||||
const now = Date.now();
|
||||
|
||||
// Create container with metadata
|
||||
const container: EncryptedContainer = {
|
||||
metadata: {
|
||||
version: STORAGE_VERSION,
|
||||
keyHash: keyHash || '',
|
||||
createdAt: now,
|
||||
lastAccessedAt: now,
|
||||
encryptedAt: now,
|
||||
},
|
||||
data: '', // Will be set after encryption
|
||||
};
|
||||
|
||||
// Encrypt the conversations array
|
||||
const encrypted = await encryptObject(conversations, key);
|
||||
container.data = JSON.stringify(encrypted);
|
||||
|
||||
// Store the encrypted container
|
||||
localStorage.setItem(CHAT_DATA_KEY, JSON.stringify(container));
|
||||
|
||||
console.log(`[EncryptedChatStorage] Saved ${conversations.length} conversations`);
|
||||
} catch (error) {
|
||||
console.error('[EncryptedChatStorage] Failed to save conversations:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load conversations from encrypted storage
|
||||
*
|
||||
* @returns Array of conversation objects or empty array if none exist
|
||||
*/
|
||||
export async function loadConversations<T = unknown>(): Promise<T[]> {
|
||||
try {
|
||||
const stored = localStorage.getItem(CHAT_DATA_KEY);
|
||||
if (!stored) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const container: EncryptedContainer = JSON.parse(stored);
|
||||
|
||||
// Validate container structure
|
||||
if (!container.data || !container.metadata) {
|
||||
console.warn('[EncryptedChatStorage] Invalid container structure');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check version compatibility
|
||||
if (container.metadata.version > STORAGE_VERSION) {
|
||||
console.error('[EncryptedChatStorage] Incompatible storage version');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Parse and decrypt the data
|
||||
const encryptedData = JSON.parse(container.data);
|
||||
if (!isValidEncryptedData(encryptedData)) {
|
||||
console.error('[EncryptedChatStorage] Invalid encrypted data');
|
||||
return [];
|
||||
}
|
||||
|
||||
const key = await getChatEncryptionKey();
|
||||
const conversations = await decryptObject<T[]>(encryptedData, key);
|
||||
|
||||
// Update last accessed time
|
||||
container.metadata.lastAccessedAt = Date.now();
|
||||
localStorage.setItem(CHAT_DATA_KEY, JSON.stringify(container));
|
||||
|
||||
console.log(`[EncryptedChatStorage] Loaded ${conversations.length} conversations`);
|
||||
return conversations;
|
||||
} catch (error) {
|
||||
console.error('[EncryptedChatStorage] Failed to load conversations:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all chat data from storage
|
||||
*/
|
||||
export async function clearAllChatData(): Promise<void> {
|
||||
try {
|
||||
// Clear encrypted data
|
||||
localStorage.removeItem(CHAT_DATA_KEY);
|
||||
localStorage.removeItem(ENCRYPTED_PREFIX + 'migrated');
|
||||
|
||||
// Clear the master key from secure storage
|
||||
await secureStorage.delete(CHAT_KEY_IDENTIFIER);
|
||||
localStorage.removeItem(CHAT_KEY_HASH_KEY);
|
||||
|
||||
// Clear cached key
|
||||
cachedChatKey = null;
|
||||
keyHash = null;
|
||||
clearKeyCache();
|
||||
|
||||
console.log('[EncryptedChatStorage] Cleared all chat data');
|
||||
} catch (error) {
|
||||
console.error('[EncryptedChatStorage] Failed to clear chat data:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export encrypted chat data for backup
|
||||
* Returns encrypted blob that can be imported later
|
||||
*
|
||||
* @returns Base64-encoded encrypted backup
|
||||
*/
|
||||
export async function exportEncryptedBackup(): Promise<string> {
|
||||
try {
|
||||
const stored = localStorage.getItem(CHAT_DATA_KEY);
|
||||
if (!stored) {
|
||||
throw new Error('No chat data to export');
|
||||
}
|
||||
|
||||
// The data is already encrypted, just return it
|
||||
const container: EncryptedContainer = JSON.parse(stored);
|
||||
const exportData = {
|
||||
type: 'zclaw_chat_backup',
|
||||
version: STORAGE_VERSION,
|
||||
exportedAt: Date.now(),
|
||||
container,
|
||||
};
|
||||
|
||||
return btoa(JSON.stringify(exportData));
|
||||
} catch (error) {
|
||||
console.error('[EncryptedChatStorage] Export failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import encrypted chat data from backup
|
||||
*
|
||||
* @param backupData - Base64-encoded encrypted backup
|
||||
* @param merge - Whether to merge with existing data (default: false, replaces)
|
||||
*/
|
||||
export async function importEncryptedBackup(
|
||||
backupData: string,
|
||||
merge: boolean = false
|
||||
): Promise<void> {
|
||||
try {
|
||||
const decoded = JSON.parse(atob(backupData));
|
||||
|
||||
// Validate backup format
|
||||
if (decoded.type !== 'zclaw_chat_backup') {
|
||||
throw new Error('Invalid backup format');
|
||||
}
|
||||
|
||||
if (decoded.version > STORAGE_VERSION) {
|
||||
throw new Error('Incompatible backup version');
|
||||
}
|
||||
|
||||
if (merge) {
|
||||
// Load existing conversations and merge
|
||||
const existing = await loadConversations();
|
||||
const imported = await decryptObject<unknown[]>(
|
||||
JSON.parse(decoded.container.data),
|
||||
await getChatEncryptionKey()
|
||||
);
|
||||
const merged = [...existing, ...imported];
|
||||
await saveConversations(merged);
|
||||
} else {
|
||||
// Replace existing data
|
||||
localStorage.setItem(CHAT_DATA_KEY, JSON.stringify(decoded.container));
|
||||
}
|
||||
|
||||
console.log('[EncryptedChatStorage] Import completed successfully');
|
||||
} catch (error) {
|
||||
console.error('[EncryptedChatStorage] Import failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if encrypted storage is being used
|
||||
*/
|
||||
export async function isEncryptedStorageActive(): Promise<boolean> {
|
||||
const stored = localStorage.getItem(CHAT_DATA_KEY);
|
||||
if (!stored) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const container: EncryptedContainer = JSON.parse(stored);
|
||||
return container.metadata?.version === STORAGE_VERSION;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage statistics
|
||||
*/
|
||||
export async function getStorageStats(): Promise<{
|
||||
encrypted: boolean;
|
||||
usingKeychain: boolean;
|
||||
conversationCount: number;
|
||||
storageSize: number;
|
||||
}> {
|
||||
const stored = localStorage.getItem(CHAT_DATA_KEY);
|
||||
let conversationCount = 0;
|
||||
let encrypted = false;
|
||||
|
||||
if (stored) {
|
||||
try {
|
||||
const container: EncryptedContainer = JSON.parse(stored);
|
||||
encrypted = container.metadata?.version === STORAGE_VERSION;
|
||||
|
||||
// Count conversations without full decryption
|
||||
const conversations = await loadConversations();
|
||||
conversationCount = conversations.length;
|
||||
} catch {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
encrypted,
|
||||
usingKeychain: await isSecureStorageAvailable(),
|
||||
conversationCount,
|
||||
storageSize: stored ? new Blob([stored]).size : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate encryption key
|
||||
* Re-encrypts all data with a new key
|
||||
*/
|
||||
export async function rotateEncryptionKey(): Promise<void> {
|
||||
try {
|
||||
// Load existing data
|
||||
const conversations = await loadConversations();
|
||||
|
||||
// Clear old key
|
||||
await secureStorage.delete(CHAT_KEY_IDENTIFIER);
|
||||
localStorage.removeItem(CHAT_KEY_HASH_KEY);
|
||||
cachedChatKey = null;
|
||||
keyHash = null;
|
||||
clearKeyCache();
|
||||
|
||||
// Generate new key (will be created on next getChatEncryptionKey call)
|
||||
const newKey = generateMasterKey();
|
||||
await secureStorage.set(CHAT_KEY_IDENTIFIER, newKey);
|
||||
const newKeyHash = await hashSha256(newKey);
|
||||
localStorage.setItem(CHAT_KEY_HASH_KEY, newKeyHash);
|
||||
|
||||
// Re-save all data with new key
|
||||
await saveConversations(conversations);
|
||||
|
||||
console.log('[EncryptedChatStorage] Encryption key rotated successfully');
|
||||
} catch (error) {
|
||||
console.error('[EncryptedChatStorage] Key rotation failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user