Skip to main content
Glama
portel-dev

NCP - Natural Context Provider

by portel-dev
secure-credential-store.ts9.38 kB
/** * Secure Credential Store with OS Keychain Integration * * Stores credentials securely using: * 1. OS Keychain (macOS Keychain, Windows Credential Store, Linux Secret Service) * 2. Fallback to AES-256 encrypted file storage if keychain unavailable * * Profile JSON files only store credential references, not actual secrets. */ import { logger } from '../utils/logger.js'; import { getTokenStore } from './token-store.js'; import * as fs from 'fs'; import * as path from 'path'; import { homedir } from 'os'; const SERVICE_NAME = '@portel/ncp'; const CREDENTIAL_INDEX_FILE = path.join(homedir(), '.ncp', 'credentials.json'); export type CredentialType = 'bearer_token' | 'api_key' | 'oauth_token' | 'basic_auth' | 'custom'; export interface CredentialMetadata { mcpName: string; type: CredentialType; description?: string; createdAt: number; updatedAt: number; } export interface BasicAuthCredential { username: string; password: string; } /** * Secure credential storage using OS keychain with encrypted file fallback */ export class SecureCredentialStore { private keychainAvailable: boolean = false; private Entry: any = null; private index: Map<string, CredentialMetadata> = new Map(); private initPromise: Promise<void>; constructor() { this.loadIndex(); this.initPromise = this.initializeKeychain(); } /** * Ensure keychain is initialized before operations */ private async ensureInitialized(): Promise<void> { await this.initPromise; } /** * Initialize OS keychain support */ private async initializeKeychain(): Promise<void> { try { // Dynamically import keyring const keyring = await import('@napi-rs/keyring'); this.Entry = keyring.Entry; // Test if keychain is accessible const testEntry = new this.Entry(SERVICE_NAME, '_ncp_test_'); await testEntry.setPassword('test'); await testEntry.getPassword(); await testEntry.deletePassword(); this.keychainAvailable = true; logger.debug('OS keychain initialized successfully'); } catch (error) { this.keychainAvailable = false; logger.debug(`OS keychain unavailable, using encrypted file storage: ${error instanceof Error ? error.message : String(error)}`); } } /** * Load credential index from disk */ private loadIndex(): void { try { if (fs.existsSync(CREDENTIAL_INDEX_FILE)) { const data = JSON.parse(fs.readFileSync(CREDENTIAL_INDEX_FILE, 'utf-8')); this.index = new Map(Object.entries(data)); } } catch (error) { logger.error('Failed to load credential index:', error); this.index = new Map(); } } /** * Save credential index to disk */ private saveIndex(): void { try { const dir = path.dirname(CREDENTIAL_INDEX_FILE); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } const data = Object.fromEntries(this.index); fs.writeFileSync(CREDENTIAL_INDEX_FILE, JSON.stringify(data, null, 2), { mode: 0o600 }); } catch (error) { logger.error('Failed to save credential index:', error); } } /** * Generate account identifier for keychain */ private getAccountId(mcpName: string, type: CredentialType): string { return `${mcpName}:${type}`; } /** * Store credential securely */ async setCredential( mcpName: string, type: CredentialType, credential: string | BasicAuthCredential, description?: string ): Promise<boolean> { await this.ensureInitialized(); const accountId = this.getAccountId(mcpName, type); try { // Serialize credential const credentialString = typeof credential === 'string' ? credential : JSON.stringify(credential); if (this.keychainAvailable && this.Entry) { // Store in OS keychain const entry = new this.Entry(SERVICE_NAME, accountId); await entry.setPassword(credentialString); logger.debug(`Stored credential in OS keychain: ${accountId}`); } else { // Fallback to encrypted file storage const tokenStore = getTokenStore(); await tokenStore.storeToken(accountId, { access_token: credentialString, expires_in: 315360000, // 10 years (basically never expires) token_type: type }); logger.debug(`Stored credential in encrypted file: ${accountId}`); } // Update index this.index.set(accountId, { mcpName, type, description, createdAt: this.index.get(accountId)?.createdAt || Date.now(), updatedAt: Date.now() }); this.saveIndex(); return true; } catch (error) { logger.error(`Failed to store credential for ${accountId}:`, error); return false; } } /** * Retrieve credential securely */ async getCredential(mcpName: string, type: CredentialType): Promise<string | BasicAuthCredential | null> { await this.ensureInitialized(); const accountId = this.getAccountId(mcpName, type); try { let credentialString: string | null = null; if (this.keychainAvailable && this.Entry) { // Retrieve from OS keychain const entry = new this.Entry(SERVICE_NAME, accountId); credentialString = await entry.getPassword(); } else { // Fallback to encrypted file storage const tokenStore = getTokenStore(); const token = await tokenStore.getToken(accountId); credentialString = token?.access_token || null; } if (!credentialString) { return null; } // Deserialize if basic auth if (type === 'basic_auth') { try { return JSON.parse(credentialString); } catch { // If parsing fails, assume it's a plain token return credentialString; } } return credentialString; } catch (error) { logger.error(`Failed to retrieve credential for ${accountId}:`, error); return null; } } /** * Delete credential securely */ async deleteCredential(mcpName: string, type: CredentialType): Promise<boolean> { await this.ensureInitialized(); const accountId = this.getAccountId(mcpName, type); try { if (this.keychainAvailable && this.Entry) { // Delete from OS keychain const entry = new this.Entry(SERVICE_NAME, accountId); await entry.deletePassword(); } else { // Delete from encrypted file storage const tokenStore = getTokenStore(); await tokenStore.deleteToken(accountId); } // Remove from index this.index.delete(accountId); this.saveIndex(); logger.debug(`Deleted credential: ${accountId}`); return true; } catch (error) { logger.error(`Failed to delete credential for ${accountId}:`, error); return false; } } /** * List all stored credentials (metadata only) */ async listCredentials(mcpName?: string): Promise<CredentialMetadata[]> { const credentials: CredentialMetadata[] = []; for (const [accountId, metadata] of this.index.entries()) { if (!mcpName || metadata.mcpName === mcpName) { credentials.push(metadata); } } return credentials.sort((a, b) => a.mcpName.localeCompare(b.mcpName)); } /** * Check if credential exists */ async hasCredential(mcpName: string, type: CredentialType): Promise<boolean> { const accountId = this.getAccountId(mcpName, type); return this.index.has(accountId); } /** * Migrate plain-text credentials from profile to secure storage */ async migrateFromPlainText( mcpName: string, credential: { type?: string; token?: string; username?: string; password?: string } ): Promise<boolean> { await this.ensureInitialized(); try { if (credential.token) { // Determine type from auth configuration const type: CredentialType = credential.type === 'bearer' ? 'bearer_token' : 'api_key'; await this.setCredential(mcpName, type, credential.token, `Migrated from profile`); logger.info(`Migrated ${type} for ${mcpName} to secure storage`); return true; } if (credential.username && credential.password) { await this.setCredential( mcpName, 'basic_auth', { username: credential.username, password: credential.password }, 'Migrated from profile' ); logger.info(`Migrated basic auth for ${mcpName} to secure storage`); return true; } return false; } catch (error) { logger.error(`Failed to migrate credentials for ${mcpName}:`, error); return false; } } /** * Check if keychain is available */ isKeychainAvailable(): boolean { return this.keychainAvailable; } /** * Get storage method being used */ getStorageMethod(): 'keychain' | 'encrypted_file' { return this.keychainAvailable ? 'keychain' : 'encrypted_file'; } } // Singleton instance let credentialStoreInstance: SecureCredentialStore | null = null; export function getSecureCredentialStore(): SecureCredentialStore { if (!credentialStoreInstance) { credentialStoreInstance = new SecureCredentialStore(); } return credentialStoreInstance; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/portel-dev/ncp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server