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:
476
desktop/src/lib/api-key-storage.ts
Normal file
476
desktop/src/lib/api-key-storage.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user