Merge krystie-improvements into main
Resolves 24 conflicts between Hermes (DC-008/009/010 + response-helper
envelope standardization) and Krystie (DC-005 src/ refactor path fixes,
DC-006 TOTP integration, DC-007 new test suites, cloud backup
destinations).
Conflict resolutions:
- src/utils/logging.js: took ours (consumers depend on logError/
safeErrorMessage/createLogger exports)
- src/config/site.js: merged (her factored validateAndLogConfig +
applyConfigFields helpers)
- src/context/dns.js: took hers (admin/readonly role iteration for
write operations)
- src/utilities/backup-
manager.js: took hers (Dropbox/WebDAV/SFTP cloud feature)
- status/dist/*, status/
sw.js: took hers (minified bundles + newer SW cache)
Additional fix (post-merge regression):
- src/monitoring/health-checker.js: fixed DC-005 path miss —
'require(./platform-paths)' → 'require(../../platform-paths)'
Test status: 921/922 passing. One known failure in logging.test.js
(async file-handle timing) tracked as follow-up.
This commit is contained in:
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// Resolve encryption key file path — supports both standard install (/app/.encryption-key)
|
||||
// and custom deployments with consolidated data directory (/app/data/.encryption-key)
|
||||
function resolveKeyFile() {
|
||||
if (process.env.ENCRYPTION_KEY_FILE) {
|
||||
return process.env.ENCRYPTION_KEY_FILE;
|
||||
}
|
||||
const candidates = [
|
||||
path.join(__dirname, '.encryption-key'),
|
||||
path.join(__dirname, 'data', '.encryption-key'),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
// No existing file — return standard path so first load creates it there
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
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');
|
||||
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
|
||||
};
|
||||
Reference in New Issue
Block a user