Files
dashcaddy/dashcaddy-api/credential-manager.js
T
Krystie d230b39948 feat(totp): 4-part defense against permanent lockout
- credential-manager.js: add diagnose(key) method that distinguishes
  ok | missing | unreadable | corrupt instead of silently returning null
- crypto-utils.js: silent fallback to .encryption-key.bak when primary
  can't decrypt existing credentials; first-run bootstrap writes .bak;
  rotateKey() backs up old key before swap
- routes/auth/totp.js: new public /api/v1/totp/recovery-info endpoint
  returns {status, isSetUp, hint} so UI can show meaningful errors
- middleware.js: add /totp/recovery-info to PUBLIC_ROUTES so the
  locked-out user can read the diagnostic without being logged in
2026-06-18 19:56:45 -07:00

443 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('./keychain-manager');
const cryptoUtils = require('./crypto-utils');
const lockfile = require('proper-lockfile');
const fs = require('fs');
const path = require('path');
const CREDENTIALS_FILE = process.env.CREDENTIALS_FILE || path.join(__dirname, 'credentials.json');
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
};
console.log(`[CredentialManager] Initialized with ${this.useKeychain ? 'OS keychain' : 'encrypted file'} storage`);
}
/**
* 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 });
console.log(`[CredentialManager] Stored '${key}' in OS keychain`);
return true;
}
console.warn(`[CredentialManager] Keychain storage failed for '${key}', falling back to encrypted file`);
}
// Fallback to encrypted file storage
await this.storeInFile(key, value, metadata);
this.cache.set(key, { value, exp: Date.now() + this.CACHE_TTL_MS });
console.log(`[CredentialManager] Stored '${key}' in encrypted file`);
return true;
} catch (error) {
console.error(`[CredentialManager] Failed to store '${key}':`, error.message);
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) {
console.error(`[CredentialManager] Failed to retrieve '${key}':`, error.message);
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);
console.log(`[CredentialManager] Deleted '${key}'`);
return true;
} catch (error) {
console.error(`[CredentialManager] Failed to delete '${key}':`, error.message);
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) {
console.error('[CredentialManager] Failed to list credentials:', error.message);
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 {
console.log('[CredentialManager] 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) {
console.log('[CredentialManager] 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();
console.log(`[CredentialManager] Successfully rotated ${keys.length} credentials`);
return true;
} catch (error) {
console.error('[CredentialManager] Key rotation failed:', error.message);
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();
console.log(`[CredentialManager] Migrated ${migrated} plaintext credentials to encrypted format`);
}
return { migrated, skipped, total: migrated + skipped };
} catch (error) {
console.error('[CredentialManager] Migration failed:', error.message);
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).
console.warn(
`[CredentialManager] '${key}' is present but cannot be decrypted ` +
`(likely encryption-key mismatch): ${decryptErr.message}`
);
return { status: 'unreadable', value: null, error: decryptErr.message };
}
} catch (err) {
console.error(`[CredentialManager] diagnose('${key}') failed:`, err.message);
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) {
console.error('[CredentialManager] Failed to load credentials file:', error.message);
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();
console.log('[CredentialManager] Successfully imported backup');
return true;
} catch (error) {
console.error('[CredentialManager] Failed to import backup:', error.message);
return false;
}
}
}
// Export singleton instance
module.exports = new CredentialManager();