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;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
412
desktop/src/lib/encrypted-chat-storage.ts
Normal file
412
desktop/src/lib/encrypted-chat-storage.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* Encrypted Chat History Storage
|
||||
*
|
||||
* Provides encrypted persistence for chat messages and conversations.
|
||||
* Uses AES-256-GCM encryption with the secure storage infrastructure.
|
||||
*
|
||||
* Security features:
|
||||
* - All chat data encrypted at rest
|
||||
* - Master key stored in OS keychain when available
|
||||
* - Automatic key derivation with key rotation support
|
||||
* - Secure backup to encrypted localStorage
|
||||
*/
|
||||
|
||||
import {
|
||||
deriveKey,
|
||||
encryptObject,
|
||||
decryptObject,
|
||||
generateMasterKey,
|
||||
hashSha256,
|
||||
isValidEncryptedData,
|
||||
clearKeyCache,
|
||||
} from './crypto-utils';
|
||||
import { secureStorage, isSecureStorageAvailable } from './secure-storage';
|
||||
|
||||
// Storage keys
|
||||
const CHAT_DATA_KEY = 'zclaw_chat_data';
|
||||
const CHAT_KEY_IDENTIFIER = 'zclaw_chat_master_key';
|
||||
const CHAT_KEY_HASH_KEY = 'zclaw_chat_key_hash';
|
||||
const ENCRYPTED_PREFIX = 'enc_chat_';
|
||||
|
||||
// Encryption version for future migrations
|
||||
const STORAGE_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Storage metadata for integrity verification
|
||||
*/
|
||||
interface StorageMetadata {
|
||||
version: number;
|
||||
keyHash: string;
|
||||
createdAt: number;
|
||||
lastAccessedAt: number;
|
||||
encryptedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypted storage container
|
||||
*/
|
||||
interface EncryptedContainer {
|
||||
metadata: StorageMetadata;
|
||||
data: string; // Encrypted payload
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached crypto key for chat encryption
|
||||
*/
|
||||
let cachedChatKey: CryptoKey | null = null;
|
||||
let keyHash: string | null = null;
|
||||
|
||||
/**
|
||||
* Get or initialize the master encryption key for chat storage
|
||||
* Uses OS keychain when available, falls back to encrypted localStorage
|
||||
*/
|
||||
async function getOrCreateMasterKey(): Promise<string> {
|
||||
// Try to get existing key from secure storage
|
||||
const existingKey = await secureStorage.get(CHAT_KEY_IDENTIFIER);
|
||||
if (existingKey) {
|
||||
return existingKey;
|
||||
}
|
||||
|
||||
// Generate new master key
|
||||
const newKey = generateMasterKey();
|
||||
|
||||
// Store in secure storage (keychain or encrypted localStorage)
|
||||
await secureStorage.set(CHAT_KEY_IDENTIFIER, newKey);
|
||||
|
||||
// Store hash for integrity verification
|
||||
const keyHashValue = await hashSha256(newKey);
|
||||
localStorage.setItem(CHAT_KEY_HASH_KEY, keyHashValue);
|
||||
|
||||
console.log('[EncryptedChatStorage] Generated new master key');
|
||||
return newKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the derived encryption key for chat data
|
||||
*/
|
||||
async function getChatEncryptionKey(): Promise<CryptoKey> {
|
||||
if (cachedChatKey && keyHash) {
|
||||
// Verify key hash matches
|
||||
const storedHash = localStorage.getItem(CHAT_KEY_HASH_KEY);
|
||||
if (storedHash === keyHash) {
|
||||
return cachedChatKey;
|
||||
}
|
||||
// Hash mismatch - clear cache and re-derive
|
||||
console.warn('[EncryptedChatStorage] Key hash mismatch, re-deriving key');
|
||||
cachedChatKey = null;
|
||||
keyHash = null;
|
||||
}
|
||||
|
||||
const masterKey = await getOrCreateMasterKey();
|
||||
cachedChatKey = await deriveKey(masterKey);
|
||||
keyHash = await hashSha256(masterKey);
|
||||
|
||||
return cachedChatKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize encrypted chat storage
|
||||
* Called during app startup
|
||||
*/
|
||||
export async function initializeEncryptedChatStorage(): Promise<void> {
|
||||
try {
|
||||
// Pre-load the encryption key
|
||||
await getChatEncryptionKey();
|
||||
|
||||
// Check if we have existing encrypted data to migrate
|
||||
const legacyData = localStorage.getItem('zclaw-chat-storage');
|
||||
if (legacyData && !localStorage.getItem(ENCRYPTED_PREFIX + 'migrated')) {
|
||||
await migrateFromLegacyStorage(legacyData);
|
||||
localStorage.setItem(ENCRYPTED_PREFIX + 'migrated', 'true');
|
||||
console.log('[EncryptedChatStorage] Migrated legacy data');
|
||||
}
|
||||
|
||||
console.log('[EncryptedChatStorage] Initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('[EncryptedChatStorage] Initialization failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate data from legacy unencrypted storage
|
||||
*/
|
||||
async function migrateFromLegacyStorage(legacyData: string): Promise<void> {
|
||||
try {
|
||||
const parsed = JSON.parse(legacyData);
|
||||
if (parsed?.state?.conversations) {
|
||||
await saveConversations(parsed.state.conversations);
|
||||
console.log(`[EncryptedChatStorage] Migrated ${parsed.state.conversations.length} conversations`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[EncryptedChatStorage] Migration failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save conversations to encrypted storage
|
||||
*
|
||||
* @param conversations - Array of conversation objects
|
||||
*/
|
||||
export async function saveConversations(conversations: unknown[]): Promise<void> {
|
||||
if (!conversations || conversations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const key = await getChatEncryptionKey();
|
||||
const now = Date.now();
|
||||
|
||||
// Create container with metadata
|
||||
const container: EncryptedContainer = {
|
||||
metadata: {
|
||||
version: STORAGE_VERSION,
|
||||
keyHash: keyHash || '',
|
||||
createdAt: now,
|
||||
lastAccessedAt: now,
|
||||
encryptedAt: now,
|
||||
},
|
||||
data: '', // Will be set after encryption
|
||||
};
|
||||
|
||||
// Encrypt the conversations array
|
||||
const encrypted = await encryptObject(conversations, key);
|
||||
container.data = JSON.stringify(encrypted);
|
||||
|
||||
// Store the encrypted container
|
||||
localStorage.setItem(CHAT_DATA_KEY, JSON.stringify(container));
|
||||
|
||||
console.log(`[EncryptedChatStorage] Saved ${conversations.length} conversations`);
|
||||
} catch (error) {
|
||||
console.error('[EncryptedChatStorage] Failed to save conversations:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load conversations from encrypted storage
|
||||
*
|
||||
* @returns Array of conversation objects or empty array if none exist
|
||||
*/
|
||||
export async function loadConversations<T = unknown>(): Promise<T[]> {
|
||||
try {
|
||||
const stored = localStorage.getItem(CHAT_DATA_KEY);
|
||||
if (!stored) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const container: EncryptedContainer = JSON.parse(stored);
|
||||
|
||||
// Validate container structure
|
||||
if (!container.data || !container.metadata) {
|
||||
console.warn('[EncryptedChatStorage] Invalid container structure');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check version compatibility
|
||||
if (container.metadata.version > STORAGE_VERSION) {
|
||||
console.error('[EncryptedChatStorage] Incompatible storage version');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Parse and decrypt the data
|
||||
const encryptedData = JSON.parse(container.data);
|
||||
if (!isValidEncryptedData(encryptedData)) {
|
||||
console.error('[EncryptedChatStorage] Invalid encrypted data');
|
||||
return [];
|
||||
}
|
||||
|
||||
const key = await getChatEncryptionKey();
|
||||
const conversations = await decryptObject<T[]>(encryptedData, key);
|
||||
|
||||
// Update last accessed time
|
||||
container.metadata.lastAccessedAt = Date.now();
|
||||
localStorage.setItem(CHAT_DATA_KEY, JSON.stringify(container));
|
||||
|
||||
console.log(`[EncryptedChatStorage] Loaded ${conversations.length} conversations`);
|
||||
return conversations;
|
||||
} catch (error) {
|
||||
console.error('[EncryptedChatStorage] Failed to load conversations:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all chat data from storage
|
||||
*/
|
||||
export async function clearAllChatData(): Promise<void> {
|
||||
try {
|
||||
// Clear encrypted data
|
||||
localStorage.removeItem(CHAT_DATA_KEY);
|
||||
localStorage.removeItem(ENCRYPTED_PREFIX + 'migrated');
|
||||
|
||||
// Clear the master key from secure storage
|
||||
await secureStorage.delete(CHAT_KEY_IDENTIFIER);
|
||||
localStorage.removeItem(CHAT_KEY_HASH_KEY);
|
||||
|
||||
// Clear cached key
|
||||
cachedChatKey = null;
|
||||
keyHash = null;
|
||||
clearKeyCache();
|
||||
|
||||
console.log('[EncryptedChatStorage] Cleared all chat data');
|
||||
} catch (error) {
|
||||
console.error('[EncryptedChatStorage] Failed to clear chat data:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export encrypted chat data for backup
|
||||
* Returns encrypted blob that can be imported later
|
||||
*
|
||||
* @returns Base64-encoded encrypted backup
|
||||
*/
|
||||
export async function exportEncryptedBackup(): Promise<string> {
|
||||
try {
|
||||
const stored = localStorage.getItem(CHAT_DATA_KEY);
|
||||
if (!stored) {
|
||||
throw new Error('No chat data to export');
|
||||
}
|
||||
|
||||
// The data is already encrypted, just return it
|
||||
const container: EncryptedContainer = JSON.parse(stored);
|
||||
const exportData = {
|
||||
type: 'zclaw_chat_backup',
|
||||
version: STORAGE_VERSION,
|
||||
exportedAt: Date.now(),
|
||||
container,
|
||||
};
|
||||
|
||||
return btoa(JSON.stringify(exportData));
|
||||
} catch (error) {
|
||||
console.error('[EncryptedChatStorage] Export failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import encrypted chat data from backup
|
||||
*
|
||||
* @param backupData - Base64-encoded encrypted backup
|
||||
* @param merge - Whether to merge with existing data (default: false, replaces)
|
||||
*/
|
||||
export async function importEncryptedBackup(
|
||||
backupData: string,
|
||||
merge: boolean = false
|
||||
): Promise<void> {
|
||||
try {
|
||||
const decoded = JSON.parse(atob(backupData));
|
||||
|
||||
// Validate backup format
|
||||
if (decoded.type !== 'zclaw_chat_backup') {
|
||||
throw new Error('Invalid backup format');
|
||||
}
|
||||
|
||||
if (decoded.version > STORAGE_VERSION) {
|
||||
throw new Error('Incompatible backup version');
|
||||
}
|
||||
|
||||
if (merge) {
|
||||
// Load existing conversations and merge
|
||||
const existing = await loadConversations();
|
||||
const imported = await decryptObject<unknown[]>(
|
||||
JSON.parse(decoded.container.data),
|
||||
await getChatEncryptionKey()
|
||||
);
|
||||
const merged = [...existing, ...imported];
|
||||
await saveConversations(merged);
|
||||
} else {
|
||||
// Replace existing data
|
||||
localStorage.setItem(CHAT_DATA_KEY, JSON.stringify(decoded.container));
|
||||
}
|
||||
|
||||
console.log('[EncryptedChatStorage] Import completed successfully');
|
||||
} catch (error) {
|
||||
console.error('[EncryptedChatStorage] Import failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if encrypted storage is being used
|
||||
*/
|
||||
export async function isEncryptedStorageActive(): Promise<boolean> {
|
||||
const stored = localStorage.getItem(CHAT_DATA_KEY);
|
||||
if (!stored) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const container: EncryptedContainer = JSON.parse(stored);
|
||||
return container.metadata?.version === STORAGE_VERSION;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage statistics
|
||||
*/
|
||||
export async function getStorageStats(): Promise<{
|
||||
encrypted: boolean;
|
||||
usingKeychain: boolean;
|
||||
conversationCount: number;
|
||||
storageSize: number;
|
||||
}> {
|
||||
const stored = localStorage.getItem(CHAT_DATA_KEY);
|
||||
let conversationCount = 0;
|
||||
let encrypted = false;
|
||||
|
||||
if (stored) {
|
||||
try {
|
||||
const container: EncryptedContainer = JSON.parse(stored);
|
||||
encrypted = container.metadata?.version === STORAGE_VERSION;
|
||||
|
||||
// Count conversations without full decryption
|
||||
const conversations = await loadConversations();
|
||||
conversationCount = conversations.length;
|
||||
} catch {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
encrypted,
|
||||
usingKeychain: await isSecureStorageAvailable(),
|
||||
conversationCount,
|
||||
storageSize: stored ? new Blob([stored]).size : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate encryption key
|
||||
* Re-encrypts all data with a new key
|
||||
*/
|
||||
export async function rotateEncryptionKey(): Promise<void> {
|
||||
try {
|
||||
// Load existing data
|
||||
const conversations = await loadConversations();
|
||||
|
||||
// Clear old key
|
||||
await secureStorage.delete(CHAT_KEY_IDENTIFIER);
|
||||
localStorage.removeItem(CHAT_KEY_HASH_KEY);
|
||||
cachedChatKey = null;
|
||||
keyHash = null;
|
||||
clearKeyCache();
|
||||
|
||||
// Generate new key (will be created on next getChatEncryptionKey call)
|
||||
const newKey = generateMasterKey();
|
||||
await secureStorage.set(CHAT_KEY_IDENTIFIER, newKey);
|
||||
const newKeyHash = await hashSha256(newKey);
|
||||
localStorage.setItem(CHAT_KEY_HASH_KEY, newKeyHash);
|
||||
|
||||
// Re-save all data with new key
|
||||
await saveConversations(conversations);
|
||||
|
||||
console.log('[EncryptedChatStorage] Encryption key rotated successfully');
|
||||
} catch (error) {
|
||||
console.error('[EncryptedChatStorage] Key rotation failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
564
desktop/src/lib/security-audit.ts
Normal file
564
desktop/src/lib/security-audit.ts
Normal 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;
|
||||
}
|
||||
241
desktop/src/lib/security-index.ts
Normal file
241
desktop/src/lib/security-index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
729
desktop/src/lib/security-utils.ts
Normal file
729
desktop/src/lib/security-utils.ts
Normal 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> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
'/': '/',
|
||||
'`': '`',
|
||||
'=': '=',
|
||||
};
|
||||
|
||||
/**
|
||||
* 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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user