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
+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) {