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:
@@ -319,6 +319,53 @@ class CredentialManager {
|
|||||||
: 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) {
|
async deleteFromFile(key) {
|
||||||
await this._lockedUpdate(credentials => {
|
await this._lockedUpdate(credentials => {
|
||||||
delete credentials[key];
|
delete credentials[key];
|
||||||
|
|||||||
@@ -66,6 +66,31 @@ function loadOrCreateKey() {
|
|||||||
if (keyData.length >= 64) {
|
if (keyData.length >= 64) {
|
||||||
encryptionKey = Buffer.from(keyData, 'hex');
|
encryptionKey = Buffer.from(keyData, 'hex');
|
||||||
console.log('[Crypto] Loaded encryption key from file');
|
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;
|
return encryptionKey;
|
||||||
}
|
}
|
||||||
// File exists but key is invalid/empty - will generate new one below
|
// File exists but key is invalid/empty - will generate new one below
|
||||||
@@ -89,6 +114,64 @@ function loadOrCreateKey() {
|
|||||||
return encryptionKey;
|
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
|
* Encrypt sensitive data
|
||||||
* @param {string|object} data - Data to encrypt (strings or objects)
|
* @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 oldKey = loadOrCreateKey(); // Ensure we have the current key loaded
|
||||||
const newKey = generateKey();
|
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 {
|
try {
|
||||||
fs.writeFileSync(KEY_FILE, newKey.toString('hex'), { mode: 0o600 });
|
fs.writeFileSync(KEY_FILE, newKey.toString('hex'), { mode: 0o600 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -283,6 +283,7 @@ module.exports = function configureMiddleware(app, {
|
|||||||
{ path: '/probe/', prefix: true },
|
{ path: '/probe/', prefix: true },
|
||||||
{ path: '/api/v1/tailscale/', prefix: true },
|
{ path: '/api/v1/tailscale/', prefix: true },
|
||||||
{ path: '/api/v1/totp/config', exact: true, method: 'GET' },
|
{ 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/verify', exact: true },
|
||||||
{ path: '/api/v1/totp/setup', exact: true, method: 'POST' },
|
{ path: '/api/v1/totp/setup', exact: true, method: 'POST' },
|
||||||
{ path: '/api/v1/totp/verify-setup', exact: true, method: 'POST' },
|
{ path: '/api/v1/totp/verify-setup', exact: true, method: 'POST' },
|
||||||
|
|||||||
@@ -37,6 +37,66 @@ module.exports = function({ authManager, credentialManager, totpConfig, saveTotp
|
|||||||
});
|
});
|
||||||
}, 'totp-config-get'));
|
}, '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
|
// Generate new TOTP secret + QR code
|
||||||
router.post('/totp/setup', asyncHandler(async (req, res) => {
|
router.post('/totp/setup', asyncHandler(async (req, res) => {
|
||||||
const { authenticator } = require('otplib');
|
const { authenticator } = require('otplib');
|
||||||
|
|||||||
Reference in New Issue
Block a user