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