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
This commit is contained in:
Krystie
2026-06-18 19:56:45 -07:00
parent 7bbd969fa2
commit d230b39948
4 changed files with 202 additions and 0 deletions
+47
View File
@@ -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];
+94
View File
@@ -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) {
+1
View File
@@ -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' },
+60
View File
@@ -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');