/** * 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'); // First-run bootstrap: if .bak doesn't exist yet, write the current // key to it. This ensures the silent recovery path is available from // the very next restart without requiring an explicit rotateKey(). if (!fs.existsSync(KEY_FILE + '.bak')) { try { fs.writeFileSync(KEY_FILE + '.bak', keyData, { mode: 0o600 }); console.log(`[Crypto] Seeded ${KEY_FILE}.bak with current key for future fallback`); } catch (e) { console.warn('[Crypto] Could not seed .bak key file:', e.message); } } // Try fallback to .bak key if primary can't decrypt existing credentials. // This handles the "container recreate rotated the key" case where the // backup key on disk is the ORIGINAL key that can still read the // bind-mounted /app/data/credentials.json written before the upgrade. if (fs.existsSync(KEY_FILE + '.bak')) { try { const backupData = fs.readFileSync(KEY_FILE + '.bak', 'utf8').trim(); if (backupData.length >= 64) { encryptionKey = tryFallbackToBackupKey(Buffer.from(keyData, 'hex'), Buffer.from(backupData, 'hex')); } } catch (e) { console.warn('[Crypto] Could not check backup key:', e.message); } } 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; } /** * If the primary key fails to decrypt any existing credentials, try the backup * key. This is the silent recovery path: if a container recreate replaced * .encryption-key with a fresh one but left .encryption-key.bak (the previous * key), the old key can still decrypt the bind-mounted credentials.json and * the user stays logged in without ever noticing. * * Called only at startup when both key files exist. Returns the working key * (either primary or backup). If neither works, returns the primary (existing * behavior — `retrieve()` will surface "unreadable" via credential-manager.diagnose). * * @param {Buffer} primaryKey - key from .encryption-key * @param {Buffer} backupKey - key from .encryption-key.bak * @returns {Buffer} the key that should be used */ function tryFallbackToBackupKey(primaryKey, backupKey) { const CREDENTIALS_FILE = process.env.CREDENTIALS_FILE || require('path').join(__dirname, 'credentials.json'); if (!fs.existsSync(CREDENTIALS_FILE)) return primaryKey; let credentials; try { credentials = JSON.parse(fs.readFileSync(CREDENTIALS_FILE, 'utf8')); } catch { return primaryKey; } // Find the first encrypted entry to probe const probeEntry = Object.values(credentials).find(v => v && v.value && isEncrypted(v.value)); if (!probeEntry) return primaryKey; const tryDecrypt = (key) => { const parts = probeEntry.value.split(':'); if (parts.length !== 3) return false; try { const iv = Buffer.from(parts[0], 'base64'); const tag = Buffer.from(parts[1], 'base64'); const ct = Buffer.from(parts[2], 'base64'); const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); decipher.setAuthTag(tag); Buffer.concat([decipher.update(ct), decipher.final()]); return true; } catch { return false; } }; if (tryDecrypt(primaryKey)) return primaryKey; if (tryDecrypt(backupKey)) { console.warn( '[Crypto] Primary encryption key failed to decrypt credentials; ' + 'fell back to .encryption-key.bak. The current primary key was set ' + 'without preserving the original. Consider rotating the key explicitly ' + 'via the credential-manager API to avoid this warning next restart.' ); return backupKey; } return primaryKey; // neither works — credential-manager.diagnose() will report 'unreadable' } /** * 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(); // Save the OLD key to .bak BEFORE swapping the primary. This gives the // startup-time fallback a way to recover the previous key if a future // restart loses the new one (e.g. another accidental recreate). The .bak // file is overwritten on each rotate so it always holds the previous key, // not an ever-accumulating history. try { fs.writeFileSync(KEY_FILE + '.bak', oldKey.toString('hex'), { mode: 0o600 }); } catch (error) { console.warn(`[Crypto] Could not save backup key to ${KEY_FILE}.bak:`, error.message); } 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; } // Lazy-initialize: key is loaded on first encrypt/decrypt call. // Do NOT call loadOrCreateKey() here — during Docker build, it would generate // a key baked into the image that conflicts with the mounted production key. /** * 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 };