research-cli
Version:
AI-powered research assistant with web search capabilities and beautiful terminal UI
369 lines • 14.5 kB
JavaScript
/**
* 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