feat(providers): implement secure API key storage and provider management
Add complete provider configuration system with the following features: - Secure API key storage using Electron's safeStorage encryption - Provider CRUD operations with IPC handlers - Lazy-loaded electron-store for ESM compatibility - Provider settings UI component with add/edit/delete functionality - API key masking for display (shows first/last 4 chars) - Basic API key format validation per provider type - Default provider selection - Provider enable/disable toggle New files: - electron/utils/secure-storage.ts: Encrypted key storage and provider config - src/stores/providers.ts: Zustand store for provider state - src/components/settings/ProvidersSettings.tsx: Provider management UI
This commit is contained in:
@@ -4,6 +4,21 @@
|
||||
*/
|
||||
import { ipcMain, BrowserWindow, shell, dialog, app } from 'electron';
|
||||
import { GatewayManager } from '../gateway/manager';
|
||||
import {
|
||||
storeApiKey,
|
||||
getApiKey,
|
||||
deleteApiKey,
|
||||
hasApiKey,
|
||||
saveProvider,
|
||||
getProvider,
|
||||
getAllProviders,
|
||||
deleteProvider,
|
||||
setDefaultProvider,
|
||||
getDefaultProvider,
|
||||
getAllProvidersWithKeyInfo,
|
||||
isEncryptionAvailable,
|
||||
type ProviderConfig,
|
||||
} from '../utils/secure-storage';
|
||||
|
||||
/**
|
||||
* Register all IPC handlers
|
||||
@@ -15,6 +30,9 @@ export function registerIpcHandlers(
|
||||
// Gateway handlers
|
||||
registerGatewayHandlers(gatewayManager, mainWindow);
|
||||
|
||||
// Provider handlers
|
||||
registerProviderHandlers();
|
||||
|
||||
// Shell handlers
|
||||
registerShellHandlers();
|
||||
|
||||
@@ -136,6 +154,136 @@ function registerGatewayHandlers(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider-related IPC handlers
|
||||
*/
|
||||
function registerProviderHandlers(): void {
|
||||
// Check if encryption is available
|
||||
ipcMain.handle('provider:encryptionAvailable', () => {
|
||||
return isEncryptionAvailable();
|
||||
});
|
||||
|
||||
// Get all providers with key info
|
||||
ipcMain.handle('provider:list', async () => {
|
||||
return await getAllProvidersWithKeyInfo();
|
||||
});
|
||||
|
||||
// Get a specific provider
|
||||
ipcMain.handle('provider:get', async (_, providerId: string) => {
|
||||
return await getProvider(providerId);
|
||||
});
|
||||
|
||||
// Save a provider configuration
|
||||
ipcMain.handle('provider:save', async (_, config: ProviderConfig, apiKey?: string) => {
|
||||
try {
|
||||
// Save the provider config
|
||||
await saveProvider(config);
|
||||
|
||||
// Store the API key if provided
|
||||
if (apiKey) {
|
||||
await storeApiKey(config.id, apiKey);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a provider
|
||||
ipcMain.handle('provider:delete', async (_, providerId: string) => {
|
||||
try {
|
||||
await deleteProvider(providerId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
// Update API key for a provider
|
||||
ipcMain.handle('provider:setApiKey', async (_, providerId: string, apiKey: string) => {
|
||||
try {
|
||||
await storeApiKey(providerId, apiKey);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
// Delete API key for a provider
|
||||
ipcMain.handle('provider:deleteApiKey', async (_, providerId: string) => {
|
||||
try {
|
||||
await deleteApiKey(providerId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
// Check if a provider has an API key
|
||||
ipcMain.handle('provider:hasApiKey', async (_, providerId: string) => {
|
||||
return await hasApiKey(providerId);
|
||||
});
|
||||
|
||||
// Get the actual API key (for internal use only - be careful!)
|
||||
ipcMain.handle('provider:getApiKey', async (_, providerId: string) => {
|
||||
return await getApiKey(providerId);
|
||||
});
|
||||
|
||||
// Set default provider
|
||||
ipcMain.handle('provider:setDefault', async (_, providerId: string) => {
|
||||
try {
|
||||
await setDefaultProvider(providerId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
// Get default provider
|
||||
ipcMain.handle('provider:getDefault', async () => {
|
||||
return await getDefaultProvider();
|
||||
});
|
||||
|
||||
// Validate API key by making a test request (simulated for now)
|
||||
ipcMain.handle('provider:validateKey', async (_, providerId: string, apiKey: string) => {
|
||||
// In a real implementation, this would make a test API call to the provider
|
||||
// For now, we'll just do basic format validation
|
||||
try {
|
||||
// Basic validation based on provider type
|
||||
const provider = await getProvider(providerId);
|
||||
if (!provider) {
|
||||
return { valid: false, error: 'Provider not found' };
|
||||
}
|
||||
|
||||
switch (provider.type) {
|
||||
case 'anthropic':
|
||||
if (!apiKey.startsWith('sk-ant-')) {
|
||||
return { valid: false, error: 'Anthropic keys should start with sk-ant-' };
|
||||
}
|
||||
break;
|
||||
case 'openai':
|
||||
if (!apiKey.startsWith('sk-')) {
|
||||
return { valid: false, error: 'OpenAI keys should start with sk-' };
|
||||
}
|
||||
break;
|
||||
case 'google':
|
||||
if (apiKey.length < 20) {
|
||||
return { valid: false, error: 'Google API key seems too short' };
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Simulate API validation delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
return { valid: true };
|
||||
} catch (error) {
|
||||
return { valid: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell-related IPC handlers
|
||||
*/
|
||||
|
||||
@@ -52,6 +52,17 @@ const electronAPI = {
|
||||
'env:setApiKey',
|
||||
'env:deleteApiKey',
|
||||
// Provider
|
||||
'provider:encryptionAvailable',
|
||||
'provider:list',
|
||||
'provider:get',
|
||||
'provider:save',
|
||||
'provider:delete',
|
||||
'provider:setApiKey',
|
||||
'provider:deleteApiKey',
|
||||
'provider:hasApiKey',
|
||||
'provider:getApiKey',
|
||||
'provider:setDefault',
|
||||
'provider:getDefault',
|
||||
'provider:validateKey',
|
||||
// Cron
|
||||
'cron:list',
|
||||
|
||||
273
electron/utils/secure-storage.ts
Normal file
273
electron/utils/secure-storage.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Secure Storage Utility
|
||||
* Uses Electron's safeStorage for encrypting sensitive data like API keys
|
||||
*/
|
||||
import { safeStorage } from 'electron';
|
||||
|
||||
// Lazy-load electron-store (ESM module)
|
||||
let store: any = null;
|
||||
let providerStore: any = null;
|
||||
|
||||
async function getStore() {
|
||||
if (!store) {
|
||||
const Store = (await import('electron-store')).default;
|
||||
store = new Store({
|
||||
name: 'clawx-secure',
|
||||
defaults: {
|
||||
encryptedKeys: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
async function getProviderStore() {
|
||||
if (!providerStore) {
|
||||
const Store = (await import('electron-store')).default;
|
||||
providerStore = new Store({
|
||||
name: 'clawx-providers',
|
||||
defaults: {
|
||||
providers: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
return providerStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider configuration
|
||||
*/
|
||||
export interface ProviderConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'anthropic' | 'openai' | 'google' | 'ollama' | 'custom';
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if encryption is available
|
||||
*/
|
||||
export function isEncryptionAvailable(): boolean {
|
||||
return safeStorage.isEncryptionAvailable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an API key securely
|
||||
*/
|
||||
export async function storeApiKey(providerId: string, apiKey: string): Promise<boolean> {
|
||||
try {
|
||||
const s = await getStore();
|
||||
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
console.warn('Encryption not available, storing key in plain text');
|
||||
// Fallback to plain storage (not recommended for production)
|
||||
const keys = s.get('encryptedKeys') as Record<string, string>;
|
||||
keys[providerId] = Buffer.from(apiKey).toString('base64');
|
||||
s.set('encryptedKeys', keys);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Encrypt the API key
|
||||
const encrypted = safeStorage.encryptString(apiKey);
|
||||
const keys = s.get('encryptedKeys') as Record<string, string>;
|
||||
keys[providerId] = encrypted.toString('base64');
|
||||
s.set('encryptedKeys', keys);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to store API key:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve an API key
|
||||
*/
|
||||
export async function getApiKey(providerId: string): Promise<string | null> {
|
||||
try {
|
||||
const s = await getStore();
|
||||
const keys = s.get('encryptedKeys') as Record<string, string>;
|
||||
const encryptedBase64 = keys[providerId];
|
||||
|
||||
if (!encryptedBase64) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
// Fallback for plain storage
|
||||
return Buffer.from(encryptedBase64, 'base64').toString('utf-8');
|
||||
}
|
||||
|
||||
// Decrypt the API key
|
||||
const encrypted = Buffer.from(encryptedBase64, 'base64');
|
||||
return safeStorage.decryptString(encrypted);
|
||||
} catch (error) {
|
||||
console.error('Failed to retrieve API key:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an API key
|
||||
*/
|
||||
export async function deleteApiKey(providerId: string): Promise<boolean> {
|
||||
try {
|
||||
const s = await getStore();
|
||||
const keys = s.get('encryptedKeys') as Record<string, string>;
|
||||
delete keys[providerId];
|
||||
s.set('encryptedKeys', keys);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to delete API key:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an API key exists for a provider
|
||||
*/
|
||||
export async function hasApiKey(providerId: string): Promise<boolean> {
|
||||
const s = await getStore();
|
||||
const keys = s.get('encryptedKeys') as Record<string, string>;
|
||||
return providerId in keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all provider IDs that have stored keys
|
||||
*/
|
||||
export async function listStoredKeyIds(): Promise<string[]> {
|
||||
const s = await getStore();
|
||||
const keys = s.get('encryptedKeys') as Record<string, string>;
|
||||
return Object.keys(keys);
|
||||
}
|
||||
|
||||
// ==================== Provider Configuration ====================
|
||||
|
||||
/**
|
||||
* Save a provider configuration
|
||||
*/
|
||||
export async function saveProvider(config: ProviderConfig): Promise<void> {
|
||||
const s = await getProviderStore();
|
||||
const providers = s.get('providers') as Record<string, ProviderConfig>;
|
||||
providers[config.id] = config;
|
||||
s.set('providers', providers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a provider configuration
|
||||
*/
|
||||
export async function getProvider(providerId: string): Promise<ProviderConfig | null> {
|
||||
const s = await getProviderStore();
|
||||
const providers = s.get('providers') as Record<string, ProviderConfig>;
|
||||
return providers[providerId] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all provider configurations
|
||||
*/
|
||||
export async function getAllProviders(): Promise<ProviderConfig[]> {
|
||||
const s = await getProviderStore();
|
||||
const providers = s.get('providers') as Record<string, ProviderConfig>;
|
||||
return Object.values(providers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a provider configuration
|
||||
*/
|
||||
export async function deleteProvider(providerId: string): Promise<boolean> {
|
||||
try {
|
||||
// Delete the API key first
|
||||
await deleteApiKey(providerId);
|
||||
|
||||
// Delete the provider config
|
||||
const s = await getProviderStore();
|
||||
const providers = s.get('providers') as Record<string, ProviderConfig>;
|
||||
delete providers[providerId];
|
||||
s.set('providers', providers);
|
||||
|
||||
// Clear default if this was the default
|
||||
if (s.get('defaultProvider') === providerId) {
|
||||
s.delete('defaultProvider');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to delete provider:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the default provider
|
||||
*/
|
||||
export async function setDefaultProvider(providerId: string): Promise<void> {
|
||||
const s = await getProviderStore();
|
||||
s.set('defaultProvider', providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default provider
|
||||
*/
|
||||
export async function getDefaultProvider(): Promise<string | undefined> {
|
||||
const s = await getProviderStore();
|
||||
return s.get('defaultProvider') as string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider with masked key info (for UI display)
|
||||
*/
|
||||
export async function getProviderWithKeyInfo(providerId: string): Promise<(ProviderConfig & { hasKey: boolean; keyMasked: string | null }) | null> {
|
||||
const provider = await getProvider(providerId);
|
||||
if (!provider) return null;
|
||||
|
||||
const apiKey = await getApiKey(providerId);
|
||||
let keyMasked: string | null = null;
|
||||
|
||||
if (apiKey) {
|
||||
// Show first 4 and last 4 characters
|
||||
if (apiKey.length > 12) {
|
||||
keyMasked = `${apiKey.substring(0, 4)}${'*'.repeat(apiKey.length - 8)}${apiKey.substring(apiKey.length - 4)}`;
|
||||
} else {
|
||||
keyMasked = '*'.repeat(apiKey.length);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...provider,
|
||||
hasKey: !!apiKey,
|
||||
keyMasked,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all providers with key info (for UI display)
|
||||
*/
|
||||
export async function getAllProvidersWithKeyInfo(): Promise<Array<ProviderConfig & { hasKey: boolean; keyMasked: string | null }>> {
|
||||
const providers = await getAllProviders();
|
||||
const results: Array<ProviderConfig & { hasKey: boolean; keyMasked: string | null }> = [];
|
||||
|
||||
for (const provider of providers) {
|
||||
const apiKey = await getApiKey(provider.id);
|
||||
let keyMasked: string | null = null;
|
||||
|
||||
if (apiKey) {
|
||||
if (apiKey.length > 12) {
|
||||
keyMasked = `${apiKey.substring(0, 4)}${'*'.repeat(apiKey.length - 8)}${apiKey.substring(apiKey.length - 4)}`;
|
||||
} else {
|
||||
keyMasked = '*'.repeat(apiKey.length);
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
...provider,
|
||||
hasKey: !!apiKey,
|
||||
keyMasked,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
Reference in New Issue
Block a user