/** * Crypto Utilities for DashCaddy * Handles encryption/decryption of sensitive credentials * Uses AES-256-GCM for authenticated encryption */ const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); // Encryption settings const ALGORITHM = 'aes-256-gcm'; const KEY_LENGTH = 32; // 256 bits const IV_LENGTH = 16; // 128 bits for GCM const AUTH_TAG_LENGTH = 16; const SALT_LENGTH = 32; // Key file location (should be outside of mounted volumes for security) const KEY_FILE = process.env.ENCRYPTION_KEY_FILE || path.join(__dirname, '.encryption-key'); let encryptionKey = null; /** * Generate a new encryption key * @returns {Buffer} 32-byte encryption key */ function generateKey() { return crypto.randomBytes(KEY_LENGTH); } /** * Derive a key from a password using PBKDF2 (async, non-blocking) * @param {string} password - Password to derive key from * @param {Buffer} salt - Salt for key derivation * @returns {Promise} Derived key */ async function deriveKey(password, salt) { return new Promise((resolve, reject) => { crypto.pbkdf2(password, salt, 100000, KEY_LENGTH, 'sha512', (err, key) => { if (err) reject(err); else resolve(key); }); }); } /** * Load or create the encryption key * @returns {Buffer} The encryption key */ function loadOrCreateKey() { if (encryptionKey) { return encryptionKey; } // Check for key in environment variable first if (process.env.DASHCADDY_ENCRYPTION_KEY) { encryptionKey = Buffer.from(process.env.DASHCADDY_ENCRYPTION_KEY, 'hex'); console.log('[Crypto] Using encryption key from environment variable'); return encryptionKey; } // Try to load from file if (fs.existsSync(KEY_FILE)) { try { const keyData = fs.readFileSync(KEY_FILE, 'utf8').trim(); if (keyData.length >= 64) { encryptionKey = Buffer.from(keyData, 'hex'); console.log('[Crypto] Loaded encryption key from file'); return encryptionKey; } // File exists but key is invalid/empty - will generate new one below } catch (error) { console.error('[Crypto] Error loading key file:', error.message); } } // Generate new key encryptionKey = generateKey(); try { // Save key to file with restricted permissions fs.writeFileSync(KEY_FILE, encryptionKey.toString('hex'), { mode: 0o600 }); console.log('[Crypto] Generated and saved new encryption key'); } catch (error) { console.warn('[Crypto] Could not save key to file:', error.message); console.warn('[Crypto] Key will be regenerated on restart - credentials will need to be re-entered'); } return encryptionKey; } /** * Encrypt sensitive data * @param {string|object} data - Data to encrypt (strings or objects) * @returns {string} Encrypted data as base64 string with format: iv:authTag:ciphertext */ function encrypt(data) { const key = loadOrCreateKey(); const iv = crypto.randomBytes(IV_LENGTH); // Convert object to string if needed const plaintext = typeof data === 'object' ? JSON.stringify(data) : String(data); const cipher = crypto.createCipheriv(ALGORITHM, key, iv); let encrypted = cipher.update(plaintext, 'utf8', 'base64'); encrypted += cipher.final('base64'); const authTag = cipher.getAuthTag(); // Return format: iv:authTag:ciphertext (all base64) return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`; } /** * Decrypt encrypted data * @param {string} encryptedData - Encrypted string in format iv:authTag:ciphertext * @returns {string} Decrypted plaintext */ function decrypt(encryptedData) { const key = loadOrCreateKey(); const parts = encryptedData.split(':'); if (parts.length !== 3) { throw new Error('Invalid encrypted data format'); } const iv = Buffer.from(parts[0], 'base64'); const authTag = Buffer.from(parts[1], 'base64'); const ciphertext = parts[2]; const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); decipher.setAuthTag(authTag); let decrypted = decipher.update(ciphertext, 'base64', 'utf8'); decrypted += decipher.final('utf8'); return decrypted; } /** * Check if a string is encrypted (has our format) * @param {string} data - Data to check * @returns {boolean} True if data appears to be encrypted */ function isEncrypted(data) { if (typeof data !== 'string') return false; const parts = data.split(':'); if (parts.length !== 3) return false; // Check if parts look like base64 try { Buffer.from(parts[0], 'base64'); Buffer.from(parts[1], 'base64'); return true; } catch { return false; } } /** * Encrypt specific fields in an object * @param {object} obj - Object with fields to encrypt * @param {string[]} fields - Array of field names to encrypt * @returns {object} Object with specified fields encrypted */ function encryptFields(obj, fields) { const result = { ...obj }; for (const field of fields) { if (result[field] !== undefined && result[field] !== null) { // Don't double-encrypt if (!isEncrypted(result[field])) { result[field] = encrypt(result[field]); } } } result._encrypted = true; // Mark as encrypted result._encryptedFields = fields; return result; } /** * Decrypt specific fields in an object * @param {object} obj - Object with encrypted fields * @param {string[]} fields - Array of field names to decrypt (optional, uses _encryptedFields if available) * @returns {object} Object with specified fields decrypted */ function decryptFields(obj, fields = null) { if (!obj._encrypted) { return obj; // Not encrypted, return as-is } const fieldsToDecrypt = fields || obj._encryptedFields || []; const result = { ...obj }; for (const field of fieldsToDecrypt) { if (result[field] !== undefined && isEncrypted(result[field])) { try { result[field] = decrypt(result[field]); } catch (error) { console.error(`[Crypto] Failed to decrypt field '${field}':`, error.message); // Leave the field as-is if decryption fails } } } // Remove encryption markers from result delete result._encrypted; delete result._encryptedFields; return result; } /** * Migrate plaintext credentials to encrypted format * @param {object} credentials - Credentials object that may or may not be encrypted * @param {string[]} sensitiveFields - Fields that should be encrypted * @returns {object} Encrypted credentials object */ function migrateToEncrypted(credentials, sensitiveFields) { if (credentials._encrypted) { return credentials; // Already encrypted } console.log('[Crypto] Migrating plaintext credentials to encrypted format'); return encryptFields(credentials, sensitiveFields); } /** * Read and decrypt a credentials file * @param {string} filePath - Path to credentials file * @param {string[]} sensitiveFields - Fields that are encrypted * @returns {object|null} Decrypted credentials or null if file doesn't exist */ function readEncryptedFile(filePath, sensitiveFields = ['password', 'token', 'apiKey', 'secret']) { if (!fs.existsSync(filePath)) { return null; } try { const data = fs.readFileSync(filePath, 'utf8'); const parsed = JSON.parse(data); // Check if this is encrypted data if (parsed._encrypted) { return decryptFields(parsed, sensitiveFields); } // Plain text data - migrate it console.log(`[Crypto] Found plaintext data in ${filePath}, will encrypt on next save`); return parsed; } catch (error) { console.error(`[Crypto] Error reading ${filePath}:`, error.message); return null; } } /** * Encrypt and write credentials to a file * @param {string} filePath - Path to credentials file * @param {object} credentials - Credentials to save * @param {string[]} sensitiveFields - Fields to encrypt */ function writeEncryptedFile(filePath, credentials, sensitiveFields = ['password', 'token', 'apiKey', 'secret']) { const encrypted = encryptFields(credentials, sensitiveFields); fs.writeFileSync(filePath, JSON.stringify(encrypted, null, 2), 'utf8'); console.log(`[Crypto] Saved encrypted credentials to ${filePath}`); } /** * Rotate the encryption key — generates a new key and returns both old and new * @returns {{ oldKey: Buffer, newKey: Buffer }} Old and new key pair * @throws {Error} If new key cannot be saved to disk */ function rotateKey() { const oldKey = loadOrCreateKey(); // Ensure we have the current key loaded const newKey = generateKey(); try { fs.writeFileSync(KEY_FILE, newKey.toString('hex'), { mode: 0o600 }); } catch (error) { throw new Error(`Failed to save new encryption key: ${error.message}`); } // Only update the cached key after file write succeeds encryptionKey = newKey; return { oldKey, newKey }; } /** * Decrypt data using a specific key (for key rotation) * @param {string} encryptedData - Encrypted string in format iv:authTag:ciphertext * @param {Buffer} key - The key to decrypt with * @returns {string} Decrypted plaintext */ function decryptWithKey(encryptedData, key) { const parts = encryptedData.split(':'); if (parts.length !== 3) { throw new Error('Invalid encrypted data format'); } const iv = Buffer.from(parts[0], 'base64'); const authTag = Buffer.from(parts[1], 'base64'); const ciphertext = parts[2]; const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); decipher.setAuthTag(authTag); let decrypted = decipher.update(ciphertext, 'base64', 'utf8'); decrypted += decipher.final('utf8'); return decrypted; } // Initialize key on module load loadOrCreateKey(); /** * Clear the cached encryption key so it reloads from file on next use. * Called after restoring an encryption key from backup. */ function clearCachedKey() { encryptionKey = null; } module.exports = { encrypt, decrypt, isEncrypted, encryptFields, decryptFields, migrateToEncrypted, readEncryptedFile, writeEncryptedFile, loadOrCreateKey, deriveKey, rotateKey, decryptWithKey, clearCachedKey, };