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>
417 lines
11 KiB
TypeScript
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;
|
|
}
|
|
}
|