/** * Credential Manager for DashCaddy * Unified interface for secure credential storage * Uses OS keychain when available, falls back to encrypted file storage */ const keychainManager = require('./keychain-manager'); const cryptoUtils = require('./crypto-utils'); const lockfile = require('proper-lockfile'); const fs = require('fs'); const path = require('path'); const CREDENTIALS_FILE = process.env.CREDENTIALS_FILE || path.join(__dirname, 'credentials.json'); class CredentialManager { constructor() { this.useKeychain = keychainManager.available; this.cache = new Map(); // In-memory cache with TTL this.CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes this.lockOptions = { retries: { retries: 10, minTimeout: 100, maxTimeout: 300 }, stale: 30000, }; console.log(`[CredentialManager] Initialized with ${this.useKeychain ? 'OS keychain' : 'encrypted file'} storage`); } /** * Store a credential securely * @param {string} key - Credential identifier (e.g., 'dns.token', 'cloudflare.apikey') * @param {string} value - Credential value * @param {Object} metadata - Optional metadata (non-sensitive) * @returns {Promise} Success status */ async store(key, value, metadata = {}) { try { // Validate inputs if (!key || typeof key !== 'string') { throw new Error('Credential key is required'); } if (!value || typeof value !== 'string') { throw new Error('Credential value is required'); } // Try OS keychain first if (this.useKeychain) { const success = await keychainManager.store(key, value); if (success) { // Store metadata separately in file await this.storeMetadata(key, metadata); this.cache.set(key, { value, exp: Date.now() + this.CACHE_TTL_MS }); console.log(`[CredentialManager] Stored '${key}' in OS keychain`); return true; } console.warn(`[CredentialManager] Keychain storage failed for '${key}', falling back to encrypted file`); } // Fallback to encrypted file storage await this.storeInFile(key, value, metadata); this.cache.set(key, { value, exp: Date.now() + this.CACHE_TTL_MS }); console.log(`[CredentialManager] Stored '${key}' in encrypted file`); return true; } catch (error) { console.error(`[CredentialManager] Failed to store '${key}':`, error.message); return false; } } /** * Retrieve a credential * @param {string} key - Credential identifier * @returns {Promise} Credential value or null */ async retrieve(key) { try { // Check cache first (with TTL expiration) if (this.cache.has(key)) { const cached = this.cache.get(key); if (Date.now() < cached.exp) { return cached.value; } this.cache.delete(key); } // Try OS keychain first if (this.useKeychain) { const value = await keychainManager.retrieve(key); if (value) { this.cache.set(key, { value, exp: Date.now() + this.CACHE_TTL_MS }); return value; } } // Fallback to encrypted file storage const value = await this.retrieveFromFile(key); if (value) { this.cache.set(key, { value, exp: Date.now() + this.CACHE_TTL_MS }); } return value; } catch (error) { console.error(`[CredentialManager] Failed to retrieve '${key}':`, error.message); return null; } } /** * Delete a credential * @param {string} key - Credential identifier * @returns {Promise} Success status */ async delete(key) { try { // Remove from cache this.cache.delete(key); // Try OS keychain if (this.useKeychain) { await keychainManager.delete(key); } // Remove from file storage await this.deleteFromFile(key); console.log(`[CredentialManager] Deleted '${key}'`); return true; } catch (error) { console.error(`[CredentialManager] Failed to delete '${key}':`, error.message); return false; } } /** * List all stored credential keys (not values) * @returns {Promise>} Array of credential keys */ async list() { try { const credentials = await this.loadCredentialsFile(); return Object.keys(credentials); } catch (error) { console.error('[CredentialManager] Failed to list credentials:', error.message); return []; } } /** * Get metadata for a credential * @param {string} key - Credential identifier * @returns {Promise} Metadata object or null */ async getMetadata(key) { try { const credentials = await this.loadCredentialsFile(); return credentials[key]?.metadata || null; } catch (error) { return null; } } /** * Rotate encryption key (re-encrypt all credentials with new key) * @returns {Promise} Success status */ async rotateEncryptionKey() { let release; try { console.log('[CredentialManager] Starting encryption key rotation...'); // Ensure file exists before locking this._ensureFileExists(); release = await lockfile.lock(CREDENTIALS_FILE, this.lockOptions); const data = fs.readFileSync(CREDENTIALS_FILE, 'utf8'); const credentials = JSON.parse(data); const keys = Object.keys(credentials); if (keys.length === 0) { console.log('[CredentialManager] No credentials to rotate'); return true; } // Decrypt all values with the CURRENT key first const decryptedEntries = {}; for (const key of keys) { const value = credentials[key].value; decryptedEntries[key] = { plaintext: cryptoUtils.isEncrypted(value) ? cryptoUtils.decrypt(value) : value, metadata: credentials[key].metadata, }; } // Generate new key (this replaces the cached key and saves to disk) const { oldKey } = cryptoUtils.rotateKey(); // Re-encrypt all credentials with the new key const rotated = {}; for (const key of keys) { rotated[key] = { value: cryptoUtils.encrypt(decryptedEntries[key].plaintext), metadata: decryptedEntries[key].metadata, rotatedAt: new Date().toISOString(), }; } // Save with new encryption fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(rotated, null, 2), { mode: 0o600 }); // Clear cache to force reload this.cache.clear(); console.log(`[CredentialManager] Successfully rotated ${keys.length} credentials`); return true; } catch (error) { console.error('[CredentialManager] Key rotation failed:', error.message); return false; } finally { if (release) { try { await release(); } catch (e) { /* lock will expire via stale timeout */ } } } } /** * Migrate plaintext credentials to encrypted format * @returns {Promise} Migration results */ async migrateToEncrypted() { try { let migrated = 0; let skipped = 0; await this._lockedUpdate(credentials => { for (const [key, data] of Object.entries(credentials)) { if (!cryptoUtils.isEncrypted(data.value)) { credentials[key].value = cryptoUtils.encrypt(data.value); credentials[key].migratedAt = new Date().toISOString(); migrated++; } else { skipped++; } } return credentials; }); if (migrated > 0) { this.cache.clear(); console.log(`[CredentialManager] Migrated ${migrated} plaintext credentials to encrypted format`); } return { migrated, skipped, total: migrated + skipped }; } catch (error) { console.error('[CredentialManager] Migration failed:', error.message); throw error; } } // Private methods /** * Ensure credentials file exists (needed before locking) * @private */ _ensureFileExists() { if (!fs.existsSync(CREDENTIALS_FILE)) { const dir = path.dirname(CREDENTIALS_FILE); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(CREDENTIALS_FILE, '{}', { mode: 0o600 }); } } /** * Atomic read-modify-write with file locking * @param {Function} updateFn - Receives current credentials object, returns updated object * @returns {Promise} Updated credentials * @private */ async _lockedUpdate(updateFn) { this._ensureFileExists(); let release; try { release = await lockfile.lock(CREDENTIALS_FILE, this.lockOptions); const data = fs.readFileSync(CREDENTIALS_FILE, 'utf8'); const credentials = JSON.parse(data); const updated = await updateFn(credentials); fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(updated, null, 2), { mode: 0o600 }); return updated; } catch (error) { if (error.code === 'ELOCKED') { throw new Error('Credentials file is locked by another process. Try again.'); } throw error; } finally { if (release) { try { await release(); } catch (e) { /* lock will expire via stale timeout */ } } } } async storeInFile(key, value, metadata) { await this._lockedUpdate(credentials => { credentials[key] = { value: cryptoUtils.encrypt(value), metadata, updatedAt: new Date().toISOString(), }; return credentials; }); } async retrieveFromFile(key) { const credentials = await this.loadCredentialsFile(); const data = credentials[key]; if (!data) return null; return cryptoUtils.isEncrypted(data.value) ? cryptoUtils.decrypt(data.value) : data.value; } async deleteFromFile(key) { await this._lockedUpdate(credentials => { delete credentials[key]; return credentials; }); } async storeMetadata(key, metadata) { await this._lockedUpdate(credentials => { if (!credentials[key]) { credentials[key] = { metadata }; } else { credentials[key].metadata = metadata; } credentials[key].updatedAt = new Date().toISOString(); return credentials; }); } async loadCredentialsFile() { try { if (!fs.existsSync(CREDENTIALS_FILE)) { return {}; } const data = fs.readFileSync(CREDENTIALS_FILE, 'utf8'); return JSON.parse(data); } catch (error) { console.error('[CredentialManager] Failed to load credentials file:', error.message); return {}; } } /** * Export credentials for backup (encrypted) * @returns {Promise} Encrypted backup data */ async exportBackup() { const credentials = await this.loadCredentialsFile(); const backup = { version: '1.0', exportedAt: new Date().toISOString(), credentials, }; return cryptoUtils.encrypt(JSON.stringify(backup)); } /** * Import credentials from backup * @param {string} encryptedBackup - Encrypted backup data * @returns {Promise} Success status */ async importBackup(encryptedBackup) { try { const decrypted = cryptoUtils.decrypt(encryptedBackup); const backup = JSON.parse(decrypted); if (backup.version !== '1.0') { throw new Error('Unsupported backup version'); } await this._lockedUpdate(() => backup.credentials); this.cache.clear(); console.log('[CredentialManager] Successfully imported backup'); return true; } catch (error) { console.error('[CredentialManager] Failed to import backup:', error.message); return false; } } } // Export singleton instance module.exports = new CredentialManager();