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
+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');