UNPKG

research-cli

Version:

AI-powered research assistant with web search capabilities and beautiful terminal UI

369 lines 14.5 kB
/** * Secure API key management using system keyring * Supports multiple providers with fallback mechanisms */ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import * as keytar from "keytar"; import { InteractiveUI } from "../ui/interactive.js"; export class SecureAPIKeyManager { static SERVICE_NAME = "research-cli"; static FALLBACK_CONFIG_PATH = path.join(os.homedir(), ".research-cli", "encrypted-credentials.json"); keyringAvailable = null; fallbackWarningShown = false; /** * Test if system keyring is available and writable */ async testKeyringAvailability() { if (this.keyringAvailable !== null) { return { available: this.keyringAvailable, backend: this.keyringAvailable ? "system" : "fallback", }; } try { const testKey = `${SecureAPIKeyManager.SERVICE_NAME}-test`; const testValue = `test-value-${Date.now()}`; // Try to set a test value await keytar.setPassword(testKey, "test", testValue); // Try to retrieve it const retrieved = await keytar.getPassword(testKey, "test"); // Clean up try { await keytar.deletePassword(testKey, "test"); } catch { // Ignore cleanup errors } this.keyringAvailable = retrieved === testValue; return { available: this.keyringAvailable, backend: this.keyringAvailable ? "system" : "fallback", }; } catch (error) { this.keyringAvailable = false; return { available: false, backend: "fallback", error: error instanceof Error ? error.message : "Unknown error", }; } } /** * Store API key securely for a provider */ async storeApiKey(provider, apiKey) { const testResult = await this.testKeyringAvailability(); if (!testResult.available) { return this.fallbackStore(provider, { apiKey }); } try { await keytar.setPassword(SecureAPIKeyManager.SERVICE_NAME, `${provider}-api-key`, apiKey); console.log(`✅ API key stored securely for ${provider}`); return true; } catch (error) { console.error(`❌ Could not store API key: ${error instanceof Error ? error.message : "Unknown error"}`); return this.fallbackStore(provider, { apiKey }); } } /** * Retrieve API key for a provider */ async getApiKey(provider) { const testResult = await this.testKeyringAvailability(); if (!testResult.available) { const credentials = await this.fallbackGet(provider); return credentials?.apiKey || null; } try { return await keytar.getPassword(SecureAPIKeyManager.SERVICE_NAME, `${provider}-api-key`); } catch (error) { console.error(`Warning: Could not access keyring: ${error instanceof Error ? error.message : "Unknown error"}`); const credentials = await this.fallbackGet(provider); return credentials?.apiKey || null; } } /** * Remove API key for a provider */ async removeApiKey(provider) { const testResult = await this.testKeyringAvailability(); let success = false; if (testResult.available) { try { const result = await keytar.deletePassword(SecureAPIKeyManager.SERVICE_NAME, `${provider}-api-key`); if (result) { console.log(`✅ API key removed for ${provider}`); success = true; } } catch (error) { console.warn(`Warning: Could not remove from keyring: ${error instanceof Error ? error.message : "Unknown error"}`); } } // Also try fallback removal const fallbackResult = await this.fallbackRemove(provider); if (success || fallbackResult) { if (!success) { console.log(`✅ API key removed for ${provider} (from fallback storage)`); } return true; } console.log(`ℹ️ No API key found for ${provider}`); return false; } /** * List providers with stored API keys */ async listStoredProviders() { const providers = []; const allProviders = [ "openai", "claude", "gemini", "perplexity", ]; for (const provider of allProviders) { const apiKey = await this.getApiKey(provider); if (apiKey) { providers.push(provider); } } return providers; } /** * Store full credentials for a provider (API key + optional extras) */ async storeCredentials(provider, credentials) { const testResult = await this.testKeyringAvailability(); if (!testResult.available) { return this.fallbackStore(provider, credentials); } try { // Store main API key await keytar.setPassword(SecureAPIKeyManager.SERVICE_NAME, `${provider}-api-key`, credentials.apiKey); // Store optional fields if (credentials.organizationId) { await keytar.setPassword(SecureAPIKeyManager.SERVICE_NAME, `${provider}-org-id`, credentials.organizationId); } if (credentials.baseUrl) { await keytar.setPassword(SecureAPIKeyManager.SERVICE_NAME, `${provider}-base-url`, credentials.baseUrl); } console.log(`✅ Credentials stored securely for ${provider}`); return true; } catch (error) { console.error(`❌ Could not store credentials: ${error instanceof Error ? error.message : "Unknown error"}`); return this.fallbackStore(provider, credentials); } } /** * Get full credentials for a provider */ async getCredentials(provider) { const testResult = await this.testKeyringAvailability(); if (!testResult.available) { return this.fallbackGet(provider); } try { const apiKey = await keytar.getPassword(SecureAPIKeyManager.SERVICE_NAME, `${provider}-api-key`); if (!apiKey) { // Try fallback return this.fallbackGet(provider); } const credentials = { apiKey }; // Get optional fields const orgId = await keytar.getPassword(SecureAPIKeyManager.SERVICE_NAME, `${provider}-org-id`); if (orgId) credentials.organizationId = orgId; const baseUrl = await keytar.getPassword(SecureAPIKeyManager.SERVICE_NAME, `${provider}-base-url`); if (baseUrl) credentials.baseUrl = baseUrl; return credentials; } catch (error) { console.error(`Warning: Could not access keyring: ${error instanceof Error ? error.message : "Unknown error"}`); return this.fallbackGet(provider); } } /** * Migrate API keys from a config file to secure storage */ async migrateFromConfig(configFilePath) { const result = { migrated: 0, errors: [] }; try { const configContent = await fs.readFile(configFilePath, "utf-8"); const config = JSON.parse(configContent); for (const [provider, settings] of Object.entries(config)) { if (typeof settings === "object" && settings !== null && "apiKey" in settings) { const providerKey = provider; const apiKey = settings.apiKey; if (apiKey && typeof apiKey === "string") { const success = await this.storeApiKey(providerKey, apiKey); if (success) { result.migrated++; } else { result.errors.push(`Failed to migrate ${provider} API key`); } } } } if (result.migrated > 0) { console.log(`✅ Migrated ${result.migrated} API keys to secure storage`); } } catch (error) { const errorMsg = error instanceof Error ? error.message : "Unknown error"; result.errors.push(`Failed to read config file: ${errorMsg}`); } return result; } /** * Interactive API key setup for a provider */ async setupApiKey(provider) { try { // Use the enhanced interactive UI const providerUrls = { openai: "https://platform.openai.com/api-keys", claude: "https://console.anthropic.com/", gemini: "https://makersuite.google.com/app/apikey", perplexity: "https://www.perplexity.ai/settings/api", }; const apiKey = await InteractiveUI.getApiKey(provider, providerUrls[provider]); if (!apiKey) { return null; } // Validate API key format if (!this.validateApiKeyFormat(provider, apiKey)) { console.log(`\n⚠️ Warning: API key format doesn't match expected pattern for ${provider}`); const shouldContinue = await InteractiveUI.confirmAction("Continue anyway?", false); if (!shouldContinue) { return null; } } // Ask if they want to save it securely const shouldSave = await InteractiveUI.confirmSaveKey(); if (shouldSave) { const saved = await this.storeApiKey(provider, apiKey); if (!saved) { console.log("\n⚠️ Could not save to secure storage. You can set an environment variable instead."); } } return apiKey; } catch (error) { if (error && typeof error === "object" && "code" in error && error.code === "USER_ABORT") { return null; // User cancelled } throw error; } } /** * Test API key with a simple request (if possible) */ async testApiKey(provider, apiKey) { const testKey = apiKey || (await this.getApiKey(provider)); if (!testKey) { return { valid: false, error: "No API key found" }; } // Basic format validation if (!this.validateApiKeyFormat(provider, testKey)) { return { valid: false, error: "Invalid API key format" }; } // For now, just return format validation // In a full implementation, you could make actual API calls to test return { valid: true }; } // Private helper methods // Removed getProviderInstructions since it's now handled by InteractiveUI validateApiKeyFormat(provider, apiKey) { const patterns = { openai: /^sk-(proj-)?[a-zA-Z0-9]{20,}$/, claude: /^sk-ant-[a-zA-Z0-9-_]{50,}$/, gemini: /^[a-zA-Z0-9-_]{39}$/, perplexity: /^pplx-[a-zA-Z0-9]{32,}$/, }; const pattern = patterns[provider]; return pattern ? pattern.test(apiKey) : true; } // Fallback storage methods (encrypted JSON file) async ensureFallbackDir() { const dir = path.dirname(SecureAPIKeyManager.FALLBACK_CONFIG_PATH); try { await fs.mkdir(dir, { recursive: true }); } catch (_error) { // Directory might already exist } } async fallbackStore(provider, credentials) { if (!this.fallbackWarningShown) { console.log("⚠️ System keyring unavailable, using encrypted fallback storage"); this.fallbackWarningShown = true; } try { await this.ensureFallbackDir(); let config = {}; try { const content = await fs.readFile(SecureAPIKeyManager.FALLBACK_CONFIG_PATH, "utf-8"); const decrypted = Buffer.from(content, "base64").toString("utf-8"); config = JSON.parse(decrypted); } catch { // File doesn't exist or is invalid, start fresh } config[provider] = credentials; // Simple encryption (in production, use proper encryption) const encrypted = Buffer.from(JSON.stringify(config)).toString("base64"); await fs.writeFile(SecureAPIKeyManager.FALLBACK_CONFIG_PATH, encrypted); console.log(`✅ Credentials stored in fallback storage for ${provider}`); return true; } catch (error) { console.error(`❌ Could not save to fallback storage: ${error instanceof Error ? error.message : "Unknown error"}`); return false; } } async fallbackGet(provider) { try { const content = await fs.readFile(SecureAPIKeyManager.FALLBACK_CONFIG_PATH, "utf-8"); const decrypted = Buffer.from(content, "base64").toString("utf-8"); const config = JSON.parse(decrypted); return config[provider] || null; } catch { return null; } } async fallbackRemove(provider) { try { const content = await fs.readFile(SecureAPIKeyManager.FALLBACK_CONFIG_PATH, "utf-8"); const decrypted = Buffer.from(content, "base64").toString("utf-8"); const config = JSON.parse(decrypted); if (!(provider in config)) { return false; } delete config[provider]; const encrypted = Buffer.from(JSON.stringify(config)).toString("base64"); await fs.writeFile(SecureAPIKeyManager.FALLBACK_CONFIG_PATH, encrypted); return true; } catch { return false; } } } // Global instance export const apiKeyManager = new SecureAPIKeyManager(); //# sourceMappingURL=keyring-manager.js.map