Files
dashcaddy/dashcaddy-api/src/security/crypto-utils.js
T
Hermes 5efacd11e8
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
fix(security): close rotateEncryptionKey crash window with in-process key rollback (DC-107) [glm-grade=A]
If the atomicWriteJSON of rotated credentials failed after rotateKey() had
already persisted+cached the new key, the on-disk key could no longer decrypt
the on-disk credentials.json (permanent loss on restart). Catch-path now
restores the old key via new cryptoUtils.restoreKey() (canonical atomic-write,
0600, hex-validated) while still holding the proper-lockfile. Double-failure
(rollback throws) is contained and the lock is still released. Hard-crash
mid-rollback is covered by the existing .bak startup fallback.

Judge: GLM-4.6 stand-in, round-1 grade A, 0 blocking, 1 LOW polish (folded).
URN urn:ump:z2sz3x6abtcffpqyde2l2ssq34vy47t5h2gbmkr3wtmfxwkerinq
Tests: 121 suites / 2785 green (6 consecutive runs pre-fold; suite re-run post-fold).
2026-08-23 04:40:41 -07:00

464 lines
15 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');
const platformPaths = require('../../platform-paths');
const { log } = require('../utils/logging');
// 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;
// Resolve the encryption key alongside the canonical services/config state.
// platformPaths.dataDir supports both current /app/data mounts and legacy /app
// single-file mounts, and does not change when this module moves within src/.
function resolveKeyFile() {
if (process.env.ENCRYPTION_KEY_FILE) {
return process.env.ENCRYPTION_KEY_FILE;
}
return path.join(platformPaths.dataDir, '.encryption-key');
}
const KEY_FILE = resolveKeyFile();
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');
log.info('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');
log.info('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 });
log.info('crypto', 'Seeded .bak key file for future fallback');
} catch (e) {
log.warn('crypto', 'Could not seed .bak key file', { error: 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) {
log.warn('crypto', 'Could not check backup key', { error: e.message });
}
}
return encryptionKey;
}
// File exists but key is invalid/empty - will generate new one below
} catch (error) {
log.error('crypto', error, { operation: 'loadKey' });
}
}
// Generate new key
encryptionKey = generateKey();
try {
// Save key to file with restricted permissions
fs.writeFileSync(KEY_FILE, encryptionKey.toString('hex'), { mode: 0o600 });
log.info('crypto', 'Generated and saved new encryption key');
} catch (error) {
log.warn('crypto', 'Could not save key to file', { error: error.message });
log.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 ||
path.join(platformPaths.dataDir, '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)) {
log.warn('crypto', 'Primary encryption key failed to decrypt credentials; fell back to .encryption-key.bak. Consider rotating the key explicitly via the credential-manager API.');
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) {
log.error('crypto', error, { field, operation: 'decryptField' });
// 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
}
log.info('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
log.info('crypto', 'Found plaintext data', { filePath });
return parsed;
} catch (error) {
log.error('crypto', error, { filePath, operation: 'readFile' });
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');
log.info('crypto', 'Saved encrypted credentials', { 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) {
log.warn('crypto', 'Could not save backup key', { error: 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;
}
/**
* Restore the encryption key to a previous value (in-process rollback).
* Writes `oldKeyHex` back to KEY_FILE atomically (DC-107: same canonical
* atomic-write path as every other state file — tmp + fsync + rename, mode
* 0600) and clears the cached key so the next operation reloads from disk.
* Used when a write (e.g. atomicWriteJSON of rotated credentials) fails
* after rotateKey() has already swapped the on-disk key and in-memory cache.
* @param {string} oldKeyHex - Previous key as hex string (32 bytes = 64 hex chars)
* @returns {string} the final path (KEY_FILE)
* @throws {Error} If oldKeyHex is not a 64-char hex string or the write fails
*/
function restoreKey(oldKeyHex) {
if (typeof oldKeyHex !== 'string' || !/^[0-9a-fA-F]{64}$/.test(oldKeyHex)) {
throw new Error('restoreKey: expected 64-char hex string (32-byte key)');
}
const { atomicWriteFile } = require('../utils/atomic-write');
atomicWriteFile(KEY_FILE, oldKeyHex, { mode: 0o600 });
clearCachedKey();
return KEY_FILE;
}
module.exports = {
encrypt,
decrypt,
isEncrypted,
encryptFields,
decryptFields,
migrateToEncrypted,
readEncryptedFile,
writeEncryptedFile,
loadOrCreateKey,
deriveKey,
rotateKey,
decryptWithKey,
clearCachedKey,
restoreKey
};