Files
zclaw_openfang/desktop/src/lib/encrypted-chat-storage.ts
iven f79560a911 refactor(desktop): split kernel_commands/pipeline_commands into modules, add SaaS client libs and gateway modules
Split monolithic kernel_commands.rs (2185 lines) and pipeline_commands.rs (1391 lines)
into focused sub-modules under kernel_commands/ and pipeline_commands/ directories.
Add gateway module (commands, config, io, runtime), health_check, and 15 new
TypeScript client libraries for SaaS relay, auth, admin, telemetry, and kernel
sub-systems (a2a, agent, chat, hands, skills, triggers).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 11:12:47 +08:00

417 lines
11 KiB
TypeScript

/**
* 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';
import { createLogger } from './logger';
const log = createLogger('EncryptedChatStorage');
// 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);
log.debug('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
log.warn('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');
log.debug('Migrated legacy data');
}
log.debug('Initialized successfully');
} catch (error) {
log.error('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);
log.debug(`Migrated ${parsed.state.conversations.length} conversations`);
}
} catch (error) {
log.error('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));
log.debug(`Saved ${conversations.length} conversations`);
} catch (error) {
log.error('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) {
log.warn('Invalid container structure');
return [];
}
// Check version compatibility
if (container.metadata.version > STORAGE_VERSION) {
log.error('Incompatible storage version');
return [];
}
// Parse and decrypt the data
const encryptedData = JSON.parse(container.data);
if (!isValidEncryptedData(encryptedData)) {
log.error('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));
log.debug(`Loaded ${conversations.length} conversations`);
return conversations;
} catch (error) {
log.error('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();
log.debug('Cleared all chat data');
} catch (error) {
log.error('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) {
log.error('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));
}
log.debug('Import completed successfully');
} catch (error) {
log.error('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 (e) {
log.debug('Failed to check encrypted storage version', { error: e });
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 (e) {
log.debug('Failed to parse storage stats', { error: e });
}
}
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);
log.debug('Encryption key rotated successfully');
} catch (error) {
log.error('Key rotation failed:', error);
throw error;
}
}