feat(e2e): bunyip-e2e-bootstrap --enable-2fa enrolls a preset TOTP secret (BUNYIP-359) #367

Merged
nrupard merged 2 commits from feat/BUNYIP-359-bootstrap-2fa into main 2026-07-13 20:55:37 +02:00
Owner

What

BUNYIP-359: add an opt-in --enable-2fa to bunyip-e2e-bootstrap that enrolls a PRESET TOTP secret, so a re-seeded E2E account keeps a stable, known secret and the shared Forgejo E2E_*_TOTP_SECRET never has to be rotated on a staging wipe.

Why

Enabling 2FA was interactive-only: TotpService::begin_setup mints a fresh random secret and confirm_setup needs a live code. So every wipe + re-seed forced a manual hub enrollment and a secret rotation. The recent staging-wipe incident had to run the account with 2FA off to avoid that.

Changes

  • TotpService::enroll_preset(user_id, base32_secret) (crates/bunyip-domain/src/services/totp.rs): decode the preset base32 (normalized to the encoding begin_setup emits), sanity-check it builds a usable TOTP, then encrypt + store it via the SAME AES-256-GCM key set and the same upsert_totp + mark_verified writes as begin_setup + confirm_setup. The stored secret verifies through the normal login path. Documented as non-interactive-provisioning-only (no possession proof); never called from a handler.
  • --enable-2fa flag (bunyip-api/src/bin/bunyip_e2e_bootstrap.rs): opt-in (default off). Reads BUNYIP_E2E_TOTP_SECRET (fail-loud if unset), builds the TotpService from the same config the API uses (TOTP_ENCRYPTION_KEY + app_name), and enrolls it on every seeded account (E2E_ACCOUNT_EMAILS). Run in the API container so the key matches. Idempotent.
  • Docs: docs/e2e.md provisioning + 2FA steps now prefer --enable-2fa with a stable preset secret; manual UI enrollment kept as the alternative.

Decisions (from the issue)

  • Reuse the app's crypto/repo (no hand-rolled AES).
  • Both seeded accounts get the same preset secret (one shared E2E_*_TOTP_SECRET).
  • No digest/checksum concerns here; this is the TOTP path only.

Testing

just check-container for bunyip-domain + bunyip-api: fmt clean, clippy --all-targets -- -D warnings clean, tests pass (bin template test green). No DB-backed unit test added: the service's test module is pure (no #[sqlx::test] harness to mirror), and enroll_preset reuses the already-tested encrypt + repository writes; the end-to-end proof is the E2E suite once staging is re-seeded with --enable-2fa.

## What BUNYIP-359: add an opt-in `--enable-2fa` to `bunyip-e2e-bootstrap` that enrolls a PRESET TOTP secret, so a re-seeded E2E account keeps a stable, known secret and the shared Forgejo `E2E_*_TOTP_SECRET` never has to be rotated on a staging wipe. ## Why Enabling 2FA was interactive-only: `TotpService::begin_setup` mints a fresh random secret and `confirm_setup` needs a live code. So every wipe + re-seed forced a manual hub enrollment and a secret rotation. The recent staging-wipe incident had to run the account with 2FA off to avoid that. ## Changes - **`TotpService::enroll_preset(user_id, base32_secret)`** (`crates/bunyip-domain/src/services/totp.rs`): decode the preset base32 (normalized to the encoding `begin_setup` emits), sanity-check it builds a usable TOTP, then encrypt + store it via the SAME AES-256-GCM key set and the same `upsert_totp` + `mark_verified` writes as `begin_setup` + `confirm_setup`. The stored secret verifies through the normal login path. Documented as non-interactive-provisioning-only (no possession proof); never called from a handler. - **`--enable-2fa` flag** (`bunyip-api/src/bin/bunyip_e2e_bootstrap.rs`): opt-in (default off). Reads `BUNYIP_E2E_TOTP_SECRET` (fail-loud if unset), builds the `TotpService` from the same config the API uses (`TOTP_ENCRYPTION_KEY` + `app_name`), and enrolls it on every seeded account (`E2E_ACCOUNT_EMAILS`). Run in the API container so the key matches. Idempotent. - **Docs**: `docs/e2e.md` provisioning + 2FA steps now prefer `--enable-2fa` with a stable preset secret; manual UI enrollment kept as the alternative. ## Decisions (from the issue) - Reuse the app's crypto/repo (no hand-rolled AES). - Both seeded accounts get the same preset secret (one shared `E2E_*_TOTP_SECRET`). - No digest/checksum concerns here; this is the TOTP path only. ## Testing `just check-container` for `bunyip-domain` + `bunyip-api`: `fmt` clean, `clippy --all-targets -- -D warnings` clean, tests pass (bin template test green). No DB-backed unit test added: the service's test module is pure (no `#[sqlx::test]` harness to mirror), and `enroll_preset` reuses the already-tested `encrypt` + repository writes; the end-to-end proof is the E2E suite once staging is re-seeded with `--enable-2fa`.
feat(e2e): bunyip-e2e-bootstrap --enable-2fa enrolls a preset TOTP secret (BUNYIP-359)
All checks were successful
E2E / Playwright against deployment (pull_request) Successful in 41s
Check / fmt + clippy + build + tests (pull_request) Successful in 23m9s
a84839c989
Enabling 2FA on the E2E accounts was interactive-only: TotpService::begin_setup generates a fresh random secret and confirm_setup requires a live code, so every staging wipe + re-seed forced a manual hub enrollment and a rotation of the Forgejo E2E_*_TOTP_SECRET. The recent staging-wipe incident had to fall back to running the account with 2FA off to avoid that churn.

Adds TotpService::enroll_preset(user_id, base32_secret): decode the preset base32 (normalized to the encoding begin_setup emits), sanity-check it builds a usable TOTP, then encrypt and store it through the SAME AES-256-GCM key set and the same upsert_totp + mark_verified repository writes as begin_setup + confirm_setup, so the stored secret verifies through the normal login path. It is documented as non-interactive-provisioning-only (no possession proof) and never called from a handler.

Wires an opt-in --enable-2fa flag into bunyip-e2e-bootstrap (default off = current behavior): it reads a preset secret from BUNYIP_E2E_TOTP_SECRET (fail-loud if unset), builds the TotpService from the same config the API uses (TOTP_ENCRYPTION_KEY + app_name), and enrolls it on every seeded E2E account. Run in the API container so TOTP_ENCRYPTION_KEY matches. Idempotent (upsert + re-mark). So the shared E2E TOTP secret stays stable across re-seeds and never needs rotating on a wipe. Docs updated in docs/e2e.md. check-container green for bunyip-domain + bunyip-api (fmt + clippy -D warnings + tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nrupard scheduled this pull request to auto merge when all checks succeed 2026-07-13 20:10:07 +02:00
nrupard canceled auto merging this pull request when all checks succeed 2026-07-13 20:10:13 +02:00
fix(e2e): refuse --enable-2fa under the all-zero dev TOTP key (BUNYIP-359 review)
All checks were successful
E2E / Playwright against deployment (pull_request) Successful in 38s
Check / fmt + clippy + build + tests (pull_request) Successful in 22m12s
Create release / Create release from merged PR (pull_request) Has been skipped
20bc03b41f
Self-review catch: load_totp_encryption_key falls back to the all-zero dev key (with only a warn) when TOTP_ENCRYPTION_KEY is unset in a non-production env, which the bootstrap requires. Enrolling the E2E secret under that key while the API decrypts with its real key produces a secret the API cannot decrypt, silently breaking 2FA login while enroll_2fa still reports success. Guard it: bail if config.totp_encryption_key is the zero key, pointing the operator at running in the API container.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nrupard deleted branch feat/BUNYIP-359-bootstrap-2fa 2026-07-13 20:55:37 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/bunyip!367
No description provided.