Skip to content

fix(totp): harden authenticator lifecycle with step-up authorization - #380

Open
Cerarin wants to merge 1 commit into
shuaiplus:mainfrom
Cerarin:fix/totp-lifecycle
Open

Cerarin wants to merge 1 commit into
shuaiplus:mainfrom
Cerarin:fix/totp-lifecycle

Conversation

@Cerarin

@Cerarin Cerarin commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Make the authenticator (TOTP) key rotatable and disableable, unify its management UI, and let users hand-edit the setup key. Most importantly, enforce step-up authorization from server-side state:

  • Replacing an existing secret now requires a current second factor (the current authenticator code or the recovery code) — the master password alone is never enough.
  • Minting a recovery code for an account with an active authenticator also requires a current second factor, closing the first-factor-only mint paths.
  • Rotation hands out a fresh key only after the current second factor is verified, and the new key is stored only when its code is proven — the old key keeps working the whole time, and abandoning the dialog leaves everything untouched.
  • One unified management dialog (master password once → choose action → current code once → new key), replacing the per-action buttons.
  • The setup key is editable; the server re-validates any submitted key at commit time.
  • The recovery code is consumed only at commit and replaced atomically; the response carries a RecoveryCodeConsumed flag so the UI can distinguish a fresh mint from a consumed-and-replaced code.

Root cause

A security audit of the existing TOTP lifecycle found that first-factor proof (master password + session) was sufficient on several state-changing paths once an authenticator was already active:

  1. PUT /api/two-factor/authenticator never checked that replacing an existing secret carried a second-factor proof. With the verification token no longer bound to the key (a change made so the key can be hand-edited), masterPasswordHash → via-less token → submit attacker-chosen key + its code would replace the victim's authenticator and lock them out.
  2. The same commit path (and the legacy PUT /api/accounts/totp) minted a recovery code for accounts with an active TOTP but no stored recovery code — a legacy data shape that predates the TOTP feature — and returned it in plaintext to the caller. Combined with the recovery login, this allowed a full second-factor wipe from the first factor alone.
  3. handleGetTotpRecoveryCode minted and returned a recovery code with only the master password, which is the same attack via a more direct endpoint.

The fix derives everything from server-side state via focused helpers (isTotpRotation, totpRotationRequiresStepUp, recoveryCodeMintRequiresStepUp): whether a commit is a rotation, whether step-up is required, and which second factor authorized it (via is embedded in the HMAC-signed userVerificationToken, bound to user + securityStamp + 10-minute TTL).

Change Type

  • Bug fix
  • Feature
  • Compatibility update
  • Documentation
  • Refactor

Cross-File Checklist

  • I read CONTRIBUTING.md.
  • Schema changes, if any, updated both runtime schema and migrations/0001_init.sql — N/A, no database schema changes (no new tables/columns; the new Rotating / RecoveryCode / RecoveryCodeConsumed fields are response-only extensions).
  • Persistent data changes, if any, updated backup export/import or documented why backup is not needed — N/A, no new persistent fields.
  • User-facing text changes, if any, updated all locale files — all 10 locale files updated (7 keys added, 7 stale keys removed), npm run i18n:validate passes with every locale at 1553 keys.
  • Bitwarden client compatibility was considered for sync/API shape changes — see Notes: first-time enable is unchanged; disabling or replacing an active authenticator now requires the current code (a deliberate security trade-off, detailed below).
  • No secrets, tokens, private deployment values, or real vault data are included.

Checks

  • npx tsc -p tsconfig.json --noEmit
  • npx tsc -p webapp/tsconfig.json --noEmit
  • npm run i18n:validate — 10 locales, 1553 keys each, 0 errors
  • npm run test:totp-rotation — 22 tests passed (new suite: token integrity, rotation step-up rejection, same-secret mint gating, legacy API paths, recovery-code endpoint)
  • npx tsx --test scripts/totp-secret-validation.test.ts — 5 tests passed (new suite for hand-edited keys)
  • npm run test:config-compatibility
  • npm run test:web-crypto
  • npm run test:notifications-security
  • npm run build

Notes

Intentional behavior change (needs maintainer sign-off): clients that disable or replace an active authenticator with the master password alone now receive a 400. Affected endpoints: POST /api/two-factor/disable, DELETE /api/two-factor/authenticator (both branches of the legacy PUT /api/accounts/totp), and the replacement path of PUT /api/two-factor/authenticator. They must also send a current authenticator code or the recovery code. First-time enable is unchanged: the master password is still accepted when no authenticator is active. This matches the spirit of the official server, which requires the current 2FA code to disable 2FA, and closes a path where a leaked first factor alone could strip every second factor.

Rotation safety: the replacement key never touches storage until PUT /api/two-factor/authenticator verifies its code. POST /api/two-factor/get-authenticator with regenerate: true mints a key only after matchTotpSecondFactor — a read-only comparison that deliberately does not consume the login replay counter (provisioning is not a login). Cancelling the dialog or mistyping the new code leaves the old secret and the recovery code fully usable.

Audit trail: replacement commits log account.totp.rotate (with a recoveryCodeConsumed flag when authorized via recovery code) instead of account.totp.enable, so the log distinguishes first enable from rotation.

This PR addresses the issue.

Closes #290

@gitguardian

gitguardian Bot commented Sep 15, 2026

Copy link
Copy Markdown

️✅ There are no secrets present in this pull request anymore.

If these secrets were true positive and are still valid, we highly recommend you to revoke them.
While these secrets were previously flagged, we no longer have a reference to the
specific commits where they were detected. Once a secret has been leaked into a git
repository, you should consider it compromised, even if it was deleted immediately.
Find here more information about risks.


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

Make the authenticator key rotatable and disableable, unify its management
UI, and let users hand-edit the setup key. Prove the current authenticator
before minting a replacement key and refine the recovery code lifecycle.

Enforce step-up authorization from server-side state:
- Replacing an existing secret requires a current second factor
  (via=totp or via=recovery), never the master password alone.
- Minting a recovery code for an account with an active TOTP requires a
  current second factor, closing the first-factor-only mint path.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] 账号的 TOTP 密钥启用后隐藏直到停用重新显示

1 participant