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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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];
|
||||
|
||||
Reference in New Issue
Block a user