Files
zclaw_openfang/desktop/src/lib/api-key-storage.ts
iven 5fdf96c3f5 chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成
包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、
文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
2026-03-29 10:46:41 +08:00

475 lines
11 KiB
TypeScript

/**
* 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, generateRandomString } 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
* @internal Only use for testing purposes
*/
export function generateTestApiKey(type: ApiKeyType): string {
const rules = KEY_VALIDATION_RULES[type];
const length = rules.minLength + 10;
let key = '';
if (rules.prefix && rules.prefix.length > 0) {
key = rules.prefix[0];
}
const remainingLength = length - key.length;
key += generateRandomString(remainingLength);
return key;
}