Files
dashcaddy/dashcaddy-api/crypto-utils.js
Sami 6979302fb7 Fix 7 critical security bugs and 1 high-severity data loss bug
- CSRF: HMAC-signed double-submit cookie (server-bound, not raw compare)
- Keychain: execFileSync with arg arrays to prevent command injection
- Caddy config: always use structured generation, never accept raw config
- Templates: replace {{GENERATED_SECRET}} with crypto.randomBytes
- Caddyfile removal: move regex inside ctx.caddy.modify() to fix TOCTOU race
- Credentials: proper-lockfile for all file operations, fix key rotation
  to decrypt with old key before generating new key
- Service removal: filter by ID only, not AND with appTemplate

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:08:30 -08:00

332 lines
9.7 KiB
JavaScript

/**
* 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<Buffer>} 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();
module.exports = {
encrypt,
decrypt,
isEncrypted,
encryptFields,
decryptFields,
migrateToEncrypted,
readEncryptedFile,
writeEncryptedFile,
loadOrCreateKey,
deriveKey,
rotateKey,
decryptWithKey
};