diff --git a/dashcaddy-api/credential-manager.js b/dashcaddy-api/credential-manager.js index 8acdeb1..72bd318 100644 --- a/dashcaddy-api/credential-manager.js +++ b/dashcaddy-api/credential-manager.js @@ -319,6 +319,53 @@ class CredentialManager { : 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]; diff --git a/dashcaddy-api/crypto-utils.js b/dashcaddy-api/crypto-utils.js index 49fb7f9..f0b8147 100644 --- a/dashcaddy-api/crypto-utils.js +++ b/dashcaddy-api/crypto-utils.js @@ -66,6 +66,31 @@ function loadOrCreateKey() { 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 @@ -89,6 +114,64 @@ function loadOrCreateKey() { 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) @@ -276,6 +359,17 @@ 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) { diff --git a/dashcaddy-api/middleware.js b/dashcaddy-api/middleware.js index e441570..beff43c 100644 --- a/dashcaddy-api/middleware.js +++ b/dashcaddy-api/middleware.js @@ -283,6 +283,7 @@ module.exports = function configureMiddleware(app, { { path: '/probe/', prefix: true }, { path: '/api/v1/tailscale/', prefix: true }, { path: '/api/v1/totp/config', exact: true, method: 'GET' }, + { path: '/api/v1/totp/recovery-info', exact: true, method: 'GET' }, { path: '/api/v1/totp/verify', exact: true }, { path: '/api/v1/totp/setup', exact: true, method: 'POST' }, { path: '/api/v1/totp/verify-setup', exact: true, method: 'POST' }, diff --git a/dashcaddy-api/routes/auth/totp.js b/dashcaddy-api/routes/auth/totp.js index 450c55b..5adf3ac 100644 --- a/dashcaddy-api/routes/auth/totp.js +++ b/dashcaddy-api/routes/auth/totp.js @@ -37,6 +37,66 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp }); }, 'totp-config-get')); + // Recovery diagnostic (public, no auth required). + // + // Returns information a locked-out user needs to choose a recovery path: + // - whether TOTP is configured at all (isSetUp) + // - whether the stored secret is readable by the current encryption key + // - a human-readable hint matching the situation + // + // Status values: + // 'not_configured' — no TOTP setup yet, user should set it up + // 'healthy' — secret present and decryptable, normal login + // 'unreadable' — secret on disk but can't decrypt (key rotated) + // 'corrupt' — entry exists but value is malformed + // + // This route never returns the secret itself — only metadata about it. + router.get('/totp/recovery-info', asyncHandler(async (req, res) => { + if (!ctx.totpConfig.isSetUp) { + return res.json({ + success: true, + status: 'not_configured', + isSetUp: false, + hint: 'TOTP has not been set up on this server yet. Open settings to configure it.' + }); + } + + const diag = await ctx.credentialManager.diagnose('totp.secret'); + if (diag.status === 'ok') { + return res.json({ + success: true, + status: 'healthy', + isSetUp: true, + hint: 'TOTP is configured and the stored secret is readable. Enter your authenticator code to log in.' + }); + } + if (diag.status === 'unreadable') { + return res.json({ + success: true, + status: 'unreadable', + isSetUp: true, + hint: 'Your stored TOTP secret is on disk but cannot be decrypted — this usually means the encryption key changed during an upgrade. ' + + 'If you saved your Base32 secret when you first set up TOTP, paste it below to restore access. ' + + 'Otherwise you will need SSH access to the server to recover or rotate the key.' + }); + } + if (diag.status === 'missing') { + // Config says isSetUp:true but no secret in store — corrupted config state + return res.json({ + success: true, + status: 'corrupt', + isSetUp: true, + hint: 'TOTP is marked as configured but the secret is missing. Set up TOTP again with a fresh secret.' + }); + } + return res.json({ + success: true, + status: 'corrupt', + isSetUp: true, + hint: 'TOTP storage is in an unexpected state. ' + (diag.error || '') + }); + }, 'totp-recovery-info')); + // Generate new TOTP secret + QR code router.post('/totp/setup', asyncHandler(async (req, res) => { const { authenticator } = require('otplib');