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:
iven
2026-03-22 00:03:22 +08:00
parent ce562e8bfc
commit 185763868a
27 changed files with 5725 additions and 268 deletions

View File

@@ -0,0 +1,476 @@
/**
* Secure API Key Storage
*
* Provides secure storage for API keys and sensitive credentials.
* Uses OS keychain when available, with encrypted localStorage fallback.
*
* Security features:
* - Keys stored in OS keychain (Windows DPAPI, macOS Keychain, Linux Secret Service)
* - Encrypted backup in localStorage for migration support
* - Key validation and format checking
* - Audit logging for key access
* - Support for multiple API key types
*/
import { secureStorage, isSecureStorageAvailable } from './secure-storage';
import { hashSha256 } from './crypto-utils';
// Storage key prefixes
const API_KEY_PREFIX = 'zclaw_api_key_';
const API_KEY_META_PREFIX = 'zclaw_api_key_meta_';
/**
* Supported API key types
*/
export type ApiKeyType =
| 'openai'
| 'anthropic'
| 'google'
| 'deepseek'
| 'zhipu'
| 'moonshot'
| 'custom';
/**
* API key metadata
*/
export interface ApiKeyMetadata {
type: ApiKeyType;
name: string;
description?: string;
createdAt: number;
updatedAt: number;
lastUsedAt?: number;
keyHash: string; // Partial hash for validation
prefix: string; // First 8 characters for display
isValid?: boolean;
}
/**
* API key entry with metadata
*/
export interface ApiKeyEntry {
key: string;
metadata: ApiKeyMetadata;
}
/**
* Validation rules for different API key types
*/
const KEY_VALIDATION_RULES: Record<ApiKeyType, {
pattern: RegExp;
minLength: number;
maxLength: number;
prefix?: string[];
}> = {
openai: {
pattern: /^sk-[A-Za-z0-9_-]{20,}$/,
minLength: 20,
maxLength: 200,
prefix: ['sk-'],
},
anthropic: {
pattern: /^sk-ant-[A-Za-z0-9_-]{20,}$/,
minLength: 20,
maxLength: 200,
prefix: ['sk-ant-'],
},
google: {
pattern: /^AIza[A-Za-z0-9_-]{35}$/,
minLength: 35,
maxLength: 50,
prefix: ['AIza'],
},
deepseek: {
pattern: /^sk-[A-Za-z0-9]{20,}$/,
minLength: 20,
maxLength: 100,
prefix: ['sk-'],
},
zhipu: {
pattern: /^[A-Za-z0-9_.-]{20,}$/,
minLength: 20,
maxLength: 100,
},
moonshot: {
pattern: /^sk-[A-Za-z0-9]{20,}$/,
minLength: 20,
maxLength: 100,
prefix: ['sk-'],
},
custom: {
pattern: /^.{8,}$/,
minLength: 8,
maxLength: 500,
},
};
/**
* Validate an API key format
*
* @param type - The API key type
* @param key - The API key to validate
* @returns True if the key format is valid
*/
export function validateApiKeyFormat(type: ApiKeyType, key: string): {
valid: boolean;
error?: string;
} {
const rules = KEY_VALIDATION_RULES[type];
if (!key || typeof key !== 'string') {
return { valid: false, error: 'API key is required' };
}
// Trim whitespace
const trimmedKey = key.trim();
if (trimmedKey.length < rules.minLength) {
return {
valid: false,
error: `API key too short (minimum ${rules.minLength} characters)`,
};
}
if (trimmedKey.length > rules.maxLength) {
return {
valid: false,
error: `API key too long (maximum ${rules.maxLength} characters)`,
};
}
if (!rules.pattern.test(trimmedKey)) {
return {
valid: false,
error: `Invalid API key format for type: ${type}`,
};
}
if (rules.prefix && !rules.prefix.some(p => trimmedKey.startsWith(p))) {
return {
valid: false,
error: `API key must start with: ${rules.prefix.join(' or ')}`,
};
}
return { valid: true };
}
/**
* Create a partial hash for key validation
* Uses first 8 characters for identification without exposing full key
*/
async function createKeyHash(key: string): Promise<string> {
// Use partial hash for validation
const partialKey = key.slice(0, 8) + key.slice(-4);
return hashSha256(partialKey);
}
/**
* Store an API key securely
*
* @param type - The API key type
* @param key - The API key value
* @param options - Optional metadata
*/
export async function storeApiKey(
type: ApiKeyType,
key: string,
options?: {
name?: string;
description?: string;
}
): Promise<ApiKeyMetadata> {
// Validate key format
const validation = validateApiKeyFormat(type, key);
if (!validation.valid) {
throw new Error(validation.error);
}
const trimmedKey = key.trim();
const now = Date.now();
const keyHash = await createKeyHash(trimmedKey);
const metadata: ApiKeyMetadata = {
type,
name: options?.name || `${type}_api_key`,
description: options?.description,
createdAt: now,
updatedAt: now,
keyHash,
prefix: trimmedKey.slice(0, 8) + '...',
isValid: true,
};
// Store key in secure storage
const storageKey = API_KEY_PREFIX + type;
await secureStorage.set(storageKey, trimmedKey);
// Store metadata in localStorage (non-sensitive)
localStorage.setItem(
API_KEY_META_PREFIX + type,
JSON.stringify(metadata)
);
// Log security event
logSecurityEvent('api_key_stored', { type, prefix: metadata.prefix });
return metadata;
}
/**
* Retrieve an API key from secure storage
*
* @param type - The API key type
* @returns The API key or null if not found
*/
export async function getApiKey(type: ApiKeyType): Promise<string | null> {
const storageKey = API_KEY_PREFIX + type;
const key = await secureStorage.get(storageKey);
if (!key) {
return null;
}
// Validate key still matches stored hash
const metaJson = localStorage.getItem(API_KEY_META_PREFIX + type);
if (metaJson) {
try {
const metadata: ApiKeyMetadata = JSON.parse(metaJson);
const currentHash = await createKeyHash(key);
if (currentHash !== metadata.keyHash) {
console.error('[ApiKeyStorage] Key hash mismatch - possible tampering');
logSecurityEvent('api_key_hash_mismatch', { type });
return null;
}
// Update last used timestamp
metadata.lastUsedAt = Date.now();
localStorage.setItem(API_KEY_META_PREFIX + type, JSON.stringify(metadata));
} catch {
// Ignore metadata parsing errors
}
}
logSecurityEvent('api_key_accessed', { type });
return key;
}
/**
* Get API key metadata (without the actual key)
*
* @param type - The API key type
* @returns The metadata or null if not found
*/
export function getApiKeyMetadata(type: ApiKeyType): ApiKeyMetadata | null {
const metaJson = localStorage.getItem(API_KEY_META_PREFIX + type);
if (!metaJson) {
return null;
}
try {
return JSON.parse(metaJson) as ApiKeyMetadata;
} catch {
return null;
}
}
/**
* List all stored API key metadata
*
* @returns Array of API key metadata
*/
export function listApiKeyMetadata(): ApiKeyMetadata[] {
const metadata: ApiKeyMetadata[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith(API_KEY_META_PREFIX)) {
try {
const meta = JSON.parse(localStorage.getItem(key) || '');
metadata.push(meta);
} catch {
// Ignore parsing errors
}
}
}
return metadata;
}
/**
* Delete an API key
*
* @param type - The API key type
*/
export async function deleteApiKey(type: ApiKeyType): Promise<void> {
const storageKey = API_KEY_PREFIX + type;
await secureStorage.delete(storageKey);
localStorage.removeItem(API_KEY_META_PREFIX + type);
logSecurityEvent('api_key_deleted', { type });
}
/**
* Update API key metadata
*
* @param type - The API key type
* @param updates - Metadata updates
*/
export function updateApiKeyMetadata(
type: ApiKeyType,
updates: Partial<Pick<ApiKeyMetadata, 'name' | 'description'>>
): void {
const metaJson = localStorage.getItem(API_KEY_META_PREFIX + type);
if (!metaJson) {
throw new Error(`API key metadata not found for type: ${type}`);
}
const metadata: ApiKeyMetadata = JSON.parse(metaJson);
Object.assign(metadata, updates, { updatedAt: Date.now() });
localStorage.setItem(API_KEY_META_PREFIX + type, JSON.stringify(metadata));
}
/**
* Check if an API key exists for a type
*
* @param type - The API key type
* @returns True if key exists
*/
export async function hasApiKey(type: ApiKeyType): Promise<boolean> {
const key = await getApiKey(type);
return key !== null;
}
/**
* Validate a stored API key
*
* @param type - The API key type
* @returns Validation result
*/
export async function validateStoredApiKey(type: ApiKeyType): Promise<{
valid: boolean;
error?: string;
}> {
const key = await getApiKey(type);
if (!key) {
return { valid: false, error: 'API key not found' };
}
return validateApiKeyFormat(type, key);
}
/**
* Rotate an API key
*
* @param type - The API key type
* @param newKey - The new API key value
*/
export async function rotateApiKey(type: ApiKeyType, newKey: string): Promise<ApiKeyMetadata> {
// Delete old key first
await deleteApiKey(type);
// Store new key
return storeApiKey(type, newKey, {
name: `${type}_api_key_rotated`,
description: `Rotated at ${new Date().toISOString()}`,
});
}
/**
* Export API key configuration (without actual keys)
* Useful for backup or migration
*/
export function exportApiKeyConfig(): Array<Omit<ApiKeyMetadata, 'keyHash'>> {
return listApiKeyMetadata().map(({ keyHash: _, ...meta }) => meta);
}
/**
* Check if using OS keychain for storage
*/
export async function isUsingKeychain(): Promise<boolean> {
return isSecureStorageAvailable();
}
// ============================================================================
// Security Audit Logging
// ============================================================================
interface SecurityEvent {
type: string;
timestamp: number;
details: Record<string, unknown>;
}
const SECURITY_LOG_KEY = 'zclaw_security_events';
const MAX_LOG_ENTRIES = 1000;
/**
* Log a security event
*/
function logSecurityEvent(
type: string,
details: Record<string, unknown>
): void {
try {
const events: SecurityEvent[] = JSON.parse(
localStorage.getItem(SECURITY_LOG_KEY) || '[]'
);
events.push({
type,
timestamp: Date.now(),
details,
});
// Trim old entries
if (events.length > MAX_LOG_ENTRIES) {
events.splice(0, events.length - MAX_LOG_ENTRIES);
}
localStorage.setItem(SECURITY_LOG_KEY, JSON.stringify(events));
} catch {
// Ignore logging failures
}
}
/**
* Get security event log
*/
export function getSecurityLog(): SecurityEvent[] {
try {
return JSON.parse(localStorage.getItem(SECURITY_LOG_KEY) || '[]');
} catch {
return [];
}
}
/**
* Clear security event log
*/
export function clearSecurityLog(): void {
localStorage.removeItem(SECURITY_LOG_KEY);
}
/**
* Generate a random API key for testing
* WARNING: Only use for testing purposes
*/
export function generateTestApiKey(type: ApiKeyType): string {
const rules = KEY_VALIDATION_RULES[type];
const length = rules.minLength + 10;
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let key = '';
if (rules.prefix && rules.prefix.length > 0) {
key = rules.prefix[0];
}
for (let i = key.length; i < length; i++) {
key += chars.charAt(Math.floor(Math.random() * chars.length));
}
return key;
}

View File

@@ -1,10 +1,18 @@
/**
* Cryptographic utilities for secure storage
* Uses Web Crypto API for AES-GCM encryption
*
* Security features:
* - AES-256-GCM for authenticated encryption
* - PBKDF2 with 100,000 iterations for key derivation
* - Random IV for each encryption operation
* - Constant-time comparison for integrity verification
* - Secure key caching with automatic expiration
*/
const SALT = new TextEncoder().encode('zclaw-secure-storage-salt');
const ITERATIONS = 100000;
const KEY_EXPIRY_MS = 30 * 60 * 1000; // 30 minutes
/**
* Convert Uint8Array to base64 string
@@ -33,13 +41,64 @@ export function base64ToArray(base64: string): Uint8Array {
return array;
}
/**
* Key cache entry with expiration
*/
interface CachedKey {
key: CryptoKey;
createdAt: number;
}
/**
* Cache for derived keys with automatic expiration
*/
const keyCache = new Map<string, CachedKey>();
/**
* Clean up expired keys from cache
*/
function cleanupExpiredKeys(): void {
const now = Date.now();
for (const [cacheKey, entry] of keyCache.entries()) {
if (now - entry.createdAt > KEY_EXPIRY_MS) {
keyCache.delete(cacheKey);
}
}
}
/**
* Generate a cache key from master key and salt
*/
function getCacheKey(masterKey: string, salt: Uint8Array): string {
const encoder = new TextEncoder();
const combined = new Uint8Array(encoder.encode(masterKey).length + salt.length);
combined.set(encoder.encode(masterKey), 0);
combined.set(salt, encoder.encode(masterKey).length);
return arrayToBase64(combined.slice(0, 32)); // Use first 32 bytes as cache key
}
/**
* Derive an encryption key from a master key
* Uses PBKDF2 with SHA-256 for key derivation
*
* @param masterKey - The master key string
* @param salt - Optional salt (uses default if not provided)
* @returns Promise<CryptoKey> - The derived encryption key
*/
export async function deriveKey(
masterKey: string,
salt: Uint8Array = SALT
): Promise<CryptoKey> {
// Clean up expired keys periodically
cleanupExpiredKeys();
// Check cache first
const cacheKey = getCacheKey(masterKey, salt);
const cached = keyCache.get(cacheKey);
if (cached && Date.now() - cached.createdAt < KEY_EXPIRY_MS) {
return cached.key;
}
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw',
@@ -49,7 +108,7 @@ export async function deriveKey(
['deriveBits', 'deriveKey']
);
return crypto.subtle.deriveKey(
const derivedKey = await crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
@@ -61,15 +120,39 @@ export async function deriveKey(
false,
['encrypt', 'decrypt']
);
// Cache the derived key
keyCache.set(cacheKey, { key: derivedKey, createdAt: Date.now() });
return derivedKey;
}
/**
* Encrypted data structure
*/
export interface EncryptedData {
iv: string;
data: string;
authTag?: string; // For future use with separate auth tag
version?: number; // Schema version for future migrations
}
/**
* Current encryption schema version
*/
const ENCRYPTION_VERSION = 1;
/**
* Encrypt data using AES-GCM
*
* @param plaintext - The plaintext string to encrypt
* @param key - The encryption key
* @returns Promise<EncryptedData> - The encrypted data with IV
*/
export async function encrypt(
plaintext: string,
key: CryptoKey
): Promise<{ iv: string; data: string }> {
): Promise<EncryptedData> {
const encoder = new TextEncoder();
const iv = crypto.getRandomValues(new Uint8Array(12));
@@ -82,14 +165,19 @@ export async function encrypt(
return {
iv: arrayToBase64(iv),
data: arrayToBase64(new Uint8Array(encrypted)),
version: ENCRYPTION_VERSION,
};
}
/**
* Decrypt data using AES-GCM
*
* @param encrypted - The encrypted data object
* @param key - The decryption key
* @returns Promise<string> - The decrypted plaintext
*/
export async function decrypt(
encrypted: { iv: string; data: string },
encrypted: EncryptedData,
key: CryptoKey
): Promise<string> {
const decoder = new TextDecoder();
@@ -104,8 +192,169 @@ export async function decrypt(
/**
* Generate a random master key for encryption
* Uses cryptographically secure random number generator
*
* @returns string - Base64-encoded 256-bit random key
*/
export function generateMasterKey(): string {
const array = crypto.getRandomValues(new Uint8Array(32));
return arrayToBase64(array);
}
/**
* Generate a random salt
*
* @param length - Salt length in bytes (default: 16)
* @returns Uint8Array - Random salt
*/
export function generateSalt(length: number = 16): Uint8Array {
return crypto.getRandomValues(new Uint8Array(length));
}
/**
* Constant-time comparison to prevent timing attacks
*
* @param a - First byte array
* @param b - Second byte array
* @returns boolean - True if arrays are equal
*/
export function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) {
return false;
}
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a[i] ^ b[i];
}
return result === 0;
}
/**
* Hash a string using SHA-256
*
* @param input - The input string to hash
* @returns Promise<string> - Hex-encoded hash
*/
export async function hashSha256(input: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(input);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = new Uint8Array(hashBuffer);
// Convert to hex string
return Array.from(hashArray)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* Hash a string using SHA-512 (for sensitive data)
*
* @param input - The input string to hash
* @returns Promise<string> - Hex-encoded hash
*/
export async function hashSha512(input: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(input);
const hashBuffer = await crypto.subtle.digest('SHA-512', data);
const hashArray = new Uint8Array(hashBuffer);
return Array.from(hashArray)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* Generate a cryptographically secure random string
*
* @param length - Length of the string (default: 32)
* @returns string - Random alphanumeric string
*/
export function generateRandomString(length: number = 32): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const array = crypto.getRandomValues(new Uint8Array(length));
let result = '';
for (let i = 0; i < length; i++) {
result += chars[array[i] % chars.length];
}
return result;
}
/**
* Clear the key cache (for logout or security events)
*/
export function clearKeyCache(): void {
keyCache.clear();
}
/**
* Encrypt a JSON object
*
* @param obj - The object to encrypt
* @param key - The encryption key
* @returns Promise<EncryptedData> - The encrypted data
*/
export async function encryptObject<T>(
obj: T,
key: CryptoKey
): Promise<EncryptedData> {
const plaintext = JSON.stringify(obj);
return encrypt(plaintext, key);
}
/**
* Decrypt a JSON object
*
* @param encrypted - The encrypted data
* @param key - The decryption key
* @returns Promise<T> - The decrypted object
*/
export async function decryptObject<T>(
encrypted: EncryptedData,
key: CryptoKey
): Promise<T> {
const plaintext = await decrypt(encrypted, key);
return JSON.parse(plaintext) as T;
}
/**
* Securely wipe a string from memory (best effort)
* Note: JavaScript strings are immutable, so this only works for
* data that was explicitly copied to a Uint8Array
*
* @param array - The byte array to wipe
*/
export function secureWipe(array: Uint8Array): void {
crypto.getRandomValues(array);
array.fill(0);
}
/**
* Check if Web Crypto API is available
*/
export function isCryptoAvailable(): boolean {
return (
typeof crypto !== 'undefined' &&
typeof crypto.subtle !== 'undefined' &&
typeof crypto.getRandomValues === 'function'
);
}
/**
* Validate encrypted data structure
*/
export function isValidEncryptedData(data: unknown): data is EncryptedData {
if (typeof data !== 'object' || data === null) {
return false;
}
const obj = data as Record<string, unknown>;
return (
typeof obj.iv === 'string' &&
typeof obj.data === 'string' &&
obj.iv.length > 0 &&
obj.data.length > 0
);
}

View 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;
}
}

View File

@@ -87,6 +87,47 @@ export class SecurityError extends Error {
}
}
/**
* Connection error for WebSocket/HTTP connection failures.
*/
export class ConnectionError extends Error {
public readonly code?: string;
public readonly recoverable: boolean;
constructor(message: string, code?: string, recoverable: boolean = true) {
super(message);
this.name = 'ConnectionError';
this.code = code;
this.recoverable = recoverable;
}
}
/**
* Timeout error for request/response timeouts.
*/
export class TimeoutError extends Error {
public readonly timeout: number;
constructor(message: string, timeout: number) {
super(message);
this.name = 'TimeoutError';
this.timeout = timeout;
}
}
/**
* Authentication error for handshake/token failures.
*/
export class AuthenticationError extends Error {
public readonly code?: string;
constructor(message: string, code?: string) {
super(message);
this.name = 'AuthenticationError';
this.code = code;
}
}
/**
* Validate WebSocket URL security.
* Ensures non-localhost connections use WSS protocol.

View File

@@ -3,9 +3,14 @@
*
* Extracted from gateway-client.ts for modularity.
* Manages WSS configuration, URL normalization, and
* localStorage persistence for gateway URL and token.
* secure storage persistence for gateway URL and token.
*
* Security: Token is now stored using secure storage (keychain or encrypted localStorage)
*/
import { secureStorage } from './secure-storage';
import { logKeyEvent, logSecurityEvent } from './security-audit';
// === WSS Configuration ===
/**
@@ -95,18 +100,104 @@ export function setStoredGatewayUrl(url: string): string {
return normalized;
}
export function getStoredGatewayToken(): string {
/**
* Get the stored gateway token from secure storage
* Uses OS keychain when available, falls back to encrypted localStorage
*
* @returns The stored token or empty string if not found
*/
export async function getStoredGatewayTokenAsync(): Promise<string> {
try {
return localStorage.getItem(GATEWAY_TOKEN_STORAGE_KEY) || '';
const token = await secureStorage.get(GATEWAY_TOKEN_STORAGE_KEY);
if (token) {
logKeyEvent('key_accessed', 'Retrieved gateway token', { source: 'secure_storage' });
}
return token || '';
} catch (error) {
console.error('[GatewayStorage] Failed to get gateway token:', error);
return '';
}
}
/**
* Synchronous version for backward compatibility
* @deprecated Use getStoredGatewayTokenAsync() instead
*/
export function getStoredGatewayToken(): string {
// This returns empty string and logs a warning in dev mode
// Real code should use the async version
if (process.env.NODE_ENV === 'development') {
console.warn('[GatewayStorage] Using synchronous token access - consider using async version');
}
// Try to get from localStorage as fallback (may be encrypted)
try {
const stored = localStorage.getItem(GATEWAY_TOKEN_STORAGE_KEY);
if (stored) {
// Check if it's encrypted (has iv and data fields)
try {
const parsed = JSON.parse(stored);
if (parsed && typeof parsed.iv === 'string' && typeof parsed.data === 'string') {
// Data is encrypted - cannot decrypt synchronously
console.warn('[GatewayStorage] Token is encrypted - use async version');
return '';
}
} catch {
// Not JSON, so it's plaintext (legacy format)
return stored;
}
}
return '';
} catch {
return '';
}
}
export function setStoredGatewayToken(token: string): string {
/**
* Store the gateway token securely
* Uses OS keychain when available, falls back to encrypted localStorage
*
* @param token - The token to store
* @returns The normalized token
*/
export async function setStoredGatewayTokenAsync(token: string): Promise<string> {
const normalized = token.trim();
try {
if (normalized) {
await secureStorage.set(GATEWAY_TOKEN_STORAGE_KEY, normalized);
logKeyEvent('key_stored', 'Stored gateway token', { source: 'secure_storage' });
} else {
await secureStorage.delete(GATEWAY_TOKEN_STORAGE_KEY);
logKeyEvent('key_deleted', 'Deleted gateway token', { source: 'secure_storage' });
}
// Clear legacy localStorage token if it exists
localStorage.removeItem(GATEWAY_TOKEN_STORAGE_KEY);
} catch (error) {
console.error('[GatewayStorage] Failed to store gateway token:', error);
logSecurityEvent('security_violation', 'Failed to store gateway token securely', {
error: error instanceof Error ? error.message : String(error),
});
}
return normalized;
}
/**
* Synchronous version for backward compatibility
* @deprecated Use setStoredGatewayTokenAsync() instead
*/
export function setStoredGatewayToken(token: string): string {
const normalized = token.trim();
if (process.env.NODE_ENV === 'development') {
console.warn('[GatewayStorage] Using synchronous token storage - consider using async version');
}
try {
if (normalized) {
// Store in localStorage as fallback (not secure, but better than nothing)
localStorage.setItem(GATEWAY_TOKEN_STORAGE_KEY, normalized);
} else {
localStorage.removeItem(GATEWAY_TOKEN_STORAGE_KEY);
@@ -114,5 +205,6 @@ export function setStoredGatewayToken(token: string): string {
} catch {
/* ignore localStorage failures */
}
return normalized;
}

View File

@@ -0,0 +1,564 @@
/**
* Security Audit Logging Module
*
* Provides comprehensive security event logging for ZCLAW application.
* All security-relevant events are logged with timestamps and details.
*
* Security events logged:
* - Authentication events (login, logout, failed attempts)
* - API key operations (access, rotation, deletion)
* - Data access events (encrypted data read/write)
* - Security violations (failed decryption, tampering attempts)
* - Configuration changes
*/
import { hashSha256 } from './crypto-utils';
// ============================================================================
// Types
// ============================================================================
export type SecurityEventType =
| 'auth_login'
| 'auth_logout'
| 'auth_failed'
| 'auth_token_refresh'
| 'key_accessed'
| 'key_stored'
| 'key_deleted'
| 'key_rotated'
| 'data_encrypted'
| 'data_decrypted'
| 'data_access'
| 'data_export'
| 'data_import'
| 'security_violation'
| 'decryption_failed'
| 'integrity_check_failed'
| 'config_changed'
| 'permission_granted'
| 'permission_denied'
| 'session_started'
| 'session_ended'
| 'rate_limit_exceeded'
| 'suspicious_activity';
export type SecurityEventSeverity = 'info' | 'warning' | 'error' | 'critical';
export interface SecurityEvent {
id: string;
type: SecurityEventType;
severity: SecurityEventSeverity;
timestamp: string;
message: string;
details: Record<string, unknown>;
userAgent?: string;
ip?: string;
sessionId?: string;
agentId?: string;
}
export interface SecurityAuditReport {
generatedAt: string;
totalEvents: number;
eventsByType: Record<SecurityEventType, number>;
eventsBySeverity: Record<SecurityEventSeverity, number>;
recentCriticalEvents: SecurityEvent[];
recommendations: string[];
}
// ============================================================================
// Constants
// ============================================================================
const SECURITY_LOG_KEY = 'zclaw_security_audit_log';
const MAX_LOG_ENTRIES = 2000;
const AUDIT_VERSION = 1;
// ============================================================================
// Internal State
// ============================================================================
let isAuditEnabled: boolean = true;
let currentSessionId: string | null = null;
// ============================================================================
// Core Functions
// ============================================================================
/**
* Generate a unique event ID
*/
function generateEventId(): string {
return `evt_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
}
/**
* Get the current session ID
*/
export function getCurrentSessionId(): string | null {
return currentSessionId;
}
/**
* Set the current session ID
*/
export function setCurrentSessionId(sessionId: string | null): void {
currentSessionId = sessionId;
}
/**
* Enable or disable audit logging
*/
export function setAuditEnabled(enabled: boolean): void {
isAuditEnabled = enabled;
logSecurityEventInternal('config_changed', 'info', `Audit logging ${enabled ? 'enabled' : 'disabled'}`, {});
}
/**
* Check if audit logging is enabled
*/
export function isAuditEnabledState(): boolean {
return isAuditEnabled;
}
/**
* Internal function to persist security events
*/
function persistEvent(event: SecurityEvent): void {
try {
const events = getStoredEvents();
events.push(event);
// Trim old entries if needed
if (events.length > MAX_LOG_ENTRIES) {
events.splice(0, events.length - MAX_LOG_ENTRIES);
}
localStorage.setItem(SECURITY_LOG_KEY, JSON.stringify(events));
} catch {
// Ignore persistence failures to prevent application disruption
}
}
/**
* Get stored security events
*/
function getStoredEvents(): SecurityEvent[] {
try {
const stored = localStorage.getItem(SECURITY_LOG_KEY);
if (!stored) return [];
return JSON.parse(stored) as SecurityEvent[];
} catch {
return [];
}
}
/**
* Determine severity based on event type
*/
function getDefaultSeverity(type: SecurityEventType): SecurityEventSeverity {
const severityMap: Record<SecurityEventType, SecurityEventSeverity> = {
auth_login: 'info',
auth_logout: 'info',
auth_failed: 'warning',
auth_token_refresh: 'info',
key_accessed: 'info',
key_stored: 'info',
key_deleted: 'warning',
key_rotated: 'info',
data_encrypted: 'info',
data_decrypted: 'info',
data_access: 'info',
data_export: 'warning',
data_import: 'warning',
security_violation: 'critical',
decryption_failed: 'error',
integrity_check_failed: 'critical',
config_changed: 'warning',
permission_granted: 'info',
permission_denied: 'warning',
session_started: 'info',
session_ended: 'info',
rate_limit_exceeded: 'warning',
suspicious_activity: 'critical',
};
return severityMap[type] || 'info';
}
/**
* Internal function to log security events
*/
function logSecurityEventInternal(
type: SecurityEventType,
severity: SecurityEventSeverity,
message: string,
details: Record<string, unknown>
): void {
if (!isAuditEnabled && type !== 'config_changed') {
return;
}
const event: SecurityEvent = {
id: generateEventId(),
type,
severity,
timestamp: new Date().toISOString(),
message,
details,
sessionId: currentSessionId || undefined,
};
// Add user agent if in browser
if (typeof navigator !== 'undefined') {
event.userAgent = navigator.userAgent;
}
persistEvent(event);
// Log to console for development
if (process.env.NODE_ENV === 'development') {
const logMethod = severity === 'critical' || severity === 'error' ? 'error' :
severity === 'warning' ? 'warn' : 'log';
console[logMethod](`[SecurityAudit] ${type}: ${message}`, details);
}
}
// ============================================================================
// Public API
// ============================================================================
/**
* Log a security event
*/
export function logSecurityEvent(
type: SecurityEventType,
message: string,
details: Record<string, unknown> = {},
severity?: SecurityEventSeverity
): void {
const eventSeverity = severity || getDefaultSeverity(type);
logSecurityEventInternal(type, eventSeverity, message, details);
}
/**
* Log authentication event
*/
export function logAuthEvent(
type: 'auth_login' | 'auth_logout' | 'auth_failed' | 'auth_token_refresh',
message: string,
details: Record<string, unknown> = {}
): void {
logSecurityEvent(type, message, details);
}
/**
* Log key management event
*/
export function logKeyEvent(
type: 'key_accessed' | 'key_stored' | 'key_deleted' | 'key_rotated',
message: string,
details: Record<string, unknown> = {}
): void {
logSecurityEvent(type, message, details);
}
/**
* Log data access event
*/
export function logDataEvent(
type: 'data_encrypted' | 'data_decrypted' | 'data_access' | 'data_export' | 'data_import',
message: string,
details: Record<string, unknown> = {}
): void {
logSecurityEvent(type, message, details);
}
/**
* Log security violation
*/
export function logSecurityViolation(
message: string,
details: Record<string, unknown> = {}
): void {
logSecurityEvent('security_violation', message, details, 'critical');
}
/**
* Log decryption failure
*/
export function logDecryptionFailure(
message: string,
details: Record<string, unknown> = {}
): void {
logSecurityEvent('decryption_failed', message, details, 'error');
}
/**
* Log integrity check failure
*/
export function logIntegrityFailure(
message: string,
details: Record<string, unknown> = {}
): void {
logSecurityEvent('integrity_check_failed', message, details, 'critical');
}
/**
* Log permission event
*/
export function logPermissionEvent(
type: 'permission_granted' | 'permission_denied',
message: string,
details: Record<string, unknown> = {}
): void {
logSecurityEvent(type, message, details);
}
/**
* Log session event
*/
export function logSessionEvent(
type: 'session_started' | 'session_ended',
message: string,
details: Record<string, unknown> = {}
): void {
logSecurityEvent(type, message, details);
}
/**
* Log suspicious activity
*/
export function logSuspiciousActivity(
message: string,
details: Record<string, unknown> = {}
): void {
logSecurityEvent('suspicious_activity', message, details, 'critical');
}
/**
* Log rate limit event
*/
export function logRateLimitEvent(
message: string,
details: Record<string, unknown> = {}
): void {
logSecurityEvent('rate_limit_exceeded', message, details, 'warning');
}
// ============================================================================
// Query Functions
// ============================================================================
/**
* Get all security events
*/
export function getSecurityEvents(): SecurityEvent[] {
return getStoredEvents();
}
/**
* Get security events by type
*/
export function getSecurityEventsByType(type: SecurityEventType): SecurityEvent[] {
return getStoredEvents().filter(event => event.type === type);
}
/**
* Get security events by severity
*/
export function getSecurityEventsBySeverity(severity: SecurityEventSeverity): SecurityEvent[] {
return getStoredEvents().filter(event => event.severity === severity);
}
/**
* Get security events within a time range
*/
export function getSecurityEventsByTimeRange(start: Date, end: Date): SecurityEvent[] {
const startTime = start.getTime();
const endTime = end.getTime();
return getStoredEvents().filter(event => {
const eventTime = new Date(event.timestamp).getTime();
return eventTime >= startTime && eventTime <= endTime;
});
}
/**
* Get recent critical events
*/
export function getRecentCriticalEvents(count: number = 10): SecurityEvent[] {
return getStoredEvents()
.filter(event => event.severity === 'critical' || event.severity === 'error')
.slice(-count);
}
/**
* Get events for a specific session
*/
export function getSecurityEventsBySession(sessionId: string): SecurityEvent[] {
return getStoredEvents().filter(event => event.sessionId === sessionId);
}
// ============================================================================
// Report Generation
// ============================================================================
/**
* Generate a security audit report
*/
export function generateSecurityAuditReport(): SecurityAuditReport {
const events = getStoredEvents();
const eventsByType = Object.create(null) as Record<SecurityEventType, number>;
const eventsBySeverity: Record<SecurityEventSeverity, number> = {
info: 0,
warning: 0,
error: 0,
critical: 0,
};
for (const event of events) {
eventsByType[event.type] = (eventsByType[event.type] || 0) + 1;
eventsBySeverity[event.severity]++;
}
const recentCriticalEvents = getRecentCriticalEvents(10);
const recommendations: string[] = [];
// Generate recommendations based on findings
if (eventsBySeverity.critical > 0) {
recommendations.push('Investigate critical security events immediately');
}
if ((eventsByType.auth_failed || 0) > 5) {
recommendations.push('Multiple failed authentication attempts detected - consider rate limiting');
}
if ((eventsByType.decryption_failed || 0) > 3) {
recommendations.push('Multiple decryption failures - check key integrity');
}
if ((eventsByType.suspicious_activity || 0) > 0) {
recommendations.push('Suspicious activity detected - review access logs');
}
if (events.length === 0) {
recommendations.push('No security events recorded - ensure audit logging is enabled');
}
return {
generatedAt: new Date().toISOString(),
totalEvents: events.length,
eventsByType,
eventsBySeverity,
recentCriticalEvents,
recommendations,
};
}
// ============================================================================
// Maintenance Functions
// ============================================================================
/**
* Clear all security events
*/
export function clearSecurityAuditLog(): void {
localStorage.removeItem(SECURITY_LOG_KEY);
logSecurityEventInternal('config_changed', 'warning', 'Security audit log cleared', {});
}
/**
* Export security events for external analysis
*/
export function exportSecurityEvents(): string {
const events = getStoredEvents();
return JSON.stringify({
version: AUDIT_VERSION,
exportedAt: new Date().toISOString(),
events,
}, null, 2);
}
/**
* Import security events from external source
*/
export function importSecurityEvents(jsonData: string, merge: boolean = false): void {
try {
const data = JSON.parse(jsonData);
const importedEvents = data.events as SecurityEvent[];
if (!importedEvents || !Array.isArray(importedEvents)) {
throw new Error('Invalid import data format');
}
if (merge) {
const existingEvents = getStoredEvents();
const mergedEvents = [...existingEvents, ...importedEvents];
localStorage.setItem(SECURITY_LOG_KEY, JSON.stringify(mergedEvents.slice(-MAX_LOG_ENTRIES)));
} else {
localStorage.setItem(SECURITY_LOG_KEY, JSON.stringify(importedEvents.slice(-MAX_LOG_ENTRIES)));
}
logSecurityEventInternal('data_import', 'warning', `Imported ${importedEvents.length} security events`, {
merge,
sourceVersion: data.version,
});
} catch (error) {
logSecurityEventInternal('security_violation', 'error', 'Failed to import security events', {
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
/**
* Verify audit log integrity
*/
export async function verifyAuditLogIntegrity(): Promise<{
valid: boolean;
eventCount: number;
hash: string;
}> {
const events = getStoredEvents();
const data = JSON.stringify(events);
const hash = await hashSha256(data);
return {
valid: events.length > 0,
eventCount: events.length,
hash,
};
}
// ============================================================================
// Initialization
// ============================================================================
/**
* Initialize the security audit module
*/
export function initializeSecurityAudit(sessionId?: string): void {
if (sessionId) {
currentSessionId = sessionId;
}
logSecurityEventInternal('session_started', 'info', 'Security audit session started', {
sessionId: currentSessionId,
auditEnabled: isAuditEnabled,
});
}
/**
* Shutdown the security audit module
*/
export function shutdownSecurityAudit(): void {
logSecurityEventInternal('session_ended', 'info', 'Security audit session ended', {
sessionId: currentSessionId,
});
currentSessionId = null;
}

View File

@@ -0,0 +1,241 @@
/**
* Security Module Index
*
* Central export point for all security-related functionality in ZCLAW.
*
* Modules:
* - crypto-utils: AES-256-GCM encryption, key derivation, hashing
* - secure-storage: OS keychain integration with encrypted localStorage fallback
* - api-key-storage: Secure API key management
* - encrypted-chat-storage: Encrypted chat history persistence
* - security-audit: Security event logging and reporting
* - security-utils: Input validation, XSS prevention, rate limiting
*/
// Re-export crypto utilities
export {
// Core encryption
encrypt,
decrypt,
encryptObject,
decryptObject,
deriveKey,
generateMasterKey,
generateSalt,
// Hashing
hashSha256,
hashSha512,
// Utilities
arrayToBase64,
base64ToArray,
constantTimeEqual,
generateRandomString,
secureWipe,
clearKeyCache,
isCryptoAvailable,
isValidEncryptedData,
} from './crypto-utils';
export type { EncryptedData } from './crypto-utils';
// Re-export secure storage
export {
secureStorage,
secureStorageSync,
isSecureStorageAvailable,
storeDeviceKeys,
getDeviceKeys,
deleteDeviceKeys,
hasDeviceKeys,
getDeviceKeysCreatedAt,
} from './secure-storage';
export type { Ed25519KeyPair } from './secure-storage';
// Re-export API key storage
export {
// Types
type ApiKeyType,
type ApiKeyMetadata,
// Core functions
storeApiKey,
getApiKey,
deleteApiKey,
listApiKeyMetadata,
updateApiKeyMetadata,
hasApiKey,
validateStoredApiKey,
rotateApiKey,
// Utility functions
validateApiKeyFormat,
exportApiKeyConfig,
isUsingKeychain,
generateTestApiKey,
} from './api-key-storage';
// Re-export encrypted chat storage
export {
initializeEncryptedChatStorage,
saveConversations,
loadConversations,
clearAllChatData,
exportEncryptedBackup,
importEncryptedBackup,
isEncryptedStorageActive,
getStorageStats,
rotateEncryptionKey,
} from './encrypted-chat-storage';
// Re-export security audit
export {
// Core logging
logSecurityEvent,
logAuthEvent,
logKeyEvent,
logDataEvent,
logSecurityViolation,
logDecryptionFailure,
logIntegrityFailure,
logPermissionEvent,
logSessionEvent,
logSuspiciousActivity,
logRateLimitEvent,
// Query functions
getSecurityEvents,
getSecurityEventsByType,
getSecurityEventsBySeverity,
getSecurityEventsByTimeRange,
getRecentCriticalEvents,
getSecurityEventsBySession,
// Report generation
generateSecurityAuditReport,
// Maintenance
clearSecurityAuditLog,
exportSecurityEvents,
importSecurityEvents,
verifyAuditLogIntegrity,
// Session management
getCurrentSessionId,
setCurrentSessionId,
setAuditEnabled,
isAuditEnabledState,
initializeSecurityAudit,
shutdownSecurityAudit,
} from './security-audit';
export type {
SecurityEventType,
SecurityEventSeverity,
SecurityEvent,
SecurityAuditReport,
} from './security-audit';
// Re-export security utilities
export {
// HTML sanitization
escapeHtml,
unescapeHtml,
sanitizeHtml,
// URL validation
validateUrl,
isSafeRedirectUrl,
// Path validation
validatePath,
// Input validation
isValidEmail,
isValidUsername,
validatePasswordStrength,
sanitizeFilename,
sanitizeJson,
// Rate limiting
isRateLimited,
resetRateLimit,
getRemainingAttempts,
// CSP helpers
generateCspNonce,
buildCspHeader,
DEFAULT_CSP_DIRECTIVES,
// Security checks
checkSecurityHeaders,
// Random generation
generateSecureToken,
generateSecureId,
} from './security-utils';
// ============================================================================
// Security Initialization
// ============================================================================
/**
* Initialize all security modules
* Call this during application startup
*/
export async function initializeSecurity(sessionId?: string): Promise<void> {
// Initialize security audit first
const { initializeSecurityAudit } = await import('./security-audit');
initializeSecurityAudit(sessionId);
// Initialize encrypted chat storage
const { initializeEncryptedChatStorage } = await import('./encrypted-chat-storage');
await initializeEncryptedChatStorage();
console.log('[Security] All security modules initialized');
}
/**
* Shutdown all security modules
* Call this during application shutdown
*/
export async function shutdownSecurity(): Promise<void> {
const { shutdownSecurityAudit } = await import('./security-audit');
shutdownSecurityAudit();
const { clearKeyCache } = await import('./crypto-utils');
clearKeyCache();
console.log('[Security] All security modules shut down');
}
/**
* Get a comprehensive security status report
*/
export async function getSecurityStatus(): Promise<{
auditEnabled: boolean;
keychainAvailable: boolean;
chatStorageInitialized: boolean;
storedApiKeys: number;
recentEvents: number;
criticalEvents: number;
}> {
const { isAuditEnabledState, getSecurityEventsBySeverity } = await import('./security-audit');
const { isSecureStorageAvailable } = await import('./secure-storage');
const { isEncryptedStorageActive: isChatStorageInitialized } = await import('./encrypted-chat-storage');
const { listApiKeyMetadata } = await import('./api-key-storage');
const criticalEvents = getSecurityEventsBySeverity('critical').length;
const errorEvents = getSecurityEventsBySeverity('error').length;
return {
auditEnabled: isAuditEnabledState(),
keychainAvailable: await isSecureStorageAvailable(),
chatStorageInitialized: await isChatStorageInitialized(),
storedApiKeys: (await listApiKeyMetadata()).length,
recentEvents: criticalEvents + errorEvents,
criticalEvents,
};
}

View File

@@ -0,0 +1,729 @@
/**
* Security Utilities for Input Validation and XSS Prevention
*
* Provides comprehensive input validation, sanitization, and XSS prevention
* for the ZCLAW application.
*
* Security features:
* - HTML sanitization
* - URL validation
* - Path traversal prevention
* - Input validation helpers
* - Content Security Policy helpers
*/
// ============================================================================
// HTML Sanitization
// ============================================================================
/**
* HTML entity encoding map
*/
const HTML_ENTITIES: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;',
'/': '&#x2F;',
'`': '&#x60;',
'=': '&#x3D;',
};
/**
* Escape HTML entities in a string
* Prevents XSS attacks by encoding dangerous characters
*
* @param input - The string to escape
* @returns The escaped string
*/
export function escapeHtml(input: string): string {
if (typeof input !== 'string') {
return '';
}
return input.replace(/[&<>"'`=\/]/g, char => HTML_ENTITIES[char] || char);
}
/**
* Unescape HTML entities in a string
*
* @param input - The string to unescape
* @returns The unescaped string
*/
export function unescapeHtml(input: string): string {
if (typeof input !== 'string') {
return '';
}
const textarea = document.createElement('textarea');
textarea.innerHTML = input;
return textarea.value;
}
/**
* Allowed HTML tags for safe rendering
*/
const ALLOWED_TAGS = new Set([
'p', 'br', 'b', 'i', 'u', 'strong', 'em',
'ul', 'ol', 'li', 'blockquote', 'code', 'pre',
'a', 'span', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
]);
/**
* Allowed HTML attributes
*/
const ALLOWED_ATTRIBUTES = new Set([
'href', 'title', 'class', 'id', 'target', 'rel',
]);
/**
* Sanitize HTML content for safe rendering
* Removes dangerous tags and attributes while preserving safe content
*
* @param html - The HTML string to sanitize
* @param options - Sanitization options
* @returns The sanitized HTML
*/
export function sanitizeHtml(
html: string,
options: {
allowedTags?: string[];
allowedAttributes?: string[];
allowDataAttributes?: boolean;
} = {}
): string {
if (typeof html !== 'string') {
return '';
}
const allowedTags = new Set(options.allowedTags || ALLOWED_TAGS);
const allowedAttributes = new Set(options.allowedAttributes || ALLOWED_ATTRIBUTES);
// Create a temporary container
const container = document.createElement('div');
container.innerHTML = html;
// Recursively clean elements
function cleanElement(element: Element): void {
// Remove script tags entirely
if (element.tagName.toLowerCase() === 'script') {
element.remove();
return;
}
// Remove style tags entirely
if (element.tagName.toLowerCase() === 'style') {
element.remove();
return;
}
// Remove event handlers and dangerous attributes
const attributes = Array.from(element.attributes);
for (const attr of attributes) {
const attrName = attr.name.toLowerCase();
// Remove event handlers (onclick, onload, etc.)
if (attrName.startsWith('on')) {
element.removeAttribute(attr.name);
continue;
}
// Remove javascript: URLs
if (attrName === 'href' || attrName === 'src') {
const value = attr.value.toLowerCase().trim();
if (value.startsWith('javascript:') || value.startsWith('data:text/html')) {
element.removeAttribute(attr.name);
continue;
}
}
// Remove data attributes if not allowed
if (attrName.startsWith('data-') && !options.allowDataAttributes) {
element.removeAttribute(attr.name);
continue;
}
// Remove non-allowed attributes
if (!allowedAttributes.has(attrName)) {
element.removeAttribute(attr.name);
}
}
// Remove non-allowed tags (but keep their content)
if (!allowedTags.has(element.tagName.toLowerCase())) {
const parent = element.parentNode;
while (element.firstChild) {
parent?.insertBefore(element.firstChild, element);
}
parent?.removeChild(element);
return;
}
// Recursively clean child elements
Array.from(element.children).forEach(cleanElement);
}
// Clean all elements
Array.from(container.children).forEach(cleanElement);
return container.innerHTML;
}
// ============================================================================
// URL Validation
// ============================================================================
/**
* Allowed URL schemes
*/
const ALLOWED_SCHEMES = new Set([
'http', 'https', 'mailto', 'tel', 'ftp', 'file',
]);
/**
* Validate and sanitize a URL
*
* @param url - The URL to validate
* @param options - Validation options
* @returns The validated URL or null if invalid
*/
export function validateUrl(
url: string,
options: {
allowedSchemes?: string[];
allowLocalhost?: boolean;
allowPrivateIp?: boolean;
maxLength?: number;
} = {}
): string | null {
if (typeof url !== 'string' || url.length === 0) {
return null;
}
const maxLength = options.maxLength || 2048;
if (url.length > maxLength) {
return null;
}
try {
const parsed = new URL(url);
// Check scheme
const allowedSchemes = new Set(options.allowedSchemes || ALLOWED_SCHEMES);
if (!allowedSchemes.has(parsed.protocol.replace(':', ''))) {
return null;
}
// Check for localhost
if (!options.allowLocalhost) {
if (parsed.hostname === 'localhost' ||
parsed.hostname === '127.0.0.1' ||
parsed.hostname === '[::1]') {
return null;
}
}
// Check for private IP ranges
if (!options.allowPrivateIp) {
const privateIpRegex = /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/;
if (privateIpRegex.test(parsed.hostname)) {
return null;
}
}
return parsed.toString();
} catch {
return null;
}
}
/**
* Check if a URL is safe for redirect
* Prevents open redirect vulnerabilities
*
* @param url - The URL to check
* @returns True if the URL is safe for redirect
*/
export function isSafeRedirectUrl(url: string): boolean {
if (typeof url !== 'string' || url.length === 0) {
return false;
}
// Relative URLs are generally safe
if (url.startsWith('/') && !url.startsWith('//')) {
return true;
}
// Check for javascript: protocol
const lowerUrl = url.toLowerCase().trim();
if (lowerUrl.startsWith('javascript:')) {
return false;
}
// Check for data: protocol
if (lowerUrl.startsWith('data:')) {
return false;
}
// Validate as absolute URL
const validated = validateUrl(url, { allowLocalhost: false });
return validated !== null;
}
// ============================================================================
// Path Validation
// ============================================================================
/**
* Validate a file path to prevent path traversal attacks
*
* @param path - The path to validate
* @param options - Validation options
* @returns The validated path or null if invalid
*/
export function validatePath(
path: string,
options: {
allowAbsolute?: boolean;
allowParentDir?: boolean;
maxLength?: number;
allowedExtensions?: string[];
baseDir?: string;
} = {}
): string | null {
if (typeof path !== 'string' || path.length === 0) {
return null;
}
const maxLength = options.maxLength || 4096;
if (path.length > maxLength) {
return null;
}
// Normalize path separators
let normalized = path.replace(/\\/g, '/');
// Check for null bytes
if (normalized.includes('\0')) {
return null;
}
// Check for path traversal
if (!options.allowParentDir) {
if (normalized.includes('..') || normalized.includes('./')) {
return null;
}
}
// Check for absolute paths
if (!options.allowAbsolute) {
if (normalized.startsWith('/') || /^[a-zA-Z]:/.test(normalized)) {
return null;
}
}
// Check extensions
if (options.allowedExtensions && options.allowedExtensions.length > 0) {
const ext = normalized.split('.').pop()?.toLowerCase();
if (!ext || !options.allowedExtensions.includes(ext)) {
return null;
}
}
// If baseDir is specified, ensure path is within it
if (options.baseDir) {
const baseDir = options.baseDir.replace(/\\/g, '/').replace(/\/$/, '');
if (!normalized.startsWith(baseDir)) {
// Try to resolve relative to baseDir
try {
const resolved = new URL(normalized, `file://${baseDir}/`).pathname;
if (!resolved.startsWith(baseDir)) {
return null;
}
normalized = resolved;
} catch {
return null;
}
}
}
return normalized;
}
// ============================================================================
// Input Validation Helpers
// ============================================================================
/**
* Validate an email address
*
* @param email - The email to validate
* @returns True if valid
*/
export function isValidEmail(email: string): boolean {
if (typeof email !== 'string' || email.length === 0 || email.length > 254) {
return false;
}
// RFC 5322 compliant regex (simplified)
const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
return emailRegex.test(email);
}
/**
* Validate a username
*
* @param username - The username to validate
* @param options - Validation options
* @returns True if valid
*/
export function isValidUsername(
username: string,
options: {
minLength?: number;
maxLength?: number;
allowedChars?: RegExp;
} = {}
): boolean {
const minLength = options.minLength || 3;
const maxLength = options.maxLength || 30;
const allowedChars = options.allowedChars || /^[a-zA-Z0-9_-]+$/;
if (typeof username !== 'string') {
return false;
}
if (username.length < minLength || username.length > maxLength) {
return false;
}
return allowedChars.test(username);
}
/**
* Validate a password strength
*
* @param password - The password to validate
* @param options - Validation options
* @returns Validation result with strength score
*/
export function validatePasswordStrength(
password: string,
options: {
minLength?: number;
requireUppercase?: boolean;
requireLowercase?: boolean;
requireNumber?: boolean;
requireSpecial?: boolean;
maxLength?: number;
} = {}
): {
valid: boolean;
score: number;
issues: string[];
} {
const minLength = options.minLength || 8;
const maxLength = options.maxLength || 128;
const issues: string[] = [];
let score = 0;
if (typeof password !== 'string') {
return { valid: false, score: 0, issues: ['Password must be a string'] };
}
if (password.length < minLength) {
issues.push(`Password must be at least ${minLength} characters`);
} else {
score += Math.min(password.length / 8, 3) * 10;
}
if (password.length > maxLength) {
issues.push(`Password must be at most ${maxLength} characters`);
}
if (options.requireUppercase !== false && !/[A-Z]/.test(password)) {
issues.push('Password must contain an uppercase letter');
} else if (/[A-Z]/.test(password)) {
score += 10;
}
if (options.requireLowercase !== false && !/[a-z]/.test(password)) {
issues.push('Password must contain a lowercase letter');
} else if (/[a-z]/.test(password)) {
score += 10;
}
if (options.requireNumber !== false && !/[0-9]/.test(password)) {
issues.push('Password must contain a number');
} else if (/[0-9]/.test(password)) {
score += 10;
}
if (options.requireSpecial !== false && !/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) {
issues.push('Password must contain a special character');
} else if (/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) {
score += 15;
}
// Check for common patterns
const commonPatterns = [
/123/,
/abc/i,
/qwe/i,
/password/i,
/admin/i,
/letmein/i,
];
for (const pattern of commonPatterns) {
if (pattern.test(password)) {
issues.push('Password contains a common pattern');
score -= 10;
break;
}
}
return {
valid: issues.length === 0,
score: Math.max(0, Math.min(100, score)),
issues,
};
}
/**
* Sanitize a filename
*
* @param filename - The filename to sanitize
* @returns The sanitized filename
*/
export function sanitizeFilename(filename: string): string {
if (typeof filename !== 'string') {
return '';
}
// Remove path separators
let sanitized = filename.replace(/[\/\\]/g, '_');
// Remove null bytes
sanitized = sanitized.replace(/\0/g, '');
// Remove control characters
sanitized = sanitized.replace(/[\x00-\x1f\x7f]/g, '');
// Remove dangerous characters
sanitized = sanitized.replace(/[<>:"|?*]/g, '_');
// Trim whitespace and dots
sanitized = sanitized.trim().replace(/^\.+|\.+$/g, '');
// Limit length
if (sanitized.length > 255) {
const ext = sanitized.split('.').pop();
const name = sanitized.slice(0, -(ext?.length || 0) - 1);
sanitized = name.slice(0, 250 - (ext?.length || 0)) + (ext ? `.${ext}` : '');
}
return sanitized;
}
/**
* Sanitize JSON input
* Prevents prototype pollution and other JSON-based attacks
*
* @param json - The JSON string to sanitize
* @returns The parsed and sanitized object or null if invalid
*/
export function sanitizeJson<T = unknown>(json: string): T | null {
if (typeof json !== 'string') {
return null;
}
try {
const parsed = JSON.parse(json);
// Check for prototype pollution
if (typeof parsed === 'object' && parsed !== null) {
const dangerousKeys = ['__proto__', 'constructor', 'prototype'];
for (const key of dangerousKeys) {
if (key in parsed) {
delete (parsed as Record<string, unknown>)[key];
}
}
}
return parsed as T;
} catch {
return null;
}
}
// ============================================================================
// Rate Limiting
// ============================================================================
interface RateLimitEntry {
count: number;
resetAt: number;
}
const rateLimitStore = new Map<string, RateLimitEntry>();
/**
* Check if an action is rate limited
*
* @param key - The rate limit key (e.g., 'api:username')
* @param maxAttempts - Maximum attempts allowed
* @param windowMs - Time window in milliseconds
* @returns True if rate limited (should block), false otherwise
*/
export function isRateLimited(
key: string,
maxAttempts: number,
windowMs: number
): boolean {
const now = Date.now();
const entry = rateLimitStore.get(key);
if (!entry || now > entry.resetAt) {
rateLimitStore.set(key, {
count: 1,
resetAt: now + windowMs,
});
return false;
}
if (entry.count >= maxAttempts) {
return true;
}
entry.count++;
return false;
}
/**
* Reset rate limit for a key
*
* @param key - The rate limit key to reset
*/
export function resetRateLimit(key: string): void {
rateLimitStore.delete(key);
}
/**
* Get remaining attempts for a rate-limited action
*
* @param key - The rate limit key
* @param maxAttempts - Maximum attempts allowed
* @returns Number of remaining attempts
*/
export function getRemainingAttempts(key: string, maxAttempts: number): number {
const entry = rateLimitStore.get(key);
if (!entry || Date.now() > entry.resetAt) {
return maxAttempts;
}
return Math.max(0, maxAttempts - entry.count);
}
// ============================================================================
// Content Security Policy Helpers
// ============================================================================
/**
* Generate a nonce for CSP
*
* @returns A base64-encoded nonce
*/
export function generateCspNonce(): string {
const array = crypto.getRandomValues(new Uint8Array(16));
return btoa(String.fromCharCode(...array));
}
/**
* CSP directives for secure applications
*/
export const DEFAULT_CSP_DIRECTIVES = {
'default-src': "'self'",
'script-src': "'self' 'unsafe-inline'", // Note: unsafe-inline should be avoided in production
'style-src': "'self' 'unsafe-inline'",
'img-src': "'self' data: https:",
'font-src': "'self'",
'connect-src': "'self' ws: wss:",
'frame-ancestors': "'none'",
'base-uri': "'self'",
'form-action': "'self'",
};
/**
* Build a Content Security Policy header value
*
* @param directives - CSP directives
* @returns The CSP header value
*/
export function buildCspHeader(
directives: Partial<typeof DEFAULT_CSP_DIRECTIVES> = DEFAULT_CSP_DIRECTIVES
): string {
const merged = { ...DEFAULT_CSP_DIRECTIVES, ...directives };
return Object.entries(merged)
.map(([key, value]) => `${key} ${value}`)
.join('; ');
}
// ============================================================================
// Security Headers Validation
// ============================================================================
/**
* Check if security headers are properly set (for browser environments)
*/
export function checkSecurityHeaders(): {
secure: boolean;
issues: string[];
} {
const issues: string[] = [];
// Check if running over HTTPS
if (typeof window !== 'undefined') {
if (window.location.protocol !== 'https:' && window.location.hostname !== 'localhost') {
issues.push('Application is not running over HTTPS');
}
// Check for mixed content
if (window.location.protocol === 'https:') {
// This would require DOM inspection to detect mixed content
}
}
return {
secure: issues.length === 0,
issues,
};
}
// ============================================================================
// Secure Random Generation
// ============================================================================
/**
* Generate a secure random token
*
* @param length - Token length in bytes
* @returns Hex-encoded random token
*/
export function generateSecureToken(length: number = 32): string {
const array = crypto.getRandomValues(new Uint8Array(length));
return Array.from(array)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* Generate a secure random ID
*
* @param prefix - Optional prefix
* @returns A secure random ID
*/
export function generateSecureId(prefix: string = ''): string {
const timestamp = Date.now().toString(36);
const random = generateSecureToken(8);
return prefix ? `${prefix}_${timestamp}_${random}` : `${timestamp}_${random}`;
}