117 lines
11 KiB
Markdown
117 lines
11 KiB
Markdown
# Proposal: Console TOTP 2FA — Complete Security Baseline
|
|
|
|
## Intent
|
|
|
|
The provider console (`services/frontend-console`) approves tenant onboarding, manages billing, and gates the entire control plane. A compromise of any `platform_admin` or `platform_operator` account is a full control-plane breach. Today login is password-only. TOTP exists half-wired in `auth.rs` and the schema, but enrollment, recovery, replay protection, and at-rest encryption are missing — shipping it as-is would be irresponsible. Passkeys are the eventual destination (see deferred `console-passkeys-2fa`); TOTP is the right next step to close the gap fast with industry-standard hygiene.
|
|
|
|
## Scope
|
|
|
|
### In Scope
|
|
- Backend (`services/backend-api`):
|
|
- Migration: drop plaintext `users.two_factor_secret`, add `two_factor_secret_encrypted BYTEA`, `two_factor_secret_nonce BYTEA`, `last_totp_step_used BIGINT`, `totp_locked_until TIMESTAMPTZ`. New table `two_factor_recovery_codes(id, user_id, code_hash, used_at, created_at)`.
|
|
- New `crypto/totp_secret.rs`: AES-256-GCM `encrypt_secret` / `decrypt_secret` keyed by `TOTP_SECRET_ENCRYPTION_KEY` (32 bytes base64 from env). Per-secret random 12-byte nonce.
|
|
- New `handlers/totp.rs`: `setup_start`, `setup_verify`, `disable`, `regenerate_recovery_codes`, `admin_reset` (platform_admin only).
|
|
- `auth.rs`: new `AuthError` variants (`TwoFactorRequired` → 401 + `requires_2fa: true`, `TwoFactorFailed`, `TwoFactorAlreadyEnabled`, `TwoFactorNotEnabled`, `RecoveryCodeInvalid`).
|
|
- Login handler: emit `TwoFactorRequired` instead of 500; verify TOTP with replay check (`step > last_totp_step_used`); accept `recovery_code` as alternative; lockout after 5 failures in 15 min for 30 min.
|
|
- Audit-log entries for every event.
|
|
- Frontend (`services/frontend-console`):
|
|
- `loginSchema` adds optional `two_factor_code` and `recovery_code`; `LoginResponse` adds `requires_2fa?`.
|
|
- `LoginPage` two-step UI: TOTP field appears after first 401 with `requires_2fa: true`; tabs for "Authenticator code" / "Recovery code".
|
|
- New `src/features/security/`: `SecuritySettingsPage`, enrollment (QR + base32 secret + 6-digit confirm + recovery codes display once with explicit "I saved them" gate), disable, regenerate.
|
|
- `platform_admin` reset UI (placement decided in design phase — likely an admin panel, not access-requests).
|
|
- Route `/app/platform/security`. Update `AUTH_BYPASS_ENDPOINTS` only if needed (all 2FA endpoints require either pre-2fa context via login OR an access token).
|
|
- Docs: `docs/DESPLIEGUE.md` documents `TOTP_SECRET_ENCRYPTION_KEY` env var and generation procedure.
|
|
|
|
### Out of Scope
|
|
- 2FA for tenant users (`frontend-admin`) — separate change.
|
|
- SMS/email OTP (insecure / phishable).
|
|
- Passkeys (deferred — `console-passkeys-2fa`).
|
|
- Encryption-key rotation tooling (P2 follow-up; gap named in R1).
|
|
- Email notification on admin reset (P2 follow-up).
|
|
- Audit-log dashboard hooks beyond writing entries (existing infra reads them).
|
|
|
|
## Capabilities
|
|
|
|
### New Capabilities
|
|
- `console-totp`: TOTP enrollment, verification, recovery codes, lockout, and admin reset for the platform console. Covers `setup_start`, `setup_verify`, `disable`, `regenerate_recovery_codes`, `admin_reset` endpoints and the `crypto/totp_secret` module.
|
|
|
|
### Modified Capabilities
|
|
- `auth`: login response gains structured `requires_2fa: true` 401 instead of 500; accepts `totp_code` or `recovery_code`; enforces replay-step check and lockout; new `AuthError` variants and audit actions.
|
|
|
|
## Approach
|
|
|
|
**Approach 2 from exploration — complete industry baseline.** Anchored decisions:
|
|
|
|
1. **Single-shot login (Option A)**. Frontend posts `{email, password, totp_code?, recovery_code?}`. If `two_factor_enabled` and code missing/wrong → 401 `{ error, requires_2fa: true }`. No pre-auth token. Halves implementation surface vs Option B; sufficient for an internal high-trust console.
|
|
2. **App-layer AES-256-GCM** for the TOTP secret. Master key from `TOTP_SECRET_ENCRYPTION_KEY` env. Per-secret 12-byte nonce stored alongside ciphertext. `pgcrypto` stays out — different threat model.
|
|
3. **Replay protection** via `users.last_totp_step_used BIGINT`. On verify, accept only if `current_step > last_totp_step_used` within the ±1 skew window; then update.
|
|
4. **Recovery codes**: 8 codes at enrollment, base32 10-char alphanumeric, displayed once, stored as Argon2id hashes, one-time-use enforced by row deletion on redemption. Regeneration invalidates old codes.
|
|
5. **Lockout** (resolved Q2): 5 failed attempts in 15 min lock the account for 30 min via `users.totp_locked_until`. Defense-in-depth on top of route rate limiter.
|
|
6. **Admin reset trail** (resolved Q1): audit-log entry with actor, target, timestamp. Email notification deferred to P2.
|
|
7. **Recovery regeneration** (resolved Q3): user-initiated from security page; old codes invalidated.
|
|
8. **Migration is destructive** — drops the plaintext column. Verified safe: no enrollment endpoints existed, so no production user has a real TOTP setup. Migration NULLs any seed/test values defensively. Comment in SQL flags the destructive nature.
|
|
9. **Delivery**: two chained PRs, each ≤ 400 lines. PR1 backend (migration, crypto, endpoints, login fix, audit). PR2 frontend (login two-step, SecuritySettingsPage, admin reset UI).
|
|
10. **Crate**: `totp-rs` v5.7.1 already present with `qr` feature; add `aes-gcm` (or reuse `chacha20poly1305` if already in tree — design phase confirms).
|
|
|
|
## Affected Areas
|
|
|
|
| Area | Impact | Description |
|
|
|------|--------|-------------|
|
|
| `services/backend-api/Cargo.toml` | Modified | Add `aes-gcm` (or confirm `chacha20poly1305`). `totp-rs` already present. |
|
|
| `services/backend-api/migrations/2026XXXXXXXXXX_totp_2fa.sql` | New | Drop plaintext column; add encrypted/nonce/last_step/locked_until columns; create `two_factor_recovery_codes` table. Destructive — comment in SQL. |
|
|
| `services/backend-api/src/crypto/totp_secret.rs` | New | `encrypt_secret` / `decrypt_secret` AES-256-GCM. |
|
|
| `services/backend-api/src/handlers/totp.rs` | New | `setup_start`, `setup_verify`, `disable`, `regenerate_recovery_codes`, `admin_reset`. |
|
|
| `services/backend-api/src/handlers/auth.rs` | Modified | Login: `TwoFactorRequired` 401, replay-step check, recovery-code path, lockout enforcement, audit-log new actions. |
|
|
| `services/backend-api/src/auth.rs` | Modified | New `AuthError` variants and HTTP mappings. |
|
|
| `services/backend-api/src/main.rs` | Modified | Register `/api/auth/2fa/*` routes inside `auth_routes` under same rate limiter. |
|
|
| `services/frontend-console/src/features/auth/schemas/login-schema.ts` | Modified | Optional `two_factor_code`, `recovery_code`. |
|
|
| `services/frontend-console/src/features/auth/api/auth-api.ts` | Modified | `LoginResponse.requires_2fa?`. |
|
|
| `services/frontend-console/src/features/auth/pages/LoginPage.tsx` | Modified | Two-step UI with TOTP / recovery tabs. |
|
|
| `services/frontend-console/src/features/security/` | New | `SecuritySettingsPage`, enrollment / disable / regenerate flows, admin reset UI. |
|
|
| `services/frontend-console/src/app/router/index.tsx` | Modified | `/app/platform/security` route. |
|
|
| `services/frontend-console/src/lib/api/client.ts` | Verified | Confirm no new bypass endpoints needed (all 2FA paths require token or login context). |
|
|
| `docker-compose.yml`, `docs/DESPLIEGUE.md` | Modified | Wire and document `TOTP_SECRET_ENCRYPTION_KEY`. |
|
|
|
|
## Risks
|
|
|
|
| Risk | Likelihood | Mitigation |
|
|
|------|------------|------------|
|
|
| **R1** `TOTP_SECRET_ENCRYPTION_KEY` leak/loss = total bypass or total lockout | Med | Document generation (`openssl rand -base64 32`) and storage (env, not repo) in `DESPLIEGUE.md`. Backend refuses to start without it. Key rotation tooling explicitly named as P2 gap, not built here. |
|
|
| **R2** Destructive migration drops `two_factor_secret` | Low | Verified no production user has a working enrollment (no setup endpoints exist). Migration NULLs any seed values defensively. SQL header comment flags destructiveness. Include pre-flight check in PR description. |
|
|
| **R3** TOCTOU between password and TOTP verification | Low | Both checks read the same `users` row inside one request; document recommended `READ COMMITTED` isolation; race window is ≤ 1 request, attacker would need to disable 2FA mid-login (impossible without already authenticated). |
|
|
| **R4** Recovery codes shown once → user loses them | Med | Frontend requires explicit checkbox confirmation ("I have saved my recovery codes") before enabling close/navigation. Regeneration path always available from security page. |
|
|
| **R5** Malicious frontend silently re-submitting password | Low | Server-side validation is the source of truth; documented as non-issue. No client-trust assumptions in the protocol. |
|
|
| **R6** Audit-log gaps would hide credential abuse | Med | Every event (`enable`, `verify_setup`, `disable`, `login_with_code`, `login_with_recovery`, `admin_reset`, `regenerate_codes`, `lockout`) hits `audit_logs` via `crate::audit::log()`. Surfaces as a `sdd-verify` checklist item. |
|
|
|
|
## Rollback Plan
|
|
|
|
1. Revert backend deploy: previous binary lacks `/api/auth/2fa/*` routes; login returns to prior shape.
|
|
2. SQL rollback: re-add `two_factor_secret TEXT NULL` column. New encrypted columns are additive — leave them in place (or drop in a follow-up migration). Set `users.two_factor_enabled = false` for any platform users who enrolled during the rollout window.
|
|
3. Drop `two_factor_recovery_codes` table only if confirmed unused.
|
|
4. Frontend rollback (Vercel/static) restores password-only `LoginPage` and removes `/app/platform/security`.
|
|
5. Notify operators in the platform channel; users keep accounts but lose 2FA state (must re-enroll after rollforward).
|
|
|
|
## Dependencies
|
|
|
|
- `aes-gcm` crate (or confirm `chacha20poly1305` already in tree) — design phase locks the choice.
|
|
- `totp-rs` v5.7.1 with `qr` feature (already in `Cargo.toml`).
|
|
- `argon2` crate for recovery-code hashing (already used for password hashing — confirm in design).
|
|
- New env var `TOTP_SECRET_ENCRYPTION_KEY` (base64 32 bytes) wired in `docker-compose.yml` and prod deploy docs.
|
|
- Existing `auth_routes` rate limiter applied to new endpoints.
|
|
|
|
## Success Criteria
|
|
|
|
- [ ] Platform user logs in with password → 401 `{ requires_2fa: true }`.
|
|
- [ ] Same user re-submits with valid TOTP code → 200 with `access_token`.
|
|
- [ ] Same user re-submits with valid recovery code → 200 with `access_token`; that code is gone from the table.
|
|
- [ ] Same user re-submits with invalid code → 401 `TwoFactorFailed`.
|
|
- [ ] Replay of a just-used valid code within 30 s → 401 `TwoFactorFailed`.
|
|
- [ ] DB dump exposes no plaintext TOTP secrets (column is `BYTEA` ciphertext only).
|
|
- [ ] 5 failed TOTP attempts in 15 min → `totp_locked_until` set; further attempts return 401 until lock expires.
|
|
- [ ] Every event lands in `audit_logs` (enable, verify_setup, disable, login_with_code, login_with_recovery, admin_reset, regenerate_codes, lockout).
|
|
- [ ] Tenant user login flow is byte-for-byte unchanged.
|
|
- [ ] Non-2FA platform users (pre-enrollment) log in normally with email + password.
|
|
- [ ] Backend refuses to boot without `TOTP_SECRET_ENCRYPTION_KEY`.
|
|
- [ ] Each PR ≤ 400 changed lines (chained delivery).
|
|
- [ ] `cargo test` and `pnpm --dir services/frontend-console test` are green.
|