credential-manager.js: 20 console calls → log.info/warn/error tagged 'cred'. auth-manager.js: 20 console calls → log.info/error tagged 'auth'. Mixed-content strings extracted into meta payload (key, keyId, operation, etc). 1539/1539 Jest tests pass. ESLint: 4 pre-existing warnings unchanged.
452 lines
14 KiB
JavaScript
452 lines
14 KiB
JavaScript
/**
|
|
* Credential Manager for DashCaddy
|
|
* Unified interface for secure credential storage
|
|
* Uses OS keychain when available, falls back to encrypted file storage
|
|
*/
|
|
|
|
const keychainManager = require('../security/keychain-manager');
|
|
const cryptoUtils = require('../security/crypto-utils');
|
|
const lockfile = require('proper-lockfile');
|
|
const fs = require('fs');
|
|
const { log } = require('../utils/logging');
|
|
const path = require('path');
|
|
const platformPaths = require('../../platform-paths');
|
|
|
|
// Resolve credentials alongside the canonical services/config state. This supports
|
|
// both current /app/data mounts and legacy /app single-file mounts, and remains
|
|
// stable if the module moves within src/.
|
|
function resolveCredentialsFile() {
|
|
if (process.env.CREDENTIALS_FILE) {
|
|
return process.env.CREDENTIALS_FILE;
|
|
}
|
|
return path.join(platformPaths.dataDir, 'credentials.json');
|
|
}
|
|
|
|
const CREDENTIALS_FILE = resolveCredentialsFile();
|
|
|
|
class CredentialManager {
|
|
constructor() {
|
|
this.useKeychain = keychainManager.available;
|
|
this.cache = new Map(); // In-memory cache with TTL
|
|
this.CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
this.lockOptions = {
|
|
retries: { retries: 10, minTimeout: 100, maxTimeout: 300 },
|
|
stale: 30000
|
|
};
|
|
|
|
log.info('cred', 'Initialized', { storage: this.useKeychain ? 'keychain' : 'file' });
|
|
}
|
|
|
|
/**
|
|
* Store a credential securely
|
|
* @param {string} key - Credential identifier (e.g., 'dns.token', 'cloudflare.apikey')
|
|
* @param {string} value - Credential value
|
|
* @param {Object} metadata - Optional metadata (non-sensitive)
|
|
* @returns {Promise<boolean>} Success status
|
|
*/
|
|
async store(key, value, metadata = {}) {
|
|
try {
|
|
// Validate inputs
|
|
if (!key || typeof key !== 'string') {
|
|
throw new Error('Credential key is required');
|
|
}
|
|
if (!value || typeof value !== 'string') {
|
|
throw new Error('Credential value is required');
|
|
}
|
|
|
|
// Try OS keychain first
|
|
if (this.useKeychain) {
|
|
const success = await keychainManager.store(key, value);
|
|
if (success) {
|
|
// Store metadata separately in file
|
|
await this.storeMetadata(key, metadata);
|
|
this.cache.set(key, { value, exp: Date.now() + this.CACHE_TTL_MS });
|
|
log.info('cred', 'Stored credential in keychain', { key });
|
|
return true;
|
|
}
|
|
log.warn('cred', 'Keychain storage failed, falling back to encrypted file', { key });
|
|
}
|
|
|
|
// Fallback to encrypted file storage
|
|
await this.storeInFile(key, value, metadata);
|
|
this.cache.set(key, { value, exp: Date.now() + this.CACHE_TTL_MS });
|
|
log.info('cred', 'Stored credential in encrypted file', { key });
|
|
return true;
|
|
} catch (error) {
|
|
log.error('cred', error, { key, operation: 'store' });
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieve a credential
|
|
* @param {string} key - Credential identifier
|
|
* @returns {Promise<string|null>} Credential value or null
|
|
*/
|
|
async retrieve(key) {
|
|
try {
|
|
// Check cache first (with TTL expiration)
|
|
if (this.cache.has(key)) {
|
|
const cached = this.cache.get(key);
|
|
if (Date.now() < cached.exp) {
|
|
return cached.value;
|
|
}
|
|
this.cache.delete(key);
|
|
}
|
|
|
|
// Try OS keychain first
|
|
if (this.useKeychain) {
|
|
const value = await keychainManager.retrieve(key);
|
|
if (value) {
|
|
this.cache.set(key, { value, exp: Date.now() + this.CACHE_TTL_MS });
|
|
return value;
|
|
}
|
|
}
|
|
|
|
// Fallback to encrypted file storage
|
|
const value = await this.retrieveFromFile(key);
|
|
if (value) {
|
|
this.cache.set(key, { value, exp: Date.now() + this.CACHE_TTL_MS });
|
|
}
|
|
return value;
|
|
} catch (error) {
|
|
log.error('cred', error, { key, operation: 'retrieve' });
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a credential
|
|
* @param {string} key - Credential identifier
|
|
* @returns {Promise<boolean>} Success status
|
|
*/
|
|
async delete(key) {
|
|
try {
|
|
// Remove from cache
|
|
this.cache.delete(key);
|
|
|
|
// Try OS keychain
|
|
if (this.useKeychain) {
|
|
await keychainManager.delete(key);
|
|
}
|
|
|
|
// Remove from file storage
|
|
await this.deleteFromFile(key);
|
|
|
|
log.info('cred', 'Deleted credential', { key });
|
|
return true;
|
|
} catch (error) {
|
|
log.error('cred', error, { key, operation: 'delete' });
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List all stored credential keys (not values)
|
|
* @returns {Promise<Array<string>>} Array of credential keys
|
|
*/
|
|
async list() {
|
|
try {
|
|
const credentials = await this.loadCredentialsFile();
|
|
return Object.keys(credentials);
|
|
} catch (error) {
|
|
log.error('cred', error, { operation: 'list' });
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get metadata for a credential
|
|
* @param {string} key - Credential identifier
|
|
* @returns {Promise<Object|null>} Metadata object or null
|
|
*/
|
|
async getMetadata(key) {
|
|
try {
|
|
const credentials = await this.loadCredentialsFile();
|
|
return credentials[key]?.metadata || null;
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rotate encryption key (re-encrypt all credentials with new key)
|
|
* @returns {Promise<boolean>} Success status
|
|
*/
|
|
async rotateEncryptionKey() {
|
|
let release;
|
|
try {
|
|
log.info('cred', 'Starting encryption key rotation');
|
|
|
|
// Ensure file exists before locking
|
|
this._ensureFileExists();
|
|
release = await lockfile.lock(CREDENTIALS_FILE, this.lockOptions);
|
|
|
|
const data = fs.readFileSync(CREDENTIALS_FILE, 'utf8');
|
|
const credentials = JSON.parse(data);
|
|
const keys = Object.keys(credentials);
|
|
|
|
if (keys.length === 0) {
|
|
log.info('cred', 'No credentials to rotate');
|
|
return true;
|
|
}
|
|
|
|
// Decrypt all values with the CURRENT key first
|
|
const decryptedEntries = {};
|
|
for (const key of keys) {
|
|
const value = credentials[key].value;
|
|
decryptedEntries[key] = {
|
|
plaintext: cryptoUtils.isEncrypted(value) ? cryptoUtils.decrypt(value) : value,
|
|
metadata: credentials[key].metadata
|
|
};
|
|
}
|
|
|
|
// Generate new key (this replaces the cached key and saves to disk)
|
|
const { oldKey } = cryptoUtils.rotateKey();
|
|
|
|
// Re-encrypt all credentials with the new key
|
|
const rotated = {};
|
|
for (const key of keys) {
|
|
rotated[key] = {
|
|
value: cryptoUtils.encrypt(decryptedEntries[key].plaintext),
|
|
metadata: decryptedEntries[key].metadata,
|
|
rotatedAt: new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
// Save with new encryption
|
|
fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(rotated, null, 2), { mode: 0o600 });
|
|
|
|
// Clear cache to force reload
|
|
this.cache.clear();
|
|
|
|
log.info('cred', 'Rotated credentials', { count: keys.length });
|
|
return true;
|
|
} catch (error) {
|
|
log.error('cred', error, { operation: 'rotate' });
|
|
return false;
|
|
} finally {
|
|
if (release) {
|
|
try { await release(); } catch (e) { /* lock will expire via stale timeout */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Migrate plaintext credentials to encrypted format
|
|
* @returns {Promise<Object>} Migration results
|
|
*/
|
|
async migrateToEncrypted() {
|
|
try {
|
|
let migrated = 0;
|
|
let skipped = 0;
|
|
|
|
await this._lockedUpdate(credentials => {
|
|
for (const [key, data] of Object.entries(credentials)) {
|
|
if (!cryptoUtils.isEncrypted(data.value)) {
|
|
credentials[key].value = cryptoUtils.encrypt(data.value);
|
|
credentials[key].migratedAt = new Date().toISOString();
|
|
migrated++;
|
|
} else {
|
|
skipped++;
|
|
}
|
|
}
|
|
return credentials;
|
|
});
|
|
|
|
if (migrated > 0) {
|
|
this.cache.clear();
|
|
log.info('cred', 'Migrated plaintext credentials', { count: migrated });
|
|
}
|
|
|
|
return { migrated, skipped, total: migrated + skipped };
|
|
} catch (error) {
|
|
log.error('cred', error, { operation: 'migrate' });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Private methods
|
|
|
|
/**
|
|
* Ensure credentials file exists (needed before locking)
|
|
* @private
|
|
*/
|
|
_ensureFileExists() {
|
|
if (!fs.existsSync(CREDENTIALS_FILE)) {
|
|
const dir = path.dirname(CREDENTIALS_FILE);
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
fs.writeFileSync(CREDENTIALS_FILE, '{}', { mode: 0o600 });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Atomic read-modify-write with file locking
|
|
* @param {Function} updateFn - Receives current credentials object, returns updated object
|
|
* @returns {Promise<Object>} Updated credentials
|
|
* @private
|
|
*/
|
|
async _lockedUpdate(updateFn) {
|
|
this._ensureFileExists();
|
|
let release;
|
|
try {
|
|
release = await lockfile.lock(CREDENTIALS_FILE, this.lockOptions);
|
|
const data = fs.readFileSync(CREDENTIALS_FILE, 'utf8');
|
|
const credentials = JSON.parse(data);
|
|
const updated = await updateFn(credentials);
|
|
fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(updated, null, 2), { mode: 0o600 });
|
|
return updated;
|
|
} catch (error) {
|
|
if (error.code === 'ELOCKED') {
|
|
throw new Error('Credentials file is locked by another process. Try again.');
|
|
}
|
|
throw error;
|
|
} finally {
|
|
if (release) {
|
|
try { await release(); } catch (e) { /* lock will expire via stale timeout */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
async storeInFile(key, value, metadata) {
|
|
await this._lockedUpdate(credentials => {
|
|
credentials[key] = {
|
|
value: cryptoUtils.encrypt(value),
|
|
metadata,
|
|
updatedAt: new Date().toISOString()
|
|
};
|
|
return credentials;
|
|
});
|
|
}
|
|
|
|
async retrieveFromFile(key) {
|
|
const credentials = await this.loadCredentialsFile();
|
|
const data = credentials[key];
|
|
if (!data) return null;
|
|
|
|
return cryptoUtils.isEncrypted(data.value)
|
|
? cryptoUtils.decrypt(data.value)
|
|
: data.value;
|
|
}
|
|
|
|
/**
|
|
* Retrieve a credential with diagnostic info on failure.
|
|
*
|
|
* Used by the TOTP recovery flow: when a user is locked out and the secret
|
|
* can't be decrypted (e.g. encryption key was rotated by a container
|
|
* recreate), we need to distinguish "no secret was ever set" from "secret
|
|
* is on disk but unreadable" so the UI can show a useful next step.
|
|
*
|
|
* Status codes:
|
|
* 'ok' — value decrypted / returned as-is
|
|
* 'missing' — key is not present in the store at all
|
|
* 'unreadable' — key is present but decryption failed (key mismatch / corruption)
|
|
* 'malformed' — entry exists but value is not in expected encrypted format
|
|
*
|
|
* @param {string} key - Credential identifier
|
|
* @returns {Promise<{ status: string, value: string|null, error?: string }>}
|
|
*/
|
|
async diagnose(key) {
|
|
try {
|
|
const credentials = await this.loadCredentialsFile();
|
|
const data = credentials[key];
|
|
if (!data) return { status: 'missing', value: null };
|
|
|
|
if (!cryptoUtils.isEncrypted(data.value)) {
|
|
// Plaintext entry — return as-is
|
|
return { status: 'ok', value: data.value };
|
|
}
|
|
|
|
try {
|
|
const decrypted = cryptoUtils.decrypt(data.value);
|
|
return { status: 'ok', value: decrypted };
|
|
} catch (decryptErr) {
|
|
// Most common cause: the encryption key on disk is different from
|
|
// the key that originally encrypted this entry (rotated by a
|
|
// container recreate that didn't preserve CREDENTIALS_FILE env).
|
|
log.warn('cred', 'Credential present but cannot be decrypted (likely encryption-key mismatch)', { key, error: decryptErr.message });
|
|
return { status: 'unreadable', value: null, error: decryptErr.message };
|
|
}
|
|
} catch (err) {
|
|
log.error('cred', err, { key, operation: 'diagnose' });
|
|
return { status: 'malformed', value: null, error: err.message };
|
|
}
|
|
}
|
|
|
|
async deleteFromFile(key) {
|
|
await this._lockedUpdate(credentials => {
|
|
delete credentials[key];
|
|
return credentials;
|
|
});
|
|
}
|
|
|
|
async storeMetadata(key, metadata) {
|
|
await this._lockedUpdate(credentials => {
|
|
if (!credentials[key]) {
|
|
credentials[key] = { metadata };
|
|
} else {
|
|
credentials[key].metadata = metadata;
|
|
}
|
|
credentials[key].updatedAt = new Date().toISOString();
|
|
return credentials;
|
|
});
|
|
}
|
|
|
|
async loadCredentialsFile() {
|
|
try {
|
|
if (!fs.existsSync(CREDENTIALS_FILE)) {
|
|
return {};
|
|
}
|
|
const data = fs.readFileSync(CREDENTIALS_FILE, 'utf8');
|
|
return JSON.parse(data);
|
|
} catch (error) {
|
|
log.error('cred', error, { operation: 'loadFile' });
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Export credentials for backup (encrypted)
|
|
* @returns {Promise<string>} Encrypted backup data
|
|
*/
|
|
async exportBackup() {
|
|
const credentials = await this.loadCredentialsFile();
|
|
const backup = {
|
|
version: '1.0',
|
|
exportedAt: new Date().toISOString(),
|
|
credentials
|
|
};
|
|
return cryptoUtils.encrypt(JSON.stringify(backup));
|
|
}
|
|
|
|
/**
|
|
* Import credentials from backup
|
|
* @param {string} encryptedBackup - Encrypted backup data
|
|
* @returns {Promise<boolean>} Success status
|
|
*/
|
|
async importBackup(encryptedBackup) {
|
|
try {
|
|
const decrypted = cryptoUtils.decrypt(encryptedBackup);
|
|
const backup = JSON.parse(decrypted);
|
|
|
|
if (backup.version !== '1.0') {
|
|
throw new Error('Unsupported backup version');
|
|
}
|
|
|
|
await this._lockedUpdate(() => backup.credentials);
|
|
this.cache.clear();
|
|
|
|
log.info('cred', 'Successfully imported backup');
|
|
return true;
|
|
} catch (error) {
|
|
log.error('cred', error, { operation: 'importBackup' });
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
module.exports = new CredentialManager();
|