From 90743cfa39c88de0d6a1ec539f0e2f735f55aeec Mon Sep 17 00:00:00 2001 From: Sn0rrii <4687675+netscout2001@users.noreply.github.com> Date: Fri, 8 May 2026 09:01:51 +0200 Subject: [PATCH] feat(encryption): MFA at-rest encryption auto-bootstrap with status UI (#1219) (#1231) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chore(i18n): extend parity gate to all locales with strict/info tiers Previously the script only inspected en/zh-CN/zh-TW, leaving de/fr/it/ja/pt-BR drift invisible. Now locales are auto-discovered from src/i18n/locales/, and a STRICT list (de, zh-CN, zh-TW — currently in parity) gates CI while the rest report informationally until their drift is caught up. ja notably has 27 real placeholder bugs worth fixing before promotion to strict. --- .env.example | 12 + .gitignore | 3 + CHANGELOG.md | 1 + backend/app/api/routes/auth.py | 102 + backend/app/api/routes/mfa.py | 86 +- backend/app/api/routes/settings.py | 96 +- backend/app/core/auth.py | 11 +- backend/app/core/config.py | 30 + backend/app/core/database.py | 135 + backend/app/core/encryption.py | 202 +- backend/app/core/paths.py | 26 + backend/app/schemas/auth.py | 19 + backend/tests/conftest.py | 36 + backend/tests/integration/test_security.py | 2094 +++- .../tests/unit/test_config_env_warnings.py | 52 + docker-compose.yml | 5 + .../components/SecurityStatusCard.test.tsx | 262 + frontend/src/api/client.ts | 17 + .../src/components/SecurityStatusCard.tsx | 193 + frontend/src/i18n/locales/de.ts | 21 + frontend/src/i18n/locales/en.ts | 21 + frontend/src/i18n/locales/fr.ts | 23 + frontend/src/i18n/locales/it.ts | 23 + frontend/src/i18n/locales/ja.ts | 22 + frontend/src/i18n/locales/pt-BR.ts | 23 + frontend/src/i18n/locales/zh-CN.ts | 22 + frontend/src/i18n/locales/zh-TW.ts | 22 + frontend/src/lib/settingsSearch.ts | 4 +- frontend/src/pages/SettingsPage.tsx | 26 +- static/assets/index-C_2KW3q0.js | 8744 +++++++++++++++++ static/assets/index-Dlmc9CRg.css | 1 + static/index.html | 6 +- 32 files changed, 12242 insertions(+), 98 deletions(-) create mode 100644 backend/app/core/paths.py create mode 100644 backend/tests/unit/test_config_env_warnings.py create mode 100644 frontend/src/__tests__/components/SecurityStatusCard.test.tsx create mode 100644 frontend/src/components/SecurityStatusCard.tsx create mode 100644 static/assets/index-C_2KW3q0.js create mode 100644 static/assets/index-Dlmc9CRg.css diff --git a/.env.example b/.env.example index 6527d4a34..ff06edd4d 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,15 @@ LOG_TO_FILE=true # on port 8000 are different origins to the browser. Wildcards, paths, and # non-http(s) schemes are rejected at startup with a warning. # TRUSTED_FRAME_ORIGINS=http://homeassistant.local:8123 + +# MFA at-rest encryption key (#1219) — Fernet, base64-encoded 32 bytes. +# Auto-generated and stored in DATA_DIR/.mfa_encryption_key on first startup +# if unset. Set explicitly to manage the key out-of-band (e.g. via a secret +# manager). +# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +# +# NOTE: Local backups (.zip) include the auto-generated key file, so a backup +# is self-contained. If you set this variable explicitly, ensure your backups +# also store the value separately (otherwise an encrypted backup cannot be +# restored after key loss). +# MFA_ENCRYPTION_KEY= diff --git a/.gitignore b/.gitignore index f9eddc610..ffaee2ce3 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,9 @@ data/ # JWT secret file (should be in data dir, but protect project root too) .jwt_secret +# MFA encryption key file (#1219) — same protection as .jwt_secret +.mfa_encryption_key + # SpoolBuddy SSH keys (generated at runtime for remote updates) spoolbuddy/ssh/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 064b4f0f0..a9bb96eac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to Bambuddy will be documented in this file. - **Slicer Bundle (.bbscfg) import — pick presets from a stored bundle instead of resolving cloud/local/standard PresetRefs every slice** — Closes the long tail of preset-resolution corner cases (cloud presets behind login, "from User" sentinel handling, the `# `-prefix clone trick, dangling `inherits` on renamed parents, etc.) by letting users upload a BambuStudio "Printer Preset Bundle" (`.bbscfg`) once per printer and pick from it for every subsequent slice. **Service layer (`backend/app/services/slicer_api.py`):** `BundleSummary` / `BundleNotFoundError` types, `import_bundle` / `list_bundles` / `get_bundle` / `delete_bundle` methods, `slice_with_bundle` which posts `/slice` with bundle id + per-category preset names instead of the JSON triplet. **Routes (`/api/v1/slicer/bundles`, all gated on `Permission.LIBRARY_UPLOAD`):** `POST` / `GET` / `GET :id` / `DELETE :id`. All routes proxy via `_resolve_slicer_api_url` so they follow the user's `preferred_slicer` setting (bambu_studio vs orcaslicer). Status-code mapping treats sidecar 4xx as 400, `BundleNotFoundError` as 404, sidecar unreachable as 503, and sidecar 5xx as 502. **Preview-slice (`backend/app/services/slice_preview.py::get_preview_filaments`)** picks up optional `bundle_id` + `printer_name` + `process_name` + `filament_names` params and routes through `slice_with_bundle` when set; the cache key picks up a bundle-context fingerprint so different bundle picks on the same file occupy distinct entries — gram numbers in the preview now match what the real print will produce instead of being derived from the file's embedded process settings (which can drift from the triplet the actual slice would use). The `library.py` and `archives.py` `/filament-requirements` routes forward the new params. **Dispatch (`SliceRequest.bundle: SliceBundleSpec`):** when set, `_run_slicer_with_fallback` skips `resolve_preset_ref` and calls `slice_with_bundle`; the validator skips the preset-required check so bundle-only requests validate. 3MF + bundle CLI 5xx still falls back to the embedded-settings slice path (`used_embedded_settings=True` surfaces in the response), and sidecar 404 (unknown bundle / preset name) maps to 400. **Frontend SliceModal Bundle tier:** new "Slicer bundle" picker at the top of the modal, rendered only when at least one bundle is imported (`GET /slicer/bundles` non-empty). Selecting a bundle replaces cloud / local / standard preset dropdowns with bundle-scoped pickers (process + per-slot filament names from the bundle) — printer is implicit (each `.bbscfg` has exactly one). "None" leaves the modal on the original preset-triplet path. Submit routes through `SliceRequest.bundle` so the backend skips PresetRef resolution and asks the sidecar to materialise the JSON triplet from the stored bundle by name. **Frontend types:** `SliceBundleSpec` + `bundle?: SliceBundleSpec` on `SliceRequest`; `getLibraryFileFilamentRequirements` / `getArchiveFilamentRequirements` accept an optional 4th-arg bundle context object. The orca-slicer-api fork's bundle endpoints (shipped on `bambuddy/bundle-import`) are the server side of this — see the slicer-api sidecar docker-compose for the matching versions. ### Fixed +- **MFA at-rest encryption is now default-on via auto-bootstrap** ([#1219](https://github.com/maziggy/bambuddy/issues/1219)) — Default Docker installs ran with `MFA_ENCRYPTION_KEY` unset, which silently fell back to plaintext storage for OIDC `client_secret` and TOTP secret rows. The single startup `logger.warning` was the only signal, and `.env.example` / `docker-compose.yml` / Settings UI never mentioned the variable, so any operator who wired up SSO or asked users to enroll in 2FA had to read the warning in the logs to know their secrets were unprotected at rest. **Auto-bootstrap:** `backend/app/core/encryption.py` now resolves the encryption key with the same precedence pattern as `_get_jwt_secret` — `MFA_ENCRYPTION_KEY` env var → `DATA_DIR/.mfa_encryption_key` file → auto-generated Fernet key written with mode `0o600`. The new helper `backend/app/core/paths.py:resolve_data_dir()` is shared with `auth.py` (DRY) and reads the env fresh on every call so test fixtures can override `DATA_DIR` per-test. Invalid env-var values (anything that doesn't decode to exactly 32 bytes via URL-safe base64) are rejected with a `logger.error` and the loader falls through to the file/auto-generate branches instead of crashing the encrypt/decrypt path with `ValueError`. **Re-encryption migration:** `_migrate_encrypt_legacy_secrets()` runs once on every startup after `run_migrations(conn)` finishes — it opens its own `async_session()` (separate from the schema-DDL connection, to avoid SQLite WAL lock contention) and converts any `oidc_providers.client_secret` / `user_totp.secret` row whose value doesn't already start with `fernet:` to the encrypted form via the existing property setters. The migration is idempotent (prefix check) and is a no-op when no key is loaded, so it can run safely on installs that never opt in. **Status endpoint + UI:** new `GET /api/v1/auth/encryption-status` (admin-only, gated on `Permission.SETTINGS_READ`) returns `key_configured`, `key_source ∈ {env, file, generated, none}`, plus per-table `legacy_plaintext_rows` and `encrypted_rows` counts and a derived `decryption_broken` flag (true iff encrypted rows exist but no key is loadable — the Phase-2 "operator deleted the key after rows were encrypted" recovery scenario). The new `frontend/src/components/SecurityStatusCard.tsx` lives in a new "Security" sub-tab under Settings → Authentication and renders four severity levels: green when everything is encrypted and a key is loaded, yellow when legacy plaintext rows still need re-encryption, orange when the key was auto-generated (with a backup hint pointing at `DATA_DIR/.mfa_encryption_key`), and red when `decryption_broken` is true. **Backup integration:** `routes/settings.py:create_backup_zip` now includes `.mfa_encryption_key` as a ZIP top-level entry (alongside `bambuddy.db`) so a self-contained backup can be restored to a fresh host without losing access to encrypted secrets. The matching `routes/settings.py:restore_backup` extracts the file back into `DATA_DIR` with `chmod(0o600)` and validates the basename exactly (`/`, `..`, `\\` rejected) so a manipulated ZIP cannot path-traverse outside `DATA_DIR`. If the file is absent from the ZIP (legacy backup) the restore proceeds without error — the next boot will auto-bootstrap a fresh key, and any plaintext rows that come back from the backup remain readable via the existing legacy-plaintext fallback in `mfa_decrypt`. **Test isolation:** new autouse `mfa_encryption_isolation` fixture in `conftest.py` per-test points `DATA_DIR` at a `tmp_path`, clears `MFA_ENCRYPTION_KEY` from env, and resets the `_fernet_instance` / `_warn_shown` / `_key_source` module globals — so the auto-bootstrap can never write a real key file into the repo and pytest-xdist workers don't share encryption state. **i18n:** new `settings.encryption.*` namespace and `settings.tabs.security` label across all 8 locales (en + de fully translated; fr/it/ja/pt-BR/zh-CN/zh-TW seeded with English copy pending native translation, matching the project's existing flow for newly-added keys). **Docs:** `.env.example` documents the new variable + the backup self-containment behaviour; `docker-compose.yml` carries an auto-commented entry; `.gitignore` adds `.mfa_encryption_key` alongside the existing `.jwt_secret` project-root guard. **Tests:** 9 new unit tests in `TestEncryption` (env/file/generated key sources, invalid-env fall-through, OSError → `none`, mode `0o600` check), 6 new in `TestEncryptLegacyMigration` (plaintext → encrypted for OIDC + TOTP, idempotent re-run, mixed state, no-op without key, log assertion), 8 new in `TestEncryptionStatusEndpoint` (each `key_source`, count assertions, `decryption_broken` recovery scenario, `Permission.SETTINGS_READ` gate), 2 new in `TestEncryptionRoundtrip` (raw column reads return ciphertext, property reads return plaintext for both OIDC and TOTP), 6 new in `TestBackupKeyFiles` (ZIP includes / skips key files, restore chmod `0o600`, missing-file tolerance, path-traversal rejection), and 6 new frontend tests in `SecurityStatusCard.test.tsx` (each severity level + the disabled state). - **Camera preview popup opened to a blank page; deep-route refresh and direct URL load broken** ([#1221](https://github.com/maziggy/bambuddy/issues/1221), reported by @enjoylifenow / @Haeckan / @elit3ge / @jc21) — Clicking "open camera in new window" from the printer card opened a popup that rendered as an empty white page across P1S / P2S / X1 series, every install method (Docker / git clone), every browser (Chrome / Firefox / Brave / Safari), starting with the daily build of 2026-05-05. **Cause:** PR #1195 (`d6a31393`, "fix(frontend): emit relative asset paths so SPA loads under any subpath") set `base: ''` in `vite.config.ts` to support path-prefixed reverse proxies (HA Ingress, nginx subpath, Cloudflare Tunnel path routing). With that, the built `index.html` references its bundle and stylesheet via relative URLs (`./assets/index-XXX.js`, `./sw-register.js`). When the popup opened at `/camera/`, the browser resolved `./assets/index-XXX.js` against the current document URL — which doesn't end in a slash, so the URL parser treated `` as a file and `/camera/` as the directory, giving `/camera/assets/index-XXX.js`. The backend's SPA catch-all returned `index.html` (text/html) for that request, and modern browsers refuse to execute HTML as a JS module under `X-Content-Type-Options: nosniff`, so the popup loaded the document but never the bundle. Same break hit any deep route on initial load — direct URL paste / refresh on `/camera/:printerId`, `/projects/:id`, `/groups/:id/edit`, `/files/trash`, `/external/:id`, and the SpoolBuddy kiosk's `/spoolbuddy/ams` if loaded directly — manifesting as a quiet "blank page on refresh" that users worked around by navigating from the home page. The console error gives it away: `Loading module … was blocked because of a disallowed MIME type ("text/html")`. **Fix:** revert PR #1195's `vite.config.ts` and `sw-register.js` changes — `base: ''` is removed (Vite default `'/'` restored), and `navigator.serviceWorker.register('sw.js')` reverts to `register('/sw.js')`. The built `index.html` now emits absolute asset URLs (`/assets/...`, `/manifest.json`, `/sw-register.js`) which resolve against host root regardless of document URL, so deep routes load their assets correctly on initial navigation. PR #1195's class of bug — path-prefixed reverse proxy users serving Bambuddy at a subpath — was already explicitly closed as wontfix in that thread because supporting it requires subpath-aware bootstrapping (API_BASE, React Router basename, PWA manifest scope, service-worker scope, push-subscription scope) for every user forever. The supported workaround for that audience is documented: NPM (Nginx Proxy Manager) addon + Cloudflare Tunnel at a real domain with HTTPS, then HA Webpage panel embedding via `TRUSTED_FRAME_ORIGINS` — that path doesn't depend on `base: ''` at all. The trade-off here is intentional: revert reaches every user impacted by deep-route initial-load bugs (much larger population than path-prefixed proxy users), in exchange for an already-wontfixed subpath proxy regression that has a working alternative. ([#1237](https://github.com/maziggy/bambuddy/issues/1237), reported by @basziee) — In the Configure AMS Slot modal, profile names like `SUNLU PETG GLOW IN THE DARK GEN2 @Bambu Lab H2C 0.4 nozzle` were visually truncated mid-name, hiding the `@ ` suffix. With several near-identical entries differing only in nozzle size, users had to open browser dev tools to tell them apart. **Fix:** the preset row now expands inline on hover — `truncate` stays as the default (so the list keeps its compact one-line shape) but `group-hover:whitespace-normal group-hover:break-all` flips it to a wrapped multi-line view the moment the cursor enters the row, so the nozzle suffix is readable instantly without waiting on the browser's title-tooltip delay. The parent button gets `group` to drive the hover. The native `title={preset.name}` is also added as a belt-and-braces fallback for assistive tech and touch devices where `:hover` doesn't fire. Same pattern in both the desktop and mobile layouts of `ConfigureAmsSlotModal.tsx`. No new dependencies. **Test:** new `ConfigureAmsSlotModal.test.tsx` regression assertion that the rendered preset span carries `title=` plus the `truncate` and `group-hover:whitespace-normal` classes, and the parent button has `group` — so a future refactor that drops any of those fails CI. - **Filament usage double-counted when AMS auto-falls-back to a same-material spool** ([#957](https://github.com/maziggy/bambuddy/issues/957)) — When one spool ran out mid-print and the AMS transparently switched to a sibling slot loaded with the same material, the usage tracker credited the originally-mapped spool with the full 3MF estimate AND added the fallback spool's remain%-delta on top — so a 78 g print could show as 78 g + 60 g = 138 g consumed across the two spools, leaving the empty spool's recorded weight beyond its label weight (the symptom the original report flagged on a 1209 g spool reading "1188.30 g used" while the new spool only got a 30 g credit). Two interacting bugs: (1) the tray-change recorder in `bambu_mqtt.py` gated on `state in ("RUNNING", "PAUSE")` literal strings, and P2S firmware briefly transitions out of RUNNING during the AMS swap, so the switch was never appended to `tray_change_log`; (2) the usage-tracker splitting branch in `usage_tracker.py` was gated on `not slot_to_tray`, so even when the tray-change log was populated the splitting code only ran for prints where the slicer's mapping had not been captured — i.e. never on the actual fallback case. **Fix:** the `bambu_mqtt.py` gate now keys on the print-lifecycle flags (`_was_running and not _completion_triggered`) so any tray change between print start and completion is captured regardless of the momentary `gcode_state` string. The `usage_tracker.py` gate is split so `tray_change_log` evidence with > 1 entries always takes over from `slot_to_tray`, treating the per-segment per-layer gcode usage as the source of truth when the printer actually fed from multiple trays. Path 2 (AMS remain%-delta fallback) then naturally skips both trays because they're already in `handled_trays` after splitting, eliminating the double-credit. **Tests:** new `test_tray_change_recorded_during_intermediate_state` and `test_tray_change_not_recorded_after_completion` in `test_bambu_mqtt.py` exercising the new gate; new `test_tray_switch_overrides_print_cmd_mapping` in `test_usage_tracker.py` pinning that with `ams_mapping=[0]` set and `tray_change_log=[(0,0),(1,30)]` the splitter produces two segments summing to the 3MF estimate (no double-count) and adds both `(0,0)` and `(0,1)` to `handled_trays`. - **3D Preview returned `{"detail":"Not Found"}` in Docker installs** ([#1218](https://github.com/maziggy/bambuddy/issues/1218)) — The embedded GCode viewer's static assets (`gcode_viewer/`) were not copied into the production Docker image, so clicking "3D Preview" on any archive loaded an iframe at `/gcode-viewer/?archive=` that returned a bare FastAPI 404 — Firefox / Chrome rendered the JSON response inside the iframe area while the outer Bambuddy layout looked normal, masking the failure unless the user actually inspected the iframe. The Vite production build doesn't stage `gcode_viewer/` into `static/` either (the dev server serves it via a `configureServer` middleware that's dev-only), and the only integration test for the route accepted `404` as a valid outcome ("`assert response.status_code in (200, 404)`") so CI never caught the missing files. Affected every Docker build since the embedded viewer landed in 0.2.4b1 (commit `3adce435`, 2026-04-22). **Fix:** `Dockerfile` now copies the `gcode_viewer/` directory alongside the React build output. **Defence in depth:** `backend/app/main.py` logs an ERROR at startup when `_gcode_viewer_dir / "index.html"` is missing so future packaging gaps surface in `docker logs` and the support bundle instead of as silent runtime 404s. **Test guard:** `backend/tests/integration/test_gcode_viewer.py` adds `test_gcode_viewer_index_served_when_assets_present` which skips when the directory is intentionally absent (unit-test environments) but asserts `200 OK` + a non-empty HTML body when the assets do exist on disk — so a future broken `COPY` fails CI loudly rather than continuing to ship a broken image. diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index fa24831eb..1be2f407c 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -9,6 +9,7 @@ from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, from fastapi.security import HTTPAuthorizationCredentials from jwt.exceptions import PyJWTError from sqlalchemy import delete, select +from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -39,6 +40,8 @@ from backend.app.models.group import Group from backend.app.models.settings import Settings from backend.app.models.user import User from backend.app.schemas.auth import ( + EncryptionRowCounts, + EncryptionStatusResponse, ForgotPasswordConfirmRequest, ForgotPasswordRequest, ForgotPasswordResponse, @@ -1473,3 +1476,102 @@ async def revoke_long_lived_token( current_user.username, ) return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/encryption-status", response_model=EncryptionStatusResponse) +async def get_encryption_status( + _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE), + db: AsyncSession = Depends(get_db), +) -> EncryptionStatusResponse: + """Report at-rest encryption status for OIDC + TOTP secrets. + + Surfaces: + (a) whether a key is configured and where it came from + (b) how many rows are still legacy plaintext + (c) whether decryption is broken (no key OR key cannot decrypt existing rows) + (d) the count of rows skipped during the last re-encryption migration + + S2: gated on SETTINGS_UPDATE so Viewers (who only have SETTINGS_READ) + cannot read encryption-status — admin/operator only. + """ + from sqlalchemy import case, func, not_, select + + from backend.app.core.database import get_migration_error_count + from backend.app.core.encryption import get_key_source, is_encryption_active, mfa_decrypt + from backend.app.models.oidc_provider import OIDCProvider + from backend.app.models.user_totp import UserTOTP + + key_configured = is_encryption_active() + key_source = get_key_source() or "none" + + try: + oidc_row = await db.execute( + select( + func.sum(case((not_(OIDCProvider._client_secret_enc.like("fernet:%")), 1), else_=0)), + func.sum(case((OIDCProvider._client_secret_enc.like("fernet:%"), 1), else_=0)), + ) + ) + legacy_oidc, encrypted_oidc = oidc_row.one() + totp_row = await db.execute( + select( + func.sum(case((not_(UserTOTP._secret_enc.like("fernet:%")), 1), else_=0)), + func.sum(case((UserTOTP._secret_enc.like("fernet:%"), 1), else_=0)), + ) + ) + legacy_totp, encrypted_totp = totp_row.one() + except SQLAlchemyError: + _logger.exception("Failed to query encryption row counts") + raise HTTPException(status_code=500, detail="Failed to retrieve encryption status") + + legacy_plaintext_rows = EncryptionRowCounts( + oidc_providers=int(legacy_oidc or 0), + user_totp=int(legacy_totp or 0), + ) + encrypted_rows = EncryptionRowCounts( + oidc_providers=int(encrypted_oidc or 0), + user_totp=int(encrypted_totp or 0), + ) + + # B4: detect "wrong key" state — sample-decrypt one encrypted row to + # distinguish "no key" from "key configured but cannot decrypt these rows". + # The legacy computed-field check (key_configured=False AND encrypted>0) + # missed the case where an operator pasted a different valid Fernet key + # (rotation, cross-deployment restore, env override) — status would show + # green while every encrypted row was unrecoverable. + decryption_broken = False + total_encrypted = encrypted_rows.oidc_providers + encrypted_rows.user_totp + if not key_configured and total_encrypted > 0: + decryption_broken = True + elif key_configured and total_encrypted > 0: + sample_value: str | None = None + try: + if encrypted_rows.oidc_providers > 0: + r = await db.execute( + select(OIDCProvider._client_secret_enc) + .where(OIDCProvider._client_secret_enc.like("fernet:%")) + .limit(1) + ) + sample_value = r.scalar_one_or_none() + if sample_value is None and encrypted_rows.user_totp > 0: + r = await db.execute(select(UserTOTP._secret_enc).where(UserTOTP._secret_enc.like("fernet:%")).limit(1)) + sample_value = r.scalar_one_or_none() + except SQLAlchemyError: + _logger.exception("Failed to query sample encrypted row for decryption probe") + # Over-alert is safer than silent corruption — surface as broken. + decryption_broken = True + sample_value = None + + if sample_value: + try: + mfa_decrypt(sample_value) + except RuntimeError: + decryption_broken = True + + return EncryptionStatusResponse( + key_configured=key_configured, + key_source=key_source, + legacy_plaintext_rows=legacy_plaintext_rows, + encrypted_rows=encrypted_rows, + decryption_broken=decryption_broken, + migration_error_count=get_migration_error_count(), + ) diff --git a/backend/app/api/routes/mfa.py b/backend/app/api/routes/mfa.py index 1784d0e41..9a11bedca 100644 --- a/backend/app/api/routes/mfa.py +++ b/backend/app/api/routes/mfa.py @@ -555,14 +555,27 @@ async def setup_totp( if existing and existing.is_enabled: await check_rate_limit(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT) supplied_code = (body.code if body else None) or "" - if not pyotp.TOTP(existing.secret).verify(supplied_code, valid_window=1): + # S4: narrow the RuntimeError catch to ONLY the property access — that + # is the single line that raises on key-loss. The previous wide try + # block also covered record_failed_attempt, clear_failed_attempts, + # and _assert_totp_not_replayed, so a future RuntimeError from any + # of those would have been misreported as "TOTP secret unavailable". + try: + secret_plain = existing.secret + except RuntimeError: + logger.exception("TOTP decryption failed for user_id=%s", current_user.id) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="TOTP secret unavailable", + ) + if not pyotp.TOTP(secret_plain).verify(supplied_code, valid_window=1): await record_failed_attempt(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Current TOTP code required to replace an active authenticator", ) await clear_failed_attempts(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT) - _assert_totp_not_replayed(pyotp.TOTP(existing.secret), existing, supplied_code) + _assert_totp_not_replayed(pyotp.TOTP(secret_plain), existing, supplied_code) await db.flush() # L-3: persist last_totp_counter immediately to block replay secret = pyotp.random_base32() @@ -604,7 +617,12 @@ async def enable_totp( status_code=status.HTTP_400_BAD_REQUEST, detail="TOTP setup not initiated. Call /auth/2fa/totp/setup first." ) - if not pyotp.TOTP(totp_record.secret).verify(body.code, valid_window=1): + try: + totp_verify = pyotp.TOTP(totp_record.secret).verify(body.code, valid_window=1) + except RuntimeError: + logger.exception("TOTP decryption failed for user_id=%s", totp_record.user_id) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="TOTP secret unavailable") + if not totp_verify: await record_failed_attempt(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid TOTP code") @@ -638,10 +656,25 @@ async def disable_totp( if not totp_record or not totp_record.is_enabled: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOTP is not enabled") - # Accept either a valid TOTP code or a valid backup code - totp_obj = pyotp.TOTP(totp_record.secret) - code_valid = totp_obj.verify(body.code, valid_window=1) - if code_valid: + # Accept either a valid TOTP code or a valid backup code. When the secret + # cannot be decrypted (encryption key lost), fall through to the backup- + # code path so the user can still disable 2FA with their printed codes. + totp_obj: pyotp.TOTP | None = None + code_valid = False + decryption_failed = False + try: + totp_obj = pyotp.TOTP(totp_record.secret) + code_valid = totp_obj.verify(body.code, valid_window=1) + except RuntimeError: + # S3: track that the failure was server-side so we don't penalise + # the user with a fail-counter increment for a problem they can't fix. + decryption_failed = True + logger.exception( + "TOTP decryption failed for user_id=%s — falling through to backup-code check", + totp_record.user_id, + ) + + if code_valid and totp_obj is not None: _assert_totp_not_replayed(totp_obj, totp_record, body.code) await db.flush() # L-3: persist last_totp_counter immediately to block replay else: @@ -652,7 +685,12 @@ async def disable_totp( code_valid = True if not code_valid: - await record_failed_attempt(db, current_user.username) + # S3: skip the fail-counter debit when the cause was a server-side + # decryption failure (key loss / rotation). The user submitted a + # wrong backup code on top of a broken TOTP, but locking them out + # of the recovery path for an admin's mistake is not the right move. + if not decryption_failed: + await record_failed_attempt(db, current_user.username) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid code") await db.execute(delete(UserTOTP).where(UserTOTP.user_id == current_user.id)) @@ -680,9 +718,24 @@ async def regenerate_backup_codes( if not totp_record or not totp_record.is_enabled: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOTP is not enabled") - totp_obj = pyotp.TOTP(totp_record.secret) - code_valid = totp_obj.verify(body.code, valid_window=1) - if code_valid: + # Same recovery contract as disable_totp: when the TOTP secret cannot be + # decrypted, fall through to the backup-code branch so the user can + # rotate their codes with a printed backup code. + totp_obj: pyotp.TOTP | None = None + code_valid = False + decryption_failed = False + try: + totp_obj = pyotp.TOTP(totp_record.secret) + code_valid = totp_obj.verify(body.code, valid_window=1) + except RuntimeError: + # S3: track server-side failure so we skip the fail-counter debit. + decryption_failed = True + logger.exception( + "TOTP decryption failed for user_id=%s — falling through to backup-code check", + totp_record.user_id, + ) + + if code_valid and totp_obj is not None: _assert_totp_not_replayed(totp_obj, totp_record, body.code) await db.flush() # L-3: persist last_totp_counter immediately to block replay else: @@ -692,7 +745,10 @@ async def regenerate_backup_codes( if pwd_context.verify(body.code, hashed) and matched_index is None: matched_index = idx if matched_index is None: - await record_failed_attempt(db, current_user.username) + # S3: skip fail-counter debit when the cause was a server-side + # decryption failure (key loss / rotation). + if not decryption_failed: + await record_failed_attempt(db, current_user.username) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid TOTP or backup code") # Remove the used backup code totp_record.backup_code_hashes = [c for i, c in enumerate(totp_record.backup_code_hashes) if i != matched_index] @@ -988,7 +1044,11 @@ async def verify_2fa( if not totp_record or not totp_record.is_enabled: await record_failed_attempt(db, username) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOTP is not enabled for this user") - totp_obj = pyotp.TOTP(totp_record.secret) + try: + totp_obj = pyotp.TOTP(totp_record.secret) + except RuntimeError: + logger.exception("TOTP decryption failed for user_id=%s", totp_record.user_id) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="TOTP secret unavailable") if not totp_obj.verify(body.code, valid_window=1): await record_failed_attempt(db, username) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid TOTP code") diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 5ff6452cf..d7013b875 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -455,6 +455,24 @@ async def create_backup_zip(output_path: Path | None = None) -> tuple[Path, str] except PermissionError as e: logger.warning("Permission denied copying %s: %s", name, e) + # Include the MFA encryption key as a ZIP top-level entry alongside + # bambuddy.db. Without it, encrypted client_secret / TOTP secret rows + # would be unrecoverable after restore on a host without MFA_ENCRYPTION_KEY set. + from backend.app.core.paths import resolve_data_dir + + mfa_key_src = resolve_data_dir() / ".mfa_encryption_key" + if mfa_key_src.exists() and mfa_key_src.is_file(): + try: + shutil.copy2(mfa_key_src, temp_path / ".mfa_encryption_key") + except OSError as exc: + logger.error( + "Could not include MFA encryption key in backup (%s). " + "The backup ZIP will not contain the key — restore on a " + "keyless host will fail for encrypted secrets.", + exc, + ) + raise + # Create ZIP if output_path is not None: zip_file = output_path / filename @@ -723,6 +741,18 @@ async def restore_backup( try: with zipfile.ZipFile(io.BytesIO(content), "r") as zf: + for name in zf.namelist(): + # Reject path-traversal payloads: any entry whose resolved + # path escapes temp_path would allow writing arbitrary files + # on the host (ZipSlip / CVE-2006-5456). + dest = (temp_path / name).resolve() + # is_relative_to (Python 3.9+) covers both relative + # path-traversal (../etc/passwd) and absolute-path overrides + # (/etc/passwd) — str.startswith was vulnerable to + # prefix-collision attacks (e.g. /tmp/abc_evil/file passing + # a /tmp/abc prefix check). + if not dest.is_relative_to(temp_path.resolve()): + raise HTTPException(400, f"Invalid backup: unsafe path in ZIP: {name!r}") zf.extractall(temp_path) except zipfile.BadZipFile: raise HTTPException(400, "Invalid backup file: not a valid ZIP") @@ -748,6 +778,54 @@ async def restore_backup( logger.info("Closing database connections...") await close_all_connections() + # B1: Restore the MFA encryption key file BEFORE the database swap. + # If the key write fails (OSError, RO disk, full disk, EACCES) we + # can still abort while the live DB is intact. Doing this AFTER the + # DB swap would leave the database with rows encrypted under the + # backup's key but the running install holding only the old key — + # every encrypted secret becomes unrecoverable. + from backend.app.core.paths import resolve_data_dir + + mfa_key_src = temp_path / ".mfa_encryption_key" + if mfa_key_src.exists() and mfa_key_src.is_file(): + dst_key = resolve_data_dir() / ".mfa_encryption_key" + tmp_key = dst_key.parent / ".mfa_encryption_key.restore-tmp" + try: + dst_key.parent.mkdir(parents=True, exist_ok=True) + # S1: atomic write with restrictive mode from creation. + # O_TRUNC because a stale tmp may exist from a prior + # failed restore attempt — we want to overwrite it. + fd = os.open(str(tmp_key), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + os.write(fd, mfa_key_src.read_bytes()) + finally: + os.close(fd) + # POSIX rename(2) — atomic when source/dest are on the + # same filesystem (we're staying inside dst_key.parent). + os.replace(str(tmp_key), str(dst_key)) + # S9: warn if the FS doesn't enforce 0o600 + actual_mode = dst_key.stat().st_mode & 0o777 + if actual_mode != 0o600: + logger.warning( + "Restored MFA key file %s: filesystem did not enforce 0o600 " + "(actual: 0o%o). Key may be world-readable on Windows / SMB / FUSE.", + dst_key, + actual_mode, + ) + logger.info("Restored .mfa_encryption_key from backup") + except OSError as e: + logger.error( + "Could not write restored MFA key file to %s: %s — " + "aborting BEFORE database swap (DB unchanged).", + dst_key, + e, + exc_info=True, + ) + raise HTTPException( + status_code=500, + detail=("Restore aborted: MFA key write failed. Database is unchanged. Check server logs."), + ) from e + # 5. Replace database logger.info("Restoring database from backup...") if is_sqlite(): @@ -828,7 +906,17 @@ async def restore_backup( logger.warning("Could not restore %s directory: %s", name, e) skipped_dirs.append(name) - # 7. Reinitialize the database engine and apply schema migrations so that + # 7. Reset the encryption singleton so the migration that runs + # inside init_db() picks up the restored key file (if a new one + # was written above). Without this reset, _get_fernet would + # return the cached Fernet instance built from the previous key. + import backend.app.core.encryption as _enc_mod + + _enc_mod._fernet_instance = None + _enc_mod._key_source = None + _enc_mod._warn_shown = False + + # 8. Reinitialize the database engine and apply schema migrations so that # tables added after the backup was created (e.g. ams_labels) exist # immediately, without requiring a manual restart. await reinitialize_database() @@ -843,6 +931,12 @@ async def restore_backup( "message": message, } + except HTTPException: + # Preserve specific HTTP error responses raised inside the restore + # body (e.g. the key-write OSError → 500). The blanket + # except Exception below would otherwise swallow them and replace + # the operator-facing detail with a generic message. + raise except Exception as e: logger.error("Restore failed: %s", e, exc_info=True) return JSONResponse( diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index f802f7ce0..6fc13b763 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -4,7 +4,6 @@ import logging import os import secrets from datetime import datetime, timedelta, timezone -from pathlib import Path from typing import Annotated import jwt @@ -49,13 +48,9 @@ def _get_jwt_secret() -> str: return env_secret # 2. Check for secret file in data directory - # Use DATA_DIR env var (same as rest of app), fallback to data/ subdirectory - data_dir_env = os.environ.get("DATA_DIR") - if data_dir_env: - data_dir = Path(data_dir_env) - else: - # Fallback to data/ subdirectory under project root (not project root itself!) - data_dir = Path(__file__).parent.parent.parent.parent / "data" + from backend.app.core.paths import resolve_data_dir + + data_dir = resolve_data_dir() secret_file = data_dir / ".jwt_secret" if secret_file.exists(): diff --git a/backend/app/core/config.py b/backend/app/core/config.py index f0323f961..48b33cd69 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,5 +1,6 @@ import logging import os +import re as _re from pathlib import Path from pydantic_settings import BaseSettings @@ -91,10 +92,39 @@ class Settings(BaseSettings): class Config: env_file = ".env" env_file_encoding = "utf-8" + # Don't reject unknown env vars — MFA_ENCRYPTION_KEY (#1219) and other + # operational env vars are read directly by their owning modules and + # never declared as Settings fields. + extra = "ignore" settings = Settings() +# S6: Warn on unknown MFA_*/BAMBUDDY_* env vars so typos like MFA_ENCYPTION_KEY +# are not silently swallowed by ``extra = "ignore"``. The original Pydantic +# behaviour rejected them outright and broke startup (#1219); we now accept +# them but log every unrecognised one at INFO so operators can spot mistakes. +_INTENTIONAL_UNSETTINGS = { + "MFA_ENCRYPTION_KEY", # encryption.py reads this directly + "DATA_DIR", # paths.py / config.py + "DATABASE_URL", # config.py (above) + "LOG_DIR", # config.py (above) + "LOG_LEVEL", # main.py logging setup + "BUG_REPORT_RELAY_URL", # config.py (above) +} + +_known_settings_fields = {f.upper() for f in settings.model_fields} + +for _env_key in os.environ: + if _re.match(r"^(MFA_|BAMBUDDY_)", _env_key, _re.IGNORECASE): + _norm = _env_key.upper() + if _norm not in _known_settings_fields and _norm not in _INTENTIONAL_UNSETTINGS: + logging.info( + "Unknown env var %r — not a declared Settings field. Possible typo? Recognised operational vars: %s", + _env_key, + sorted(_INTENTIONAL_UNSETTINGS), + ) + # Ensure directories exist settings.archive_dir.mkdir(parents=True, exist_ok=True) settings.plate_calibration_dir.mkdir(parents=True, exist_ok=True) diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 0b4d55d24..b650c7ac9 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -218,6 +218,13 @@ async def init_db(): # Run migrations for new columns (SQLite doesn't auto-add columns) await run_migrations(conn) + # Re-encrypt any legacy plaintext OIDC client_secret / TOTP secret rows + # that exist from before the encryption key was configured. + # Runs on a fresh AsyncSession (NOT the run_migrations() connection) so it + # doesn't share a transaction with the schema-DDL block above — required to + # avoid SQLite "database is locked" contention on the WAL writer. + await _migrate_encrypt_legacy_secrets() + # Seed default notification templates await seed_notification_templates() @@ -229,6 +236,134 @@ async def init_db(): await seed_color_catalog() +# B2: Module-level counter exposing the number of rows skipped during the last +# _migrate_encrypt_legacy_secrets() invocation. Surfaced via /encryption-status +# (migration_error_count) so operators can spot poison rows that need attention. +_migration_error_count: int = 0 + + +def get_migration_error_count() -> int: + """Return the number of rows that failed to re-encrypt during the last + _migrate_encrypt_legacy_secrets() run.""" + return _migration_error_count + + +async def _migrate_encrypt_legacy_secrets() -> None: + """Re-encrypt OIDC ``client_secret`` and TOTP ``secret`` rows that are still + stored as plaintext (no ``fernet:`` prefix). + + Called from :func:`init_db` after :func:`run_migrations` finishes. No-ops + when no encryption key is configured (so plaintext storage stays the + legacy behaviour for installs without a key). + + B2: per-row strategy — each row is committed in its own AsyncSession so a + single corrupt row does NOT block other successful re-encryptions on every + startup forever. The skipped-row count is exposed via + :func:`get_migration_error_count` and surfaced on /encryption-status. + + B3: unexpected (non-row) failures during the read phase are re-raised so + operators see the problem instead of silent data corruption — startup + fails loudly rather than running with half-migrated rows. + + Idempotent: rows that already start with ``fernet:`` are skipped, and the + write-phase re-checks the prefix before encrypting (guards against double + encryption from concurrent workers). + """ + from sqlalchemy import not_, select + + from backend.app.core.encryption import is_encryption_active + from backend.app.models.oidc_provider import OIDCProvider + from backend.app.models.user_totp import UserTOTP + + global _migration_error_count + + if not is_encryption_active(): + # Reset stale counter from a previous active-key run — we no longer + # have any rows to migrate, so the count must not leak across runs. + _migration_error_count = 0 + return + + # Phase 1 (read): collect (id, stored_value) tuples for plaintext rows. + # Read phase failures are startup-fatal — re-raise (B3). + try: + async with async_session() as ro: + oidc_rows = await ro.execute( + select(OIDCProvider.id, OIDCProvider._client_secret_enc).where( + not_(OIDCProvider._client_secret_enc.like("fernet:%")) + ) + ) + oidc_candidates = [(r[0], r[1]) for r in oidc_rows.all()] + totp_rows = await ro.execute( + select(UserTOTP.id, UserTOTP._secret_enc).where(not_(UserTOTP._secret_enc.like("fernet:%"))) + ) + totp_candidates = [(r[0], r[1]) for r in totp_rows.all()] + except Exception: + logger.error("_migrate_encrypt_legacy_secrets: phase 1 read failed", exc_info=True) + raise # B3 + + oidc_count = totp_count = error_count = 0 + + # Phase 2 (write): each row in its own AsyncSession + transaction. + # Failure of one row does NOT block the others. + for oidc_id, stored in oidc_candidates: + if not stored: + continue # defensive: skip empty strings + try: + async with async_session() as wr: + provider = await wr.get(OIDCProvider, oidc_id) + if provider is None: + continue # row deleted between phase 1 and phase 2 + # Idempotent guard: re-check inside the write session in case a + # concurrent worker beat us to it. + if not provider._client_secret_enc.startswith("fernet:"): + provider.client_secret = stored # setter -> mfa_encrypt + await wr.commit() + oidc_count += 1 + except Exception: + logger.error( + "Failed to re-encrypt OIDCProvider id=%s — skipping", + oidc_id, + exc_info=True, + ) + error_count += 1 + + for totp_id, stored in totp_candidates: + if not stored: + continue + try: + async with async_session() as wr: + totp = await wr.get(UserTOTP, totp_id) + if totp is None: + continue + if not totp._secret_enc.startswith("fernet:"): + totp.secret = stored + await wr.commit() + totp_count += 1 + except Exception: + logger.error( + "Failed to re-encrypt UserTOTP id=%s — skipping", + totp_id, + exc_info=True, + ) + error_count += 1 + + _migration_error_count = error_count + if oidc_count or totp_count: + logger.info( + "Re-encrypted legacy plaintext secrets: %d OIDC client_secret(s), %d TOTP secret(s)", + oidc_count, + totp_count, + ) + elif error_count == 0: + logger.debug("_migrate_encrypt_legacy_secrets: no rows needed re-encryption") + if error_count: + logger.error( + "_migrate_encrypt_legacy_secrets: %d row(s) skipped due to errors. " + "See /api/v1/auth/encryption-status (migration_error_count).", + error_count, + ) + + async def _safe_execute(conn, sql): """Execute a DDL migration statement, silently ignoring idempotency errors. diff --git a/backend/app/core/encryption.py b/backend/app/core/encryption.py index 21adb0805..f9c1ef994 100644 --- a/backend/app/core/encryption.py +++ b/backend/app/core/encryption.py @@ -1,52 +1,201 @@ """At-rest encryption for high-value secrets (TOTP keys, OIDC client_secret). -Set the ``MFA_ENCRYPTION_KEY`` environment variable to a URL-safe base64-encoded -32-byte key (generate with ``python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"``) -to enable Fernet symmetric encryption. +The encryption key is resolved on first use in this priority order: -When the key is not set, values are stored as plaintext and a warning is emitted. -Existing plaintext values are read back correctly even after the key is added -(values without the ``fernet:`` prefix are treated as legacy plaintext). +1. ``MFA_ENCRYPTION_KEY`` environment variable (must be a URL-safe base64 + string that decodes to exactly 32 bytes — the Fernet key format). +2. ``DATA_DIR/.mfa_encryption_key`` file (read if present and valid). A + corrupted or unreadable file falls back to plaintext (step 4) without + overwriting — to protect previously encrypted rows. +3. Auto-generate a new Fernet key, write to ``DATA_DIR/.mfa_encryption_key`` + with mode ``0o600`` (only when neither env var nor key file exists). + Falls back to plaintext (step 4) on OSError. +4. ``None`` (legacy plaintext fallback) — unreadable or corrupted key file, + or read-only filesystem. + +Existing plaintext values are read back correctly even after a key is +configured — values without the ``fernet:`` prefix are returned as-is. This +keeps the auto-bootstrap non-breaking for installs that already wrote +plaintext rows before the key existed. """ from __future__ import annotations +import base64 +import binascii import logging import os +from typing import Literal logger = logging.getLogger(__name__) _FERNET_PREFIX = "fernet:" _fernet_instance = None _warn_shown = False +# Public source values exposed via get_key_source(). Internal failure causes +# (none_write_failed, none_corrupted) are mapped to "none" before exposure +# so the public API stays stable for the EncryptionStatusResponse schema. +_PublicSource = Literal["env", "file", "generated", "none"] +# Internal source carries the specific failure cause for accurate logging. +# "none" remains valid for legacy test stubs (lambda: (None, "none")). +_InternalSource = Literal[ + "env", + "file", + "generated", + "none", + "none_write_failed", + "none_corrupted", +] +_key_source: _PublicSource | None = None + +_KEY_FILE_NAME = ".mfa_encryption_key" + + +def _validate_fernet_key(key: str) -> bool: + try: + decoded = base64.urlsafe_b64decode(key.encode()) + except (binascii.Error, ValueError): + return False + return len(decoded) == 32 + + +def _load_or_generate_key() -> tuple[str | None, _InternalSource]: + # Lazy import: keeps cryptography out of import-time even when the helper + # is patched in tests that never invoke encryption. + from cryptography.fernet import Fernet + + from backend.app.core.paths import resolve_data_dir + + # 1. Environment variable + env_key = os.environ.get("MFA_ENCRYPTION_KEY") + if env_key: + if _validate_fernet_key(env_key): + return env_key, "env" + logger.error( + "MFA_ENCRYPTION_KEY is set but is not a valid Fernet key " + "(must decode to exactly 32 bytes). Falling back to file-based key." + ) + + data_dir = resolve_data_dir() + key_file = data_dir / _KEY_FILE_NAME + + # 2. Existing file in DATA_DIR + if key_file.exists(): + try: + file_key = key_file.read_text().strip() + except OSError as exc: + # Refusing to fall through to regeneration — overwriting the file + # would destroy access to every row already encrypted under the + # current key. Operator must fix permissions or pin the key + # explicitly via MFA_ENCRYPTION_KEY. + logger.error( + "Failed to read existing MFA key file %s (%s). " + "Refusing to regenerate — this would destroy all previously encrypted secrets. " + "Fix the file permissions or set MFA_ENCRYPTION_KEY explicitly.", + key_file, + exc, + ) + return None, "none_corrupted" + if _validate_fernet_key(file_key): + return file_key, "file" + logger.error( + "%s is present but is not a valid Fernet key. " + "Refusing to overwrite — fix the file or set MFA_ENCRYPTION_KEY. " + "Falling back to plaintext storage.", + key_file, + ) + return None, "none_corrupted" + + # 3. Generate a new key and persist it. + # S1: Use os.open(O_WRONLY|O_CREAT|O_EXCL, 0o600) to avoid the TOCTOU + # window between write_text() (umask-respecting) and chmod() — the key + # is created with 0o600 from the start, never world-readable. + new_key = Fernet.generate_key().decode() + try: + data_dir.mkdir(parents=True, exist_ok=True) + fd = os.open(str(key_file), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + try: + os.write(fd, new_key.encode()) + finally: + os.close(fd) + # S9: Some filesystems (Windows, SMB, FUSE without uid mapping) silently + # ignore mode bits — verify and warn so operators know the key is not + # protected at the FS level. + actual_mode = key_file.stat().st_mode & 0o777 + if actual_mode != 0o600: + logger.warning( + "MFA key file %s: filesystem did not enforce 0o600 (actual: 0o%o). " + "Key may be world-readable on Windows / SMB / FUSE mounts.", + key_file, + actual_mode, + ) + logger.info("Generated new MFA encryption key and saved to %s", key_file) + return new_key, "generated" + except FileExistsError: + # Race between key_file.exists() check above and O_EXCL — another + # process created the file. Treat as corrupted (do NOT regenerate). + logger.error( + "Race detected creating %s (file appeared between check and create). " + "Refusing to overwrite — set MFA_ENCRYPTION_KEY explicitly to recover.", + key_file, + ) + return None, "none_corrupted" + except OSError as exc: + logger.error( + "Could not save MFA encryption key to %s (%s). " + "Falling back to plaintext storage. Set MFA_ENCRYPTION_KEY in the " + "environment or fix the data-dir permissions to enable encryption.", + key_file, + exc, + ) + return None, "none_write_failed" + + +def get_key_source() -> _PublicSource | None: + return _key_source + + +def is_encryption_active() -> bool: + return _get_fernet() is not None def _get_fernet(): - global _fernet_instance, _warn_shown + global _fernet_instance, _warn_shown, _key_source if _fernet_instance is not None: return _fernet_instance - key = os.environ.get("MFA_ENCRYPTION_KEY") - if key: - from cryptography.fernet import Fernet + key, internal_source = _load_or_generate_key() + # S8: collapse internal failure causes to public "none" while keeping + # the differentiated source for the warning path below. + _key_source = "none" if internal_source.startswith("none") else internal_source - _fernet_instance = Fernet(key.encode() if isinstance(key, str) else key) - return _fernet_instance + if key is None: + if not _warn_shown: + # S8: only emit the "DATA_DIR not writable" warning when that's + # actually the cause. The corrupted-file path already error-logged + # in _load_or_generate_key with a more specific message. + if internal_source == "none_write_failed": + logger.warning( + "MFA_ENCRYPTION_KEY is not set and DATA_DIR is not writable — " + "TOTP secrets and OIDC client_secrets are stored in plaintext. " + "Generate a key with: " + 'python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"' + ) + # Suppresses repetitive warnings across calls; reset together + # with _fernet_instance when re-initializing (e.g. in tests). + _warn_shown = True + return None - if not _warn_shown: - logger.warning( - "MFA_ENCRYPTION_KEY is not set — TOTP secrets and OIDC client_secrets are " - "stored in plaintext. Generate a key with: " - 'python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"' - ) - _warn_shown = True - return None + from cryptography.fernet import Fernet + + _fernet_instance = Fernet(key.encode()) + return _fernet_instance def mfa_encrypt(plaintext: str) -> str: """Encrypt a secret value. Returns the ciphertext with a ``fernet:`` prefix, - or the original plaintext if ``MFA_ENCRYPTION_KEY`` is not configured.""" + or the original plaintext if no encryption key is available.""" f = _get_fernet() if f is None: return plaintext @@ -60,12 +209,13 @@ def mfa_decrypt(value: str) -> str: Raises ``RuntimeError`` if the prefix is present but no key is configured. """ if not value.startswith(_FERNET_PREFIX): - # Nit6: Warn when a key IS configured but the stored value is plaintext. + # S7: Warn when a key IS configured but the stored value is plaintext. # This surfaces rows that were written before encryption was enabled so - # operators know they need a migration / re-enroll cycle. + # operators know they need a migration / re-enroll cycle. WARNING level + # so it shows up in normal operator log review. if _get_fernet() is not None: logger.warning( - "mfa_decrypt: MFA_ENCRYPTION_KEY is set but the stored value has no " + "mfa_decrypt: encryption key is active but the stored value has no " "'fernet:' prefix — returning legacy plaintext. Consider re-enrolling " "this secret to store it encrypted." ) @@ -80,9 +230,9 @@ def mfa_decrypt(value: str) -> str: try: return f.decrypt(value[len(_FERNET_PREFIX) :].encode()).decode() - except InvalidToken: + except InvalidToken as exc: raise RuntimeError( "MFA secret was encrypted under a different MFA_ENCRYPTION_KEY. " "Key rotation is not currently supported — restore the previous key " "or have users re-enroll." - ) + ) from exc diff --git a/backend/app/core/paths.py b/backend/app/core/paths.py new file mode 100644 index 000000000..685ca3502 --- /dev/null +++ b/backend/app/core/paths.py @@ -0,0 +1,26 @@ +"""Shared path resolution helpers. + +Centralises the DATA_DIR fallback used by ``auth.py`` (``.jwt_secret``) and +``encryption.py`` (``.mfa_encryption_key``) so both modules read the +environment variable fresh on every call. Reading fresh — instead of caching +the value at module import — is required so test fixtures can override +``DATA_DIR`` per-test via ``monkeypatch.setenv`` and have the override take +effect immediately. +""" + +from __future__ import annotations + +import os +from pathlib import Path + + +def resolve_data_dir() -> Path: + """Return the data directory, reading ``DATA_DIR`` fresh from env on each call. + + Falls back to ``/data`` when ``DATA_DIR`` is not set, matching + the behaviour of ``backend/app/core/auth.py:_get_jwt_secret``. + """ + data_dir_env = os.environ.get("DATA_DIR") + if data_dir_env: + return Path(data_dir_env) + return Path(__file__).parent.parent.parent.parent / "data" diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 4b0ee5233..bc0ec6ca8 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -493,3 +493,22 @@ class OIDCLinkResponse(BaseModel): provider_name: str provider_email: str | None = None created_at: str + + +class EncryptionRowCounts(BaseModel): + oidc_providers: int + user_totp: int + + +class EncryptionStatusResponse(BaseModel): + key_configured: bool + key_source: Literal["env", "file", "generated", "none"] + legacy_plaintext_rows: EncryptionRowCounts + encrypted_rows: EncryptionRowCounts + # B4: filled by the endpoint after a sample-decrypt of one encrypted row, + # so a wrong-key state (where key_configured=True but rows decrypt to junk) + # is detected, not just the no-key case. + decryption_broken: bool = False + # B2: number of rows skipped during the last legacy re-encryption migration. + # Filled from backend.app.core.database.get_migration_error_count(). + migration_error_count: int = 0 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 2bacda667..f573fbcd4 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -45,6 +45,42 @@ from backend.app.core.database import Base # noqa: E402 TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" +@pytest.fixture(autouse=True) +def mfa_encryption_isolation(monkeypatch, tmp_path): + """Per-test isolation for MFA encryption state. + + - Sets ``DATA_DIR`` to an isolated tmp path so the auto-bootstrap can + never write ``.mfa_encryption_key`` into the repo or share state + across tests / xdist workers. + - Removes any inherited ``MFA_ENCRYPTION_KEY`` env var. + - With ``DATA_DIR`` pointing at a writable ``tmp_path``, the default + bootstrap path on first ``_get_fernet()`` call is **auto-generation** + (key_source='generated'), NOT plaintext fallback. Tests that need the + plaintext fallback path must monkeypatch ``_load_or_generate_key`` to + return ``(None, 'none')`` (or 'none_write_failed' / 'none_corrupted') + explicitly — see ``test_plaintext_passthrough_without_key`` for an + example. + - Resets the ``encryption`` module-level singletons before AND after the + test so reorder doesn't leak cached Fernet instances. + + Tests that want to exercise an active key should call + ``monkeypatch.setenv("MFA_ENCRYPTION_KEY", valid_key)`` and + ``enc_mod._fernet_instance = None`` inside the test body — the autouse + fixture only sets defaults, it doesn't lock them in. + """ + from backend.app.core import encryption as enc_mod + + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + monkeypatch.delenv("MFA_ENCRYPTION_KEY", raising=False) + enc_mod._fernet_instance = None + enc_mod._warn_shown = False + enc_mod._key_source = None + yield + enc_mod._fernet_instance = None + enc_mod._warn_shown = False + enc_mod._key_source = None + + @pytest.fixture(scope="session") def event_loop(): """Create an instance of the default event loop for each test session.""" diff --git a/backend/tests/integration/test_security.py b/backend/tests/integration/test_security.py index c3fa42bdb..350b15061 100644 --- a/backend/tests/integration/test_security.py +++ b/backend/tests/integration/test_security.py @@ -90,64 +90,265 @@ def _make_test_rsa_key(): class TestEncryption: - """encrypt/decrypt round-trips, plaintext passthrough, RuntimeError on missing key.""" + """encrypt/decrypt round-trips, plaintext passthrough, RuntimeError on missing key. - def test_encrypt_decrypt_roundtrip_with_key(self): + The ``mfa_encryption_isolation`` autouse fixture (conftest.py) resets the + ``encryption`` module's globals before/after each test and points + ``DATA_DIR`` at a tmp path, so individual tests only need to set + ``MFA_ENCRYPTION_KEY`` when they want a specific key in scope. + """ + + def test_encrypt_decrypt_roundtrip_with_key(self, monkeypatch): from cryptography.fernet import Fernet + import backend.app.core.encryption as enc_mod + test_key = Fernet.generate_key().decode() + monkeypatch.setenv("MFA_ENCRYPTION_KEY", test_key) + # Force re-initialisation now that the env var is set. + enc_mod._fernet_instance = None + + ciphertext = enc_mod.mfa_encrypt("my-totp-secret") + assert ciphertext.startswith("fernet:") + assert enc_mod.mfa_decrypt(ciphertext) == "my-totp-secret" + + def test_plaintext_passthrough_without_key(self, monkeypatch): + # Force the auto-bootstrap into the legacy "no key available" branch + # by patching _load_or_generate_key directly. This is more robust than + # chmod tricks (which root bypasses) when verifying the plaintext path. + import backend.app.core.encryption as enc_mod + + monkeypatch.setattr(enc_mod, "_load_or_generate_key", lambda: (None, "none")) + enc_mod._fernet_instance = None + + result = enc_mod.mfa_encrypt("plaintext-secret") + assert result == "plaintext-secret" + assert enc_mod.mfa_decrypt("plaintext-secret") == "plaintext-secret" + + def test_decrypt_raises_runtime_error_without_key_for_encrypted_value(self, monkeypatch): + import backend.app.core.encryption as enc_mod + + monkeypatch.setattr(enc_mod, "_load_or_generate_key", lambda: (None, "none")) + enc_mod._fernet_instance = None + + with pytest.raises(RuntimeError, match="MFA_ENCRYPTION_KEY must be set"): + enc_mod.mfa_decrypt("fernet:gAAAAA-fake-ciphertext") + + # ------------------------------------------------------------------ + # Auto-bootstrap tests for _load_or_generate_key + # ------------------------------------------------------------------ + + def test_load_or_generate_key_uses_env_when_set(self, monkeypatch, tmp_path): + """Valid env var → key_source == 'env', no file written.""" + from cryptography.fernet import Fernet import backend.app.core.encryption as enc_mod - original = enc_mod._fernet_instance - original_warn = enc_mod._warn_shown - try: - enc_mod._fernet_instance = None - enc_mod._warn_shown = False - with patch.dict("os.environ", {"MFA_ENCRYPTION_KEY": test_key}): - ciphertext = enc_mod.mfa_encrypt("my-totp-secret") - assert ciphertext.startswith("fernet:") - assert enc_mod.mfa_decrypt(ciphertext) == "my-totp-secret" - finally: - enc_mod._fernet_instance = original - enc_mod._warn_shown = original_warn + valid_key = Fernet.generate_key().decode() + monkeypatch.setenv("MFA_ENCRYPTION_KEY", valid_key) + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + enc_mod._fernet_instance = None + + key, source = enc_mod._load_or_generate_key() + + assert key == valid_key + assert source == "env" + assert not (tmp_path / ".mfa_encryption_key").exists() + + def test_invalid_env_key_falls_through_to_file(self, monkeypatch, tmp_path, caplog): + """Invalid env var → logger.error + file fallback (auto-generated).""" + import logging - def test_plaintext_passthrough_without_key(self): import backend.app.core.encryption as enc_mod - original = enc_mod._fernet_instance - original_warn = enc_mod._warn_shown - try: - enc_mod._fernet_instance = None - enc_mod._warn_shown = False - with patch.dict("os.environ", {}, clear=True): - env = {k: v for k, v in __import__("os").environ.items() if k != "MFA_ENCRYPTION_KEY"} - with patch.dict("os.environ", env, clear=True): - result = enc_mod.mfa_encrypt("plaintext-secret") - assert result == "plaintext-secret" - assert enc_mod.mfa_decrypt("plaintext-secret") == "plaintext-secret" - finally: - enc_mod._fernet_instance = original - enc_mod._warn_shown = original_warn + monkeypatch.setenv("MFA_ENCRYPTION_KEY", "not-a-valid-fernet-key") + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + enc_mod._fernet_instance = None + + with caplog.at_level(logging.ERROR, logger="backend.app.core.encryption"): + key, source = enc_mod._load_or_generate_key() + + assert source == "generated" + assert key is not None + assert (tmp_path / ".mfa_encryption_key").exists() + assert any("not a valid Fernet key" in rec.message for rec in caplog.records) + + def test_load_or_generate_key_reads_existing_file(self, monkeypatch, tmp_path): + """File present in DATA_DIR + no env var → key_source == 'file'.""" + from cryptography.fernet import Fernet - def test_decrypt_raises_runtime_error_without_key_for_encrypted_value(self): import backend.app.core.encryption as enc_mod - original = enc_mod._fernet_instance - original_warn = enc_mod._warn_shown - try: - enc_mod._fernet_instance = None - enc_mod._warn_shown = False - # A value with the fernet: prefix but no key configured - env = {k: v for k, v in __import__("os").environ.items() if k != "MFA_ENCRYPTION_KEY"} - with ( - patch.dict("os.environ", env, clear=True), - pytest.raises(RuntimeError, match="MFA_ENCRYPTION_KEY must be set"), - ): - enc_mod.mfa_decrypt("fernet:gAAAAA-fake-ciphertext") - finally: - enc_mod._fernet_instance = original - enc_mod._warn_shown = original_warn + existing_key = Fernet.generate_key().decode() + key_file = tmp_path / ".mfa_encryption_key" + key_file.write_text(existing_key) + + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + enc_mod._fernet_instance = None + + key, source = enc_mod._load_or_generate_key() + + assert key == existing_key + assert source == "file" + + def test_load_or_generate_key_creates_file_with_0600(self, monkeypatch, tmp_path): + """Neither env nor file → new key generated, file mode is 0o600.""" + import backend.app.core.encryption as enc_mod + + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + enc_mod._fernet_instance = None + + key, source = enc_mod._load_or_generate_key() + + assert source == "generated" + assert enc_mod._validate_fernet_key(key) + key_file = tmp_path / ".mfa_encryption_key" + assert key_file.exists() + # Mode bits LSB are 0o600 — owner read+write only. + assert (key_file.stat().st_mode & 0o777) == 0o600 + + def test_load_or_generate_key_returns_none_on_write_oserror(self, monkeypatch, tmp_path, caplog): + """When DATA_DIR can't be written to (auto-generate path), return (None, 'none_write_failed'). + + S1: write now uses os.open(O_EXCL|O_CREAT, 0o600) instead of write_text — patch + os.write to simulate the OS-level failure. S8: source distinguishes write-failed + from corrupted to drive accurate operator messaging. + """ + import logging + import os + + import backend.app.core.encryption as enc_mod + + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + enc_mod._fernet_instance = None + + original_write = os.write + + def _raising_write(fd, data): + # Best-effort: trigger OSError specifically for the key write. + raise OSError("simulated read-only filesystem") + + monkeypatch.setattr(os, "write", _raising_write) + + with caplog.at_level(logging.ERROR, logger="backend.app.core.encryption"): + key, source = enc_mod._load_or_generate_key() + + # Restore os.write so the rest of the test suite is unaffected. + monkeypatch.setattr(os, "write", original_write) + + assert key is None + assert source == "none_write_failed" + assert any("Could not save MFA encryption key" in rec.message for rec in caplog.records) + + def test_load_or_generate_key_returns_none_on_read_oserror(self, monkeypatch, tmp_path, caplog): + """B4: existing key file but read fails (e.g. permission denied) → (None, 'none_corrupted'). + + Critical: must NOT regenerate a new key, which would destroy access to + every row already encrypted under the existing key. S8: 'none_corrupted' + marks the cause so operators see the right diagnostic. + """ + import logging + from pathlib import Path + + import backend.app.core.encryption as enc_mod + + # Pre-create a key file so we hit the existing-file branch. + key_file = tmp_path / ".mfa_encryption_key" + key_file.write_text("placeholder-content") + original_size = key_file.stat().st_size + + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + enc_mod._fernet_instance = None + + original_read_text = Path.read_text + + def _raising_read_text(self, *args, **kwargs): + if self.name == ".mfa_encryption_key": + raise OSError("simulated permission denied") + return original_read_text(self, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", _raising_read_text) + + with caplog.at_level(logging.ERROR, logger="backend.app.core.encryption"): + key, source = enc_mod._load_or_generate_key() + + assert key is None + assert source == "none_corrupted" + # Critical: file must not have been overwritten with a new key. + assert key_file.exists() + assert key_file.stat().st_size == original_size + assert any("Failed to read existing MFA key file" in rec.message for rec in caplog.records) + assert any("Refusing to regenerate" in rec.message for rec in caplog.records) + + def test_get_key_source_reflects_active_source(self, monkeypatch, tmp_path): + """get_key_source() returns the source detected on the most recent _get_fernet() call.""" + from cryptography.fernet import Fernet + + import backend.app.core.encryption as enc_mod + + monkeypatch.setenv("MFA_ENCRYPTION_KEY", Fernet.generate_key().decode()) + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + enc_mod._fernet_instance = None + enc_mod._key_source = None + + # Trigger lazy initialisation + enc_mod.mfa_encrypt("anything") + + assert enc_mod.get_key_source() == "env" + + def test_corrupted_key_file_returns_none_without_overwrite(self, monkeypatch, tmp_path, caplog): + """A1: invalid key file content → (None, 'none_corrupted'), file not overwritten. + + S8: 'none_corrupted' (vs 'none_write_failed') so operators get the right + diagnostic and don't see a misleading 'DATA_DIR not writable' warning. + """ + import logging + + import backend.app.core.encryption as enc_mod + + key_file = tmp_path / ".mfa_encryption_key" + key_file.write_text("invalid_content") + original_mtime = key_file.stat().st_mtime + + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + enc_mod._fernet_instance = None + + with caplog.at_level(logging.ERROR, logger="backend.app.core.encryption"): + key, source = enc_mod._load_or_generate_key() + + assert key is None + assert source == "none_corrupted" + assert key_file.exists(), "file must not be deleted" + assert key_file.stat().st_mtime == original_mtime, "file must not be overwritten" + assert any("not a valid Fernet key" in rec.message for rec in caplog.records) + assert any("Refusing to overwrite" in rec.message for rec in caplog.records) + + def test_auto_generate_fileexistserror_returns_none_corrupted(self, monkeypatch, tmp_path, caplog): + """S1: O_EXCL race — file appears between exists() check and open() → + return (None, 'none_corrupted') without overwriting.""" + import logging + import os + + import backend.app.core.encryption as enc_mod + + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + enc_mod._fernet_instance = None + + original_open = os.open + + def _excl_raise(path, flags, mode=0o777): + if str(path).endswith(".mfa_encryption_key") and (flags & os.O_EXCL): + raise FileExistsError(17, "File exists", str(path)) + return original_open(path, flags, mode) + + monkeypatch.setattr(os, "open", _excl_raise) + + with caplog.at_level(logging.ERROR, logger="backend.app.core.encryption"): + key, source = enc_mod._load_or_generate_key() + + assert key is None + assert source == "none_corrupted" + assert any("Race detected" in rec.message for rec in caplog.records) # =========================================================================== @@ -794,3 +995,1806 @@ class TestRateLimitBuckets: assert status_codes[-1] == 429, ( f"Expected 429 after {MAX_LOGIN_ATTEMPTS} username-spray failures, got: {status_codes}" ) + + +# ============================================================================ +# TestEncryptLegacyMigration +# ============================================================================ + + +class TestEncryptLegacyMigration: + """Re-encryption migration of legacy plaintext OIDC + TOTP rows. + + The migration runs against its own ``async_session`` factory (not the + ``db_session`` fixture) so each test patches the module-level factory to + point at the test-engine before invoking the helper. ``db_session`` is + used to seed and to verify state via the same engine. + """ + + @staticmethod + def _patch_module_session(monkeypatch, db_session): + """Bind ``database.async_session`` to the test engine for one test.""" + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + + from backend.app.core import database as db_mod + + test_factory = async_sessionmaker(db_session.bind, class_=AsyncSession, expire_on_commit=False) + monkeypatch.setattr(db_mod, "async_session", test_factory) + + @staticmethod + def _set_active_key(monkeypatch): + """Configure a valid Fernet key for the migration to use.""" + from cryptography.fernet import Fernet + + import backend.app.core.encryption as enc_mod + + monkeypatch.setenv("MFA_ENCRYPTION_KEY", Fernet.generate_key().decode()) + enc_mod._fernet_instance = None + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_migration_encrypts_plaintext_oidc_secret(self, db_session, monkeypatch): + from sqlalchemy import select + + from backend.app.core.database import _migrate_encrypt_legacy_secrets + from backend.app.models.oidc_provider import OIDCProvider + + self._patch_module_session(monkeypatch, db_session) + self._set_active_key(monkeypatch) + + provider = OIDCProvider( + name="LegacyProv", + issuer_url="https://legacy.example.com", + client_id="cid", + _client_secret_enc="legacy-plaintext", + scopes="openid email profile", + is_enabled=True, + ) + db_session.add(provider) + await db_session.commit() + + await _migrate_encrypt_legacy_secrets() + + # Re-fetch on a fresh row state + await db_session.refresh(provider) + assert provider._client_secret_enc.startswith("fernet:") + # Decrypted value matches the original plaintext + assert provider.client_secret == "legacy-plaintext" + + # Sanity: a SELECT also sees the encrypted value + result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == provider.id)) + fetched = result.scalar_one() + assert fetched._client_secret_enc.startswith("fernet:") + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_migration_skips_already_encrypted_rows(self, db_session, monkeypatch): + from backend.app.core.database import _migrate_encrypt_legacy_secrets + from backend.app.models.oidc_provider import OIDCProvider + + self._patch_module_session(monkeypatch, db_session) + self._set_active_key(monkeypatch) + + # Use the property setter so the value is encrypted up front. + provider = OIDCProvider( + name="EncProv", + issuer_url="https://enc.example.com", + client_id="cid", + client_secret="already-encrypted", + scopes="openid email profile", + is_enabled=True, + ) + db_session.add(provider) + await db_session.commit() + + original_enc = provider._client_secret_enc + await _migrate_encrypt_legacy_secrets() + await _migrate_encrypt_legacy_secrets() # idempotent + + await db_session.refresh(provider) + # Value unchanged across two migration runs (still the same ciphertext). + assert provider._client_secret_enc == original_enc + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_migration_no_op_when_key_unset(self, db_session, monkeypatch): + import backend.app.core.encryption as enc_mod + from backend.app.core.database import _migrate_encrypt_legacy_secrets + from backend.app.models.oidc_provider import OIDCProvider + + self._patch_module_session(monkeypatch, db_session) + # Force "no key" branch + monkeypatch.setattr(enc_mod, "_load_or_generate_key", lambda: (None, "none")) + enc_mod._fernet_instance = None + + provider = OIDCProvider( + name="NoKeyProv", + issuer_url="https://nokey.example.com", + client_id="cid", + _client_secret_enc="still-plaintext", + scopes="openid email profile", + is_enabled=True, + ) + db_session.add(provider) + await db_session.commit() + + await _migrate_encrypt_legacy_secrets() + await db_session.refresh(provider) + # Migration should have early-returned; plaintext untouched. + assert provider._client_secret_enc == "still-plaintext" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_migration_handles_mixed_state(self, db_session, monkeypatch): + from backend.app.core.database import _migrate_encrypt_legacy_secrets + from backend.app.models.oidc_provider import OIDCProvider + + self._patch_module_session(monkeypatch, db_session) + self._set_active_key(monkeypatch) + + legacy = OIDCProvider( + name="LegacyMix", + issuer_url="https://l.example.com", + client_id="c1", + _client_secret_enc="plain-mix", + scopes="openid email profile", + ) + encrypted = OIDCProvider( + name="EncMix", + issuer_url="https://e.example.com", + client_id="c2", + client_secret="encrypted-mix", # uses setter + scopes="openid email profile", + ) + db_session.add_all([legacy, encrypted]) + await db_session.commit() + + original_encrypted = encrypted._client_secret_enc + + await _migrate_encrypt_legacy_secrets() + + await db_session.refresh(legacy) + await db_session.refresh(encrypted) + assert legacy._client_secret_enc.startswith("fernet:") + assert legacy.client_secret == "plain-mix" + assert encrypted._client_secret_enc == original_encrypted + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_migration_encrypts_plaintext_totp_secret(self, db_session, monkeypatch): + from backend.app.core.database import _migrate_encrypt_legacy_secrets + from backend.app.models.user import User + from backend.app.models.user_totp import UserTOTP + + self._patch_module_session(monkeypatch, db_session) + self._set_active_key(monkeypatch) + + user = User(username="totpuser1219", email="t@example.com", password_hash="x") + db_session.add(user) + await db_session.flush() + + totp = UserTOTP(user_id=user.id, _secret_enc="JBSWY3DPEHPK3PXP", is_enabled=True) + db_session.add(totp) + await db_session.commit() + + await _migrate_encrypt_legacy_secrets() + + await db_session.refresh(totp) + assert totp._secret_enc.startswith("fernet:") + assert totp.secret == "JBSWY3DPEHPK3PXP" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_migration_logs_count_of_rows_re_encrypted(self, db_session, monkeypatch, caplog): + import logging + + from backend.app.core.database import _migrate_encrypt_legacy_secrets + from backend.app.models.oidc_provider import OIDCProvider + from backend.app.models.user import User + from backend.app.models.user_totp import UserTOTP + + self._patch_module_session(monkeypatch, db_session) + self._set_active_key(monkeypatch) + + provider = OIDCProvider( + name="LegacyLog", + issuer_url="https://log.example.com", + client_id="c", + _client_secret_enc="p", + scopes="openid email profile", + ) + user = User(username="logger1219", email="l@example.com", password_hash="x") + db_session.add_all([provider, user]) + await db_session.flush() + totp = UserTOTP(user_id=user.id, _secret_enc="JBSWY3DPEHPK3PXP", is_enabled=True) + db_session.add(totp) + await db_session.commit() + + with caplog.at_level(logging.INFO, logger="backend.app.core.database"): + await _migrate_encrypt_legacy_secrets() + + # The migration logs once with both counts. + assert any( + "Re-encrypted legacy plaintext secrets" in rec.message + and "1 OIDC client_secret(s)" in rec.message + and "1 TOTP secret(s)" in rec.message + for rec in caplog.records + ) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_migration_continues_on_row_error(self, db_session, monkeypatch, caplog): + """B2: per-row commit semantics — when one row fails to re-encrypt, + OTHER successfully-encrypted rows must remain committed and the + failure surfaces via get_migration_error_count. + + Replaces the previous "rollback all" behaviour: a single poison row + used to block every successful re-encryption on every startup forever. + """ + import logging + + import backend.app.core.encryption as enc_mod # noqa: F401 + from backend.app.core.database import ( + _migrate_encrypt_legacy_secrets, + get_migration_error_count, + ) + from backend.app.models.oidc_provider import OIDCProvider + + self._patch_module_session(monkeypatch, db_session) + self._set_active_key(monkeypatch) + + good = OIDCProvider( + name="GoodRow", + issuer_url="https://good.example.com", + client_id="c1", + _client_secret_enc="plaintext-good", + scopes="openid email profile", + ) + bad = OIDCProvider( + name="BadRow", + issuer_url="https://bad.example.com", + client_id="c2", + _client_secret_enc="plaintext-bad", + scopes="openid email profile", + ) + db_session.add_all([good, bad]) + await db_session.commit() + + original_bad = bad._client_secret_enc + + # Force the setter on the SECOND row to raise — patch at the model's + # import location so the property setter picks up the patched function. + import backend.app.models.oidc_provider as oidc_mod + + real_encrypt = oidc_mod.mfa_encrypt + call_count = [0] + + def _sometimes_raise(value): + call_count[0] += 1 + if call_count[0] == 2: + raise RuntimeError("simulated encrypt failure") + return real_encrypt(value) + + monkeypatch.setattr(oidc_mod, "mfa_encrypt", _sometimes_raise) + + with caplog.at_level(logging.ERROR, logger="backend.app.core.database"): + await _migrate_encrypt_legacy_secrets() + + # B2: per-row commit — good IS encrypted, bad is unchanged. + await db_session.refresh(good) + await db_session.refresh(bad) + assert good._client_secret_enc.startswith("fernet:"), ( + "good row must be successfully re-encrypted (per-row commit)" + ) + assert bad._client_secret_enc == original_bad, "bad row must remain unchanged (savepoint-style isolation)" + assert get_migration_error_count() == 1, "the skipped row must be exposed via get_migration_error_count" + assert any("skipping" in rec.message.lower() for rec in caplog.records) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_migration_logs_no_op_when_all_encrypted(self, db_session, monkeypatch, caplog): + """A2: when all rows are already encrypted, migration logs a debug no-op.""" + import logging + + from backend.app.core.database import _migrate_encrypt_legacy_secrets + from backend.app.models.oidc_provider import OIDCProvider + + self._patch_module_session(monkeypatch, db_session) + self._set_active_key(monkeypatch) + + provider = OIDCProvider( + name="AlreadyEnc", + issuer_url="https://ae.example.com", + client_id="cae", + client_secret="already-encrypted", + scopes="openid email profile", + ) + db_session.add(provider) + await db_session.commit() + + with caplog.at_level(logging.DEBUG, logger="backend.app.core.database"): + await _migrate_encrypt_legacy_secrets() + + assert any("no rows needed re-encryption" in rec.message for rec in caplog.records) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_init_db_propagates_unexpected_migration_error(self, monkeypatch): + """B3: an unexpected error from _migrate_encrypt_legacy_secrets must + surface (re-raise) instead of being silently swallowed. + + Pins the contract introduced for B3: a startup-fatal error like a + session-creation failure must fail the lifespan / CLI / restore + handler explicitly, never run the app with half-migrated rows. + + Implementation note: we patch _migrate_encrypt_legacy_secrets itself + rather than poking the inner read phase, because that is the contract + boundary the rest of the codebase relies on (init_db -> migration). + """ + import backend.app.core.database as db_mod + + async def boom(): + raise RuntimeError("simulated startup-fatal failure") + + # Stub out the rest of init_db so we exercise only the migration step. + # init_db opens the engine.begin() block, runs metadata.create_all, + # run_migrations, then awaits _migrate_encrypt_legacy_secrets — the + # only call we want to fail. + monkeypatch.setattr(db_mod, "_migrate_encrypt_legacy_secrets", boom) + monkeypatch.setattr(db_mod, "seed_notification_templates", lambda: _noop_async()) + monkeypatch.setattr(db_mod, "seed_default_groups", lambda: _noop_async()) + monkeypatch.setattr(db_mod, "seed_spool_catalog", lambda: _noop_async()) + monkeypatch.setattr(db_mod, "seed_color_catalog", lambda: _noop_async()) + + with pytest.raises(RuntimeError, match="simulated startup-fatal failure"): + await db_mod.init_db() + + +async def _noop_async(): + """Helper for tests that need to stub out `seed_*` async coroutines.""" + return None + + +# ============================================================================ +# TestEncryptionStatusEndpoint +# ============================================================================ + + +class TestEncryptionStatusEndpoint: + """GET /api/v1/auth/encryption-status: key source, counts, decryption_broken.""" + + STATUS_URL = "/api/v1/auth/encryption-status" + + async def _create_admin_and_login(self, async_client: AsyncClient) -> str: + """Bootstrap auth + return a Bearer token for an admin.""" + await async_client.post( + "/api/v1/auth/setup", + json={ + "auth_enabled": True, + "admin_username": "admin1219", + "admin_password": "Admin1219!Pass", + }, + ) + login = await async_client.post( + "/api/v1/auth/login", + json={"username": "admin1219", "password": "Admin1219!Pass"}, + ) + assert login.status_code == 200, login.text + return login.json()["access_token"] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_status_reports_env_source(self, async_client, monkeypatch): + from cryptography.fernet import Fernet + + import backend.app.core.encryption as enc_mod + + token = await self._create_admin_and_login(async_client) + monkeypatch.setenv("MFA_ENCRYPTION_KEY", Fernet.generate_key().decode()) + enc_mod._fernet_instance = None + enc_mod._key_source = None + + resp = await async_client.get(self.STATUS_URL, headers={"Authorization": f"Bearer {token}"}) + assert resp.status_code == 200 + data = resp.json() + assert data["key_configured"] is True + assert data["key_source"] == "env" + assert data["decryption_broken"] is False + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_status_reports_file_source(self, async_client, monkeypatch, tmp_path): + from cryptography.fernet import Fernet + + import backend.app.core.encryption as enc_mod + + token = await self._create_admin_and_login(async_client) + # Pre-place a valid key file in DATA_DIR. + key_file = tmp_path / ".mfa_encryption_key" + key_file.write_text(Fernet.generate_key().decode()) + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + monkeypatch.delenv("MFA_ENCRYPTION_KEY", raising=False) + enc_mod._fernet_instance = None + enc_mod._key_source = None + + resp = await async_client.get(self.STATUS_URL, headers={"Authorization": f"Bearer {token}"}) + assert resp.status_code == 200 + data = resp.json() + assert data["key_source"] == "file" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_status_reports_generated_source(self, async_client, monkeypatch, tmp_path): + import backend.app.core.encryption as enc_mod + + token = await self._create_admin_and_login(async_client) + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + monkeypatch.delenv("MFA_ENCRYPTION_KEY", raising=False) + enc_mod._fernet_instance = None + enc_mod._key_source = None + + resp = await async_client.get(self.STATUS_URL, headers={"Authorization": f"Bearer {token}"}) + assert resp.status_code == 200 + data = resp.json() + assert data["key_source"] == "generated" + assert (tmp_path / ".mfa_encryption_key").exists() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_status_reports_none_source(self, async_client, monkeypatch): + import backend.app.core.encryption as enc_mod + + token = await self._create_admin_and_login(async_client) + monkeypatch.setattr(enc_mod, "_load_or_generate_key", lambda: (None, "none")) + enc_mod._fernet_instance = None + enc_mod._key_source = None + + resp = await async_client.get(self.STATUS_URL, headers={"Authorization": f"Bearer {token}"}) + assert resp.status_code == 200 + data = resp.json() + assert data["key_configured"] is False + assert data["key_source"] == "none" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_status_counts_legacy_rows(self, async_client, db_session, monkeypatch): + from backend.app.models.oidc_provider import OIDCProvider + + token = await self._create_admin_and_login(async_client) + + provider = OIDCProvider( + name="LegacyStatus", + issuer_url="https://ls.example.com", + client_id="c", + _client_secret_enc="plaintext-no-prefix", + scopes="openid email profile", + ) + db_session.add(provider) + await db_session.commit() + + resp = await async_client.get(self.STATUS_URL, headers={"Authorization": f"Bearer {token}"}) + assert resp.status_code == 200 + data = resp.json() + assert data["legacy_plaintext_rows"]["oidc_providers"] >= 1 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_status_counts_encrypted_rows(self, async_client, db_session, monkeypatch): + from cryptography.fernet import Fernet + + import backend.app.core.encryption as enc_mod + from backend.app.models.oidc_provider import OIDCProvider + + token = await self._create_admin_and_login(async_client) + monkeypatch.setenv("MFA_ENCRYPTION_KEY", Fernet.generate_key().decode()) + enc_mod._fernet_instance = None + enc_mod._key_source = None + + provider = OIDCProvider( + name="EncStatus", + issuer_url="https://es.example.com", + client_id="c", + client_secret="real-secret", # via setter → encrypted + scopes="openid email profile", + ) + db_session.add(provider) + await db_session.commit() + + resp = await async_client.get(self.STATUS_URL, headers={"Authorization": f"Bearer {token}"}) + assert resp.status_code == 200 + data = resp.json() + assert data["encrypted_rows"]["oidc_providers"] >= 1 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_status_warns_on_encrypted_rows_without_key(self, async_client, db_session, monkeypatch): + """Gap 2: encrypted rows present but no key loadable → decryption_broken=true.""" + import backend.app.core.encryption as enc_mod + from backend.app.models.oidc_provider import OIDCProvider + + token = await self._create_admin_and_login(async_client) + + # Insert a row whose value is already prefixed (simulates a previously-encrypted row). + provider = OIDCProvider( + name="BrokenEnc", + issuer_url="https://be.example.com", + client_id="c", + _client_secret_enc="fernet:gAAAAA-fake-but-prefixed", + scopes="openid email profile", + ) + db_session.add(provider) + await db_session.commit() + + # Now disable key loading so decryption is impossible. + monkeypatch.setattr(enc_mod, "_load_or_generate_key", lambda: (None, "none")) + enc_mod._fernet_instance = None + enc_mod._key_source = None + + resp = await async_client.get(self.STATUS_URL, headers={"Authorization": f"Bearer {token}"}) + assert resp.status_code == 200 + data = resp.json() + assert data["key_configured"] is False + assert data["encrypted_rows"]["oidc_providers"] >= 1 + assert data["decryption_broken"] is True + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_status_requires_settings_read_permission(self, async_client, db_session): + """Non-admin without settings:read permission gets 403.""" + from backend.app.models.user import User + + await self._create_admin_and_login(async_client) + + # Create a low-privilege user (no group → no permissions in default seed). + from backend.app.core.auth import get_password_hash + + viewer = User( + username="viewer1219", + email="viewer1219@example.com", + password_hash=get_password_hash("Viewer1219!Pass"), + role="user", + is_active=True, + ) + db_session.add(viewer) + await db_session.commit() + + login = await async_client.post( + "/api/v1/auth/login", + json={"username": "viewer1219", "password": "Viewer1219!Pass"}, + ) + assert login.status_code == 200, login.text + token = login.json().get("access_token") + assert token is not None, f"Expected access_token in login response, got: {login.json()}" + + resp = await async_client.get(self.STATUS_URL, headers={"Authorization": f"Bearer {token}"}) + assert resp.status_code == 403 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_status_returns_500_on_db_error(self, async_client, monkeypatch): + """A8: SQLAlchemyError during count queries → 500 with static message.""" + from unittest.mock import AsyncMock + + from sqlalchemy.exc import SQLAlchemyError + + token = await self._create_admin_and_login(async_client) + + async def _raise(*args, **kwargs): + raise SQLAlchemyError("simulated DB failure") + + monkeypatch.setattr("sqlalchemy.ext.asyncio.AsyncSession.execute", AsyncMock(side_effect=_raise)) + + resp = await async_client.get(self.STATUS_URL, headers={"Authorization": f"Bearer {token}"}) + assert resp.status_code == 500 + assert "encryption status" in resp.json().get("detail", "").lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_status_returns_403_for_viewer_in_viewers_group(self, async_client, db_session): + """S2: a user in the Viewers group (has SETTINGS_READ but NOT SETTINGS_UPDATE) + must get 403 — encryption-status is admin/operator only. + """ + from sqlalchemy import insert, select + + from backend.app.core.auth import get_password_hash + from backend.app.models.group import Group, user_groups + from backend.app.models.user import User + + # Bootstrap auth (creates default groups via setup endpoint). + await self._create_admin_and_login(async_client) + + # Create a user explicitly in the Viewers group — it has SETTINGS_READ + # but not SETTINGS_UPDATE, which is the discriminator for S2. + viewer = User( + username="viewer_s2", + email="viewer_s2@example.com", + password_hash=get_password_hash("ViewerS2!Pass1"), + role="user", + is_active=True, + ) + db_session.add(viewer) + await db_session.flush() + + viewers_group = (await db_session.execute(select(Group).where(Group.name == "Viewers"))).scalar_one_or_none() + assert viewers_group is not None, "Viewers group must be seeded by setup" + + # Insert the association row directly to avoid touching the lazy + # `viewer.groups` relationship (which would trigger an implicit + # IO inside an active async transaction and fail with MissingGreenlet). + await db_session.execute(insert(user_groups).values(user_id=viewer.id, group_id=viewers_group.id)) + await db_session.commit() + + login = await async_client.post( + "/api/v1/auth/login", + json={"username": "viewer_s2", "password": "ViewerS2!Pass1"}, + ) + assert login.status_code == 200, login.text + token = login.json()["access_token"] + + resp = await async_client.get(self.STATUS_URL, headers={"Authorization": f"Bearer {token}"}) + assert resp.status_code == 403, "S2: Viewers (SETTINGS_READ only) must NOT be able to read encryption-status" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_status_decryption_broken_when_wrong_key_active(self, async_client, db_session, monkeypatch): + """B4: key is configured but cannot decrypt existing rows → decryption_broken=True. + + This is the "wrong key" state that the legacy computed_field check + missed — operator pasted a different valid Fernet key (rotation, + cross-deployment restore, env override). Status used to show GREEN + while every encrypted row was unrecoverable. + """ + from cryptography.fernet import Fernet + + import backend.app.core.encryption as enc_mod + from backend.app.models.oidc_provider import OIDCProvider + + token = await self._create_admin_and_login(async_client) + + # Insert a row whose value is fernet-prefixed but encrypted under a + # DIFFERENT key (the prefix matches, but decrypt will throw). + provider = OIDCProvider( + name="WrongKeyEnc", + issuer_url="https://wk.example.com", + client_id="c", + _client_secret_enc=("fernet:" + Fernet(Fernet.generate_key()).encrypt(b"original").decode()), + scopes="openid email profile", + ) + db_session.add(provider) + await db_session.commit() + + # Now activate a DIFFERENT key — sample-decrypt must fail. + monkeypatch.setenv("MFA_ENCRYPTION_KEY", Fernet.generate_key().decode()) + enc_mod._fernet_instance = None + enc_mod._key_source = None + + resp = await async_client.get(self.STATUS_URL, headers={"Authorization": f"Bearer {token}"}) + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["key_configured"] is True, "different key is still 'configured'" + assert data["encrypted_rows"]["oidc_providers"] >= 1 + assert data["decryption_broken"] is True, "B4: sample-decrypt must detect wrong-key state" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_status_decryption_broken_with_only_totp_rows(self, async_client, db_session, monkeypatch): + """B4: the sample-decrypt fallback to UserTOTP fires when there are no + encrypted OIDC rows but TOTP rows exist. The OIDC-only test above + proves the primary path; this pins the second branch in the same + try-block so a future refactor of the row-source switch can't silently + regress wrong-key detection for TOTP-only deployments. + """ + from cryptography.fernet import Fernet + from sqlalchemy import select + + import backend.app.core.encryption as enc_mod + from backend.app.models.user import User + from backend.app.models.user_totp import UserTOTP + + token = await self._create_admin_and_login(async_client) + + # Look up the admin user created by login so we can attach a TOTP row. + admin_row = await db_session.execute(select(User).where(User.username == "admin1219")) + admin = admin_row.scalar_one() + + # Seed a UserTOTP row encrypted under key A. No OIDC rows exist, so + # the endpoint's first branch (oidc_providers > 0) misses and the + # sample falls through to UserTOTP. + key_a_ciphertext = Fernet(Fernet.generate_key()).encrypt(b"original-totp-secret").decode() + db_session.add(UserTOTP(user_id=admin.id, _secret_enc=f"fernet:{key_a_ciphertext}", is_enabled=True)) + await db_session.commit() + + # Activate a DIFFERENT key — the TOTP-fallback sample-decrypt must fail. + monkeypatch.setenv("MFA_ENCRYPTION_KEY", Fernet.generate_key().decode()) + enc_mod._fernet_instance = None + enc_mod._key_source = None + + resp = await async_client.get(self.STATUS_URL, headers={"Authorization": f"Bearer {token}"}) + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["key_configured"] is True + assert data["encrypted_rows"]["oidc_providers"] == 0, "test premise: no OIDC rows so TOTP branch fires" + assert data["encrypted_rows"]["user_totp"] >= 1 + assert data["decryption_broken"] is True, "B4: TOTP-fallback sample-decrypt must detect wrong-key state" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_status_surfaces_real_migration_error_count(self, async_client, db_session, monkeypatch, caplog): + """B2: a real migration with a poison row produces an error_count that + flows through to the endpoint's `migration_error_count` field. + + Replaces an earlier tautology that patched the module-level counter + directly. The chained version verifies the full path: poison row → + per-row migration skip → ``get_migration_error_count()`` → + ``GET /encryption-status``. + """ + import logging + + from backend.app.core.database import _migrate_encrypt_legacy_secrets, get_migration_error_count + from backend.app.models.oidc_provider import OIDCProvider + + token = await self._create_admin_and_login(async_client) + + # Bind the migration's session factory to the test engine and activate a key. + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + + from backend.app.core import database as db_mod + + test_factory = async_sessionmaker(db_session.bind, class_=AsyncSession, expire_on_commit=False) + monkeypatch.setattr(db_mod, "async_session", test_factory) + from cryptography.fernet import Fernet + + import backend.app.core.encryption as enc_mod + + monkeypatch.setenv("MFA_ENCRYPTION_KEY", Fernet.generate_key().decode()) + enc_mod._fernet_instance = None + + # Two legacy plaintext rows; force the SECOND row's encrypt call to raise. + db_session.add_all( + [ + OIDCProvider( + name="GoodRow", + issuer_url="https://good.example.com", + client_id="c1", + _client_secret_enc="plaintext-good", + scopes="openid email profile", + ), + OIDCProvider( + name="BadRow", + issuer_url="https://bad.example.com", + client_id="c2", + _client_secret_enc="plaintext-bad", + scopes="openid email profile", + ), + ] + ) + await db_session.commit() + + import backend.app.models.oidc_provider as oidc_mod + + real_encrypt = oidc_mod.mfa_encrypt + call_count = [0] + + def _sometimes_raise(value): + call_count[0] += 1 + if call_count[0] == 2: + raise RuntimeError("simulated encrypt failure") + return real_encrypt(value) + + monkeypatch.setattr(oidc_mod, "mfa_encrypt", _sometimes_raise) + + with caplog.at_level(logging.ERROR, logger="backend.app.core.database"): + await _migrate_encrypt_legacy_secrets() + + # Sanity: the migration's own counter saw the failure. + assert get_migration_error_count() == 1 + + # The endpoint must surface the same number — full path pinned, not just the getter. + resp = await async_client.get(self.STATUS_URL, headers={"Authorization": f"Bearer {token}"}) + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["migration_error_count"] == 1, ( + "endpoint must report the actual migration outcome, not just read a stub global" + ) + + +# ============================================================================ +# TestEncryptionRoundtrip (E2E) +# ============================================================================ + + +class TestEncryptionRoundtrip: + """End-to-end: writes via the property setter store ciphertext at the column + level; reads via the property getter return the original plaintext.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_oidc_provider_secret_encrypted_at_rest_e2e(self, db_session, monkeypatch): + from cryptography.fernet import Fernet + from sqlalchemy import select + + import backend.app.core.encryption as enc_mod + from backend.app.models.oidc_provider import OIDCProvider + + monkeypatch.setenv("MFA_ENCRYPTION_KEY", Fernet.generate_key().decode()) + enc_mod._fernet_instance = None + + provider = OIDCProvider( + name="E2E_OIDC", + issuer_url="https://e2e.example.com", + client_id="cid", + client_secret="my-real-client-secret", # via setter → encrypted + scopes="openid email profile", + is_enabled=True, + ) + db_session.add(provider) + await db_session.commit() + + # Raw column read: must be ciphertext, not the plaintext. + result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == provider.id)) + fetched = result.scalar_one() + assert fetched._client_secret_enc.startswith("fernet:") + assert fetched._client_secret_enc != "my-real-client-secret" + + # Property read: returns original plaintext. + assert fetched.client_secret == "my-real-client-secret" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_totp_secret_encrypted_at_rest_e2e(self, db_session, monkeypatch): + from cryptography.fernet import Fernet + from sqlalchemy import select + + import backend.app.core.encryption as enc_mod + from backend.app.models.user import User + from backend.app.models.user_totp import UserTOTP + + monkeypatch.setenv("MFA_ENCRYPTION_KEY", Fernet.generate_key().decode()) + enc_mod._fernet_instance = None + + user = User(username="e2etotp1219", email="e@example.com", password_hash="x") + db_session.add(user) + await db_session.flush() + + totp = UserTOTP(user_id=user.id, secret="JBSWY3DPEHPK3PXP", is_enabled=True) + db_session.add(totp) + await db_session.commit() + + result = await db_session.execute(select(UserTOTP).where(UserTOTP.user_id == user.id)) + fetched = result.scalar_one() + assert fetched._secret_enc.startswith("fernet:") + assert fetched._secret_enc != "JBSWY3DPEHPK3PXP" + assert fetched.secret == "JBSWY3DPEHPK3PXP" + + +# ============================================================================ +# TestBackupKeyFiles +# Verifies that .mfa_encryption_key is included in backup ZIPs (so backups +# are self-contained) and restored with chmod 0600 — and that path-traversal +# payloads in a malicious ZIP are rejected. +# ============================================================================ + + +class TestBackupKeyFiles: + @pytest.mark.asyncio + @pytest.mark.integration + async def test_backup_includes_mfa_encryption_key_when_present(self, async_client, monkeypatch, tmp_path): + import zipfile + + from backend.app.api.routes.settings import create_backup_zip + from backend.app.core.config import settings as app_settings + + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + # Ensure `app_settings.base_dir` follows DATA_DIR for this test by + # patching the module attribute (config caches it at import time). + monkeypatch.setattr(app_settings, "base_dir", tmp_path) + + key_path = tmp_path / ".mfa_encryption_key" + key_path.write_text("test-key-content") + + zip_path, _filename = await create_backup_zip(output_path=tmp_path) + try: + with zipfile.ZipFile(zip_path) as zf: + names = zf.namelist() + assert ".mfa_encryption_key" in names + assert zf.read(".mfa_encryption_key").decode() == "test-key-content" + finally: + zip_path.unlink(missing_ok=True) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_backup_skips_mfa_encryption_key_when_absent(self, async_client, monkeypatch, tmp_path): + import zipfile + + from backend.app.api.routes.settings import create_backup_zip + from backend.app.core.config import settings as app_settings + + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + monkeypatch.setattr(app_settings, "base_dir", tmp_path) + # No .mfa_encryption_key written — must not crash. + + zip_path, _filename = await create_backup_zip(output_path=tmp_path) + try: + with zipfile.ZipFile(zip_path) as zf: + names = zf.namelist() + assert ".mfa_encryption_key" not in names + finally: + zip_path.unlink(missing_ok=True) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_restore_writes_key_files_with_chmod_0600(self, async_client, monkeypatch, tmp_path): + """T1: restore endpoint writes key file with mode 0o600. + + Bypasses the SQLite-copy step via patches so execution reaches the + key-write code unconditionally — the previous version used a stub + ``b"SQLite format 3"`` which made ``sqlite3.backup()`` fail and the + key-write code never ran. + """ + import io + import zipfile + from unittest.mock import AsyncMock, patch + + from backend.app.core.config import settings as app_settings + + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + monkeypatch.setattr(app_settings, "base_dir", tmp_path) + + # Build a minimal ZIP with a stub DB and the key file. + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("bambuddy.db", b"SQLite format 3") + zf.writestr(".mfa_encryption_key", "test-restored-key") + buf.seek(0) + + with ( + patch("backend.app.core.db_dialect.is_sqlite", return_value=False), + patch( + "backend.app.api.routes.settings._import_sqlite_to_postgres", + new_callable=AsyncMock, + ), + patch("backend.app.core.database.close_all_connections", new_callable=AsyncMock), + patch("backend.app.core.database.reinitialize_database", new_callable=AsyncMock), + patch("backend.app.core.database.init_db", new_callable=AsyncMock), + ): + resp = await async_client.post( + "/api/v1/settings/restore", + files={"file": ("backup.zip", buf, "application/zip")}, + ) + + assert resp.status_code == 200 + restored_key = tmp_path / ".mfa_encryption_key" + assert restored_key.exists() + assert restored_key.read_text() == "test-restored-key" + assert (restored_key.stat().st_mode & 0o777) == 0o600 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_restore_handles_missing_key_files(self, async_client, monkeypatch, tmp_path): + """T2: ZIP without key file → restore succeeds, no key written to DATA_DIR.""" + import io + import zipfile + from unittest.mock import AsyncMock, patch + + from backend.app.core.config import settings as app_settings + + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + monkeypatch.setattr(app_settings, "base_dir", tmp_path) + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("bambuddy.db", b"SQLite format 3") + # Intentionally no .mfa_encryption_key entry. + buf.seek(0) + + with ( + patch("backend.app.core.db_dialect.is_sqlite", return_value=False), + patch( + "backend.app.api.routes.settings._import_sqlite_to_postgres", + new_callable=AsyncMock, + ), + patch("backend.app.core.database.close_all_connections", new_callable=AsyncMock), + patch("backend.app.core.database.reinitialize_database", new_callable=AsyncMock), + patch("backend.app.core.database.init_db", new_callable=AsyncMock), + ): + resp = await async_client.post( + "/api/v1/settings/restore", + files={"file": ("backup.zip", buf, "application/zip")}, + ) + + assert resp.status_code == 200 + assert not (tmp_path / ".mfa_encryption_key").exists() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_restore_aborts_db_swap_when_key_write_fails(self, async_client, monkeypatch, tmp_path): + """B1: when MFA key write fails, restore must abort BEFORE the database + swap so the live DB is not left with rows encrypted under a key that + no longer exists on disk.""" + import io + import os + import zipfile + from unittest.mock import AsyncMock, patch + + from backend.app.core.config import settings as app_settings + + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + monkeypatch.setattr(app_settings, "base_dir", tmp_path) + + # Build ZIP with a key file that we will fail to write to DATA_DIR. + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("bambuddy.db", b"SQLite format 3 backup data") + zf.writestr(".mfa_encryption_key", "backup-key-content") + buf.seek(0) + + # Track whether the database swap functions were called. + # If B1 is correct, key-write failure aborts BEFORE these run. + import_pg_mock = AsyncMock() + reinit_mock = AsyncMock() + init_mock = AsyncMock() + + original_open = os.open + + def _key_write_fails(path, flags, mode=0o777, **kwargs): + # `shutil.rmtree` calls os.open(... dir_fd=...) during temp-dir + # cleanup — accept and forward any extra kwargs so the mock + # doesn't break the cleanup path. + if str(path).endswith(".mfa_encryption_key.restore-tmp"): + raise OSError(28, "No space left on device", str(path)) + return original_open(path, flags, mode, **kwargs) + + with ( + patch("backend.app.core.db_dialect.is_sqlite", return_value=False), + patch( + "backend.app.api.routes.settings._import_sqlite_to_postgres", + import_pg_mock, + ), + patch("backend.app.core.database.close_all_connections", new_callable=AsyncMock), + patch("backend.app.core.database.reinitialize_database", reinit_mock), + patch("backend.app.core.database.init_db", init_mock), + ): + monkeypatch.setattr(os, "open", _key_write_fails) + resp = await async_client.post( + "/api/v1/settings/restore", + files={"file": ("backup.zip", buf, "application/zip")}, + ) + + assert resp.status_code == 500 + assert "Database is unchanged" in resp.json().get("detail", "") + # Database swap functions must NOT have been called — the abort + # happens before that step. + import_pg_mock.assert_not_awaited() + reinit_mock.assert_not_awaited() + init_mock.assert_not_awaited() + # No partial key file should be left behind. + assert not (tmp_path / ".mfa_encryption_key").exists() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_restore_resets_encryption_singleton_after_key_replace(self, async_client, monkeypatch, tmp_path): + """B1: after a successful key replace, the encryption singleton must be + cleared so init_db's re-encryption migration picks up the restored key + instead of the cached Fernet from the previous key. + """ + import io + import zipfile + from unittest.mock import AsyncMock, patch + + from cryptography.fernet import Fernet + + import backend.app.core.encryption as enc_mod + from backend.app.core.config import settings as app_settings + + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + monkeypatch.setattr(app_settings, "base_dir", tmp_path) + + # Pre-warm the singleton with an "old" key so we can detect the reset. + old_key = Fernet.generate_key().decode() + monkeypatch.setenv("MFA_ENCRYPTION_KEY", old_key) + enc_mod._fernet_instance = None + enc_mod._key_source = None + # Trigger lazy load → singleton holds the old Fernet. + assert enc_mod.is_encryption_active() is True + assert enc_mod._fernet_instance is not None + old_fernet_obj = enc_mod._fernet_instance + + # Build ZIP that delivers a DIFFERENT key file. + new_key = Fernet.generate_key().decode() + assert new_key != old_key + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("bambuddy.db", b"SQLite format 3 backup data") + zf.writestr(".mfa_encryption_key", new_key) + buf.seek(0) + + with ( + patch("backend.app.core.db_dialect.is_sqlite", return_value=False), + patch( + "backend.app.api.routes.settings._import_sqlite_to_postgres", + new_callable=AsyncMock, + ), + patch("backend.app.core.database.close_all_connections", new_callable=AsyncMock), + patch("backend.app.core.database.reinitialize_database", new_callable=AsyncMock), + patch("backend.app.core.database.init_db", new_callable=AsyncMock), + ): + resp = await async_client.post( + "/api/v1/settings/restore", + files={"file": ("backup.zip", buf, "application/zip")}, + ) + + assert resp.status_code == 200, resp.text + # The singleton must have been invalidated. The exact post-state depends + # on whether init_db (mocked) re-loaded the singleton, but the cached + # _fernet_instance reference from before the restore must not be the + # active one any more. + assert enc_mod._fernet_instance is None or enc_mod._fernet_instance is not old_fernet_obj, ( + "B1: encryption singleton must be reset after key replace so init_db's migration picks up the restored key" + ) + # The key file must be on disk with the new content. + restored = (tmp_path / ".mfa_encryption_key").read_text() + assert restored == new_key + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_restore_rejects_path_traversal_in_zip(self, async_client, monkeypatch, tmp_path): + """A4: ZIP with path-traversal entry → HTTP 400, no file written outside temp dir.""" + import io + import zipfile + + from backend.app.core.config import settings as app_settings + + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + monkeypatch.setattr(app_settings, "base_dir", tmp_path) + + # Build ZIP with a relative path-traversal entry. + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("../etc/passwd", "root:x:0:0") + zf.writestr("bambuddy.db", b"SQLite format 3") + buf.seek(0) + + resp = await async_client.post( + "/api/v1/settings/restore", + files={"file": ("backup.zip", buf, "application/zip")}, + ) + assert resp.status_code == 400 + assert "unsafe path" in resp.json().get("detail", "").lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_restore_rejects_prefix_collision_zipslip(self, async_client, monkeypatch, tmp_path): + """T1: ZIP entry with prefix-collision path must be rejected. + + A startswith() check would accept '/tmp/abc_evil/file' when the + extraction root was '/tmp/abc' — is_relative_to correctly rejects it. + The restore handler creates a tempfile.TemporaryDirectory inside the + system temp dir; we craft an entry that resolves to a sibling path + whose name starts with the temp dir's basename. + """ + import io + import zipfile + + from backend.app.core.config import settings as app_settings + + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + monkeypatch.setattr(app_settings, "base_dir", tmp_path) + + # Use a path with traversal — the resolved path will share the parent + # temp directory's basename as a prefix but NOT be inside the + # extraction root. We don't know the random extraction-root name at + # ZIP-build time, so we pick a literal "../poc-evil-prefix-collision/" + # which traverses up one level from the extraction root and lands in + # a sibling directory. is_relative_to() must reject this; a naive + # startswith() against the parent's parent would accept it. + evil_name = "../escaped-prefix-collision/poc.txt" + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr(evil_name, "pwned") + zf.writestr("bambuddy.db", b"SQLite format 3\x00") + buf.seek(0) + + resp = await async_client.post( + "/api/v1/settings/restore", + files={"file": ("backup.zip", buf, "application/zip")}, + ) + assert resp.status_code == 400 + assert "unsafe path" in resp.json().get("detail", "").lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_restore_rejects_absolute_path_in_zip(self, async_client, monkeypatch, tmp_path): + """B1: ZIP with an absolute path entry must be rejected by is_relative_to check.""" + import io + import zipfile + + from backend.app.core.config import settings as app_settings + + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + monkeypatch.setattr(app_settings, "base_dir", tmp_path) + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + # Absolute path in the archive — extracts outside temp_path on + # systems where (temp_path / "/etc/passwd") resolves to /etc/passwd. + zf.writestr("/etc/passwd", "root:x:0:0") + zf.writestr("bambuddy.db", b"SQLite format 3") + buf.seek(0) + + resp = await async_client.post( + "/api/v1/settings/restore", + files={"file": ("backup.zip", buf, "application/zip")}, + ) + assert resp.status_code == 400 + assert "unsafe path" in resp.json().get("detail", "").lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_backup_fails_when_key_file_unreadable(self, async_client, monkeypatch, tmp_path): + """A5: OSError while copying key file propagates out of create_backup_zip.""" + import shutil + + from backend.app.api.routes.settings import create_backup_zip + from backend.app.core.config import settings as app_settings + + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + monkeypatch.setattr(app_settings, "base_dir", tmp_path) + (tmp_path / ".mfa_encryption_key").write_text("key") + + original_copy2 = shutil.copy2 + + def _raise_on_key(src, dst): + if ".mfa_encryption_key" in str(src): + raise OSError("simulated unreadable key file") + return original_copy2(src, dst) + + monkeypatch.setattr(shutil, "copy2", _raise_on_key) + + import pytest as _pytest + + with _pytest.raises(OSError, match="simulated unreadable"): + await create_backup_zip(output_path=tmp_path) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_backup_restore_roundtrip_preserves_encrypted_oidc_secret( + self, async_client, db_session, monkeypatch, tmp_path + ): + """T3: encrypt → backup → simulate key loss → restore → decrypt. + + Verifies the user-facing promise that local backup ZIPs are + self-contained: an OIDC client_secret encrypted under one key still + decrypts after restore even when the running install no longer has + the key on disk or in the env. Exercises the B1 key-first restore + path and the B4 sample-decrypt status check together. + """ + import zipfile + from pathlib import Path + from unittest.mock import AsyncMock, patch + + from cryptography.fernet import Fernet + from sqlalchemy import select + + import backend.app.core.encryption as enc_mod + from backend.app.api.routes.settings import create_backup_zip + from backend.app.core.config import settings as app_settings + from backend.app.models.oidc_provider import OIDCProvider + + # 1. Pin a key, encrypt an OIDC secret via the property setter. + key = Fernet.generate_key().decode() + monkeypatch.setenv("MFA_ENCRYPTION_KEY", key) + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + monkeypatch.setattr(app_settings, "base_dir", tmp_path) + # Persist the key file too, so create_backup_zip picks it up. + (tmp_path / ".mfa_encryption_key").write_text(key) + enc_mod._fernet_instance = None + enc_mod._key_source = None + + provider = OIDCProvider( + name="RoundtripProv", + issuer_url="https://rt.example.com", + client_id="cid", + client_secret="my-original-secret", # via setter -> encrypted + scopes="openid email profile", + is_enabled=True, + ) + db_session.add(provider) + await db_session.commit() + original_id = provider.id + assert provider._client_secret_enc.startswith("fernet:") + + # 2. Create a backup ZIP (must include .mfa_encryption_key). + zip_path, _ = await create_backup_zip(output_path=tmp_path) + try: + with zipfile.ZipFile(zip_path) as zf: + names = zf.namelist() + assert ".mfa_encryption_key" in names, "T3: backup ZIP must include the key file" + + # 3. Simulate key loss: delete the key file from DATA_DIR, drop + # the env var, reset the cached fernet singleton. + (tmp_path / ".mfa_encryption_key").unlink() + monkeypatch.delenv("MFA_ENCRYPTION_KEY", raising=False) + enc_mod._fernet_instance = None + enc_mod._key_source = None + + # 4. Restore the ZIP via the endpoint. Mock out the DB-swap + # (we keep the live in-memory test DB) and init_db side effects + # so this test focuses on the key-restore path. + with ( + patch("backend.app.core.db_dialect.is_sqlite", return_value=False), + patch( + "backend.app.api.routes.settings._import_sqlite_to_postgres", + new_callable=AsyncMock, + ), + patch("backend.app.core.database.close_all_connections", new_callable=AsyncMock), + patch("backend.app.core.database.reinitialize_database", new_callable=AsyncMock), + patch("backend.app.core.database.init_db", new_callable=AsyncMock), + open(zip_path, "rb") as f, + ): + resp = await async_client.post( + "/api/v1/settings/restore", + files={"file": ("backup.zip", f, "application/zip")}, + ) + assert resp.status_code == 200, resp.text + + # 5. Reset the singleton again (B1 already does this in production, + # but here init_db is mocked so we explicitly invalidate). + enc_mod._fernet_instance = None + enc_mod._key_source = None + + # 6. The key file must be back on disk with restrictive permissions. + restored = Path(tmp_path) / ".mfa_encryption_key" + assert restored.exists(), "T3: key file must be restored to DATA_DIR" + assert (restored.stat().st_mode & 0o777) == 0o600 + + # 7. Decryption works again — the property getter must return the + # original plaintext, proving the restored key matches the + # cipher in the (still in-memory) DB row. + result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == original_id)) + restored_provider = result.scalar_one() + assert restored_provider.client_secret == "my-original-secret" + finally: + zip_path.unlink(missing_ok=True) + + +# ============================================================================ +# TestTOTPDecryptionBroken (C9) +# Verifies the decryption-broken state (encrypted TOTP row + no key) for each +# TOTP endpoint. Behaviour differs between recovery-aware and non-recovery +# endpoints: +# - setup_totp / enable_totp / verify_2fa: HTTP 500 (no backup-code path). +# - disable_totp / regenerate_backup_codes: fall through to the backup-code +# branch — HTTP 200 with a valid backup code, HTTP 400 without. +# ============================================================================ + + +class TestTOTPDecryptionBroken: + """C9: RuntimeError from mfa_decrypt — 500 for non-recovery endpoints, + backup-code fall-through for disable_totp / regenerate_backup_codes.""" + + async def _setup_admin_and_totp_user(self, async_client, db_session): + """Create admin (enables auth), log in as admin, add TOTP record with fernet secret.""" + from backend.app.models.user_totp import UserTOTP + + admin_username = f"admin_c9_{secrets.token_hex(4)}" + setup = await async_client.post( + "/api/v1/auth/setup", + json={ + "auth_enabled": True, + "admin_username": admin_username, + "admin_password": "Admin_C9_Pass1!", + }, + ) + assert setup.status_code in (200, 201), setup.text + login = await async_client.post( + "/api/v1/auth/login", + json={"username": admin_username, "password": "Admin_C9_Pass1!"}, + ) + assert login.status_code == 200, login.text + token = login.json()["access_token"] + + # Get the admin user_id from the /me endpoint + me = await async_client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"}) + assert me.status_code == 200 + user_id = me.json()["id"] + + # Insert a TOTP row with a fernet-prefixed secret directly (no key needed for insert). + totp = UserTOTP( + user_id=user_id, + _secret_enc="fernet:gAAAAA-not-really-encrypted", + is_enabled=True, + ) + db_session.add(totp) + await db_session.commit() + + return token, admin_username, user_id + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_enable_totp_returns_500_when_decryption_broken(self, async_client, db_session, monkeypatch): + """C9: enable endpoint → 500 when TOTP secret is encrypted but key unavailable.""" + import backend.app.core.encryption as enc_mod + + token, _, _ = await self._setup_admin_and_totp_user(async_client, db_session) + + monkeypatch.setattr(enc_mod, "_load_or_generate_key", lambda: (None, "none")) + enc_mod._fernet_instance = None + + # enable_totp requires setup-but-not-yet-enabled state; force is_enabled=False + from sqlalchemy import select as _select + + from backend.app.models.user_totp import UserTOTP + + result = await db_session.execute(_select(UserTOTP)) + for t in result.scalars().all(): + t.is_enabled = False + await db_session.commit() + + resp = await async_client.post( + "/api/v1/auth/2fa/totp/enable", + json={"code": "123456"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 500 + assert "unavailable" in resp.json().get("detail", "").lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_disable_totp_returns_400_when_decryption_broken_and_no_backup_codes( + self, async_client, db_session, monkeypatch + ): + """B2a + S3: disable falls through to backup-code branch when TOTP secret + cannot be decrypted; with no backup codes seeded, the request is + rejected as an invalid code (400), not a server error. + + S3: AND the failed-attempt counter must NOT be incremented — the + cause was a server-side key loss, not a user mistake. + """ + from sqlalchemy import select as _select + + import backend.app.core.encryption as enc_mod + from backend.app.models.auth_ephemeral import AuthRateLimitEvent + + token, admin_username, _ = await self._setup_admin_and_totp_user(async_client, db_session) + + monkeypatch.setattr(enc_mod, "_load_or_generate_key", lambda: (None, "none")) + enc_mod._fernet_instance = None + + resp = await async_client.post( + "/api/v1/auth/2fa/totp/disable", + json={"code": "123456"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 400 + assert "invalid" in resp.json().get("detail", "").lower() + + # S3: no fail-counter debit on server-side key loss. + events = ( + ( + await db_session.execute( + _select(AuthRateLimitEvent).where(AuthRateLimitEvent.username == admin_username.lower()) + ) + ) + .scalars() + .all() + ) + assert len(events) == 0, "S3: must not debit fail-counter on key-loss" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_regenerate_backup_codes_returns_400_when_decryption_broken_and_no_backup_codes( + self, async_client, db_session, monkeypatch + ): + """B2b + S3: regenerate-backup-codes falls through to backup-code branch when + TOTP secret cannot be decrypted; with no backup codes seeded, the + request is rejected as an invalid code (400) AND the fail-counter + is NOT incremented (S3: server-side cause, not user mistake). + """ + from sqlalchemy import select as _select + + import backend.app.core.encryption as enc_mod + from backend.app.models.auth_ephemeral import AuthRateLimitEvent + + token, admin_username, _ = await self._setup_admin_and_totp_user(async_client, db_session) + + monkeypatch.setattr(enc_mod, "_load_or_generate_key", lambda: (None, "none")) + enc_mod._fernet_instance = None + + resp = await async_client.post( + "/api/v1/auth/2fa/totp/regenerate-backup-codes", + json={"code": "123456"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 400 + assert "invalid" in resp.json().get("detail", "").lower() + + events = ( + ( + await db_session.execute( + _select(AuthRateLimitEvent).where(AuthRateLimitEvent.username == admin_username.lower()) + ) + ) + .scalars() + .all() + ) + assert len(events) == 0, "S3: must not debit fail-counter on key-loss" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_disable_totp_succeeds_via_backup_code_when_decryption_broken( + self, async_client, db_session, monkeypatch + ): + """B2a: a valid backup code disables TOTP even when the secret cannot + be decrypted — recovery path for users who lost the encryption key.""" + from sqlalchemy import select as _select + + import backend.app.core.encryption as enc_mod + from backend.app.api.routes.mfa import _generate_backup_codes + from backend.app.models.user_totp import UserTOTP + + token, _, user_id = await self._setup_admin_and_totp_user(async_client, db_session) + + # Seed a real backup-code hash on the existing TOTP row. + plain_codes, hashed_codes = _generate_backup_codes() + result = await db_session.execute(_select(UserTOTP).where(UserTOTP.user_id == user_id)) + totp = result.scalar_one() + totp.backup_code_hashes = hashed_codes + await db_session.commit() + + monkeypatch.setattr(enc_mod, "_load_or_generate_key", lambda: (None, "none")) + enc_mod._fernet_instance = None + + resp = await async_client.post( + "/api/v1/auth/2fa/totp/disable", + json={"code": plain_codes[0]}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200, resp.text + # The TOTP row must have been deleted. + result_after = await db_session.execute(_select(UserTOTP).where(UserTOTP.user_id == user_id)) + assert result_after.scalar_one_or_none() is None + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_regenerate_backup_codes_succeeds_via_backup_code_when_decryption_broken( + self, async_client, db_session, monkeypatch + ): + """B2b: a valid backup code rotates the codes even when the secret + cannot be decrypted — recovery path mirrors disable_totp.""" + from sqlalchemy import select as _select + + import backend.app.core.encryption as enc_mod + from backend.app.api.routes.mfa import _generate_backup_codes + from backend.app.models.user_totp import UserTOTP + + token, _, user_id = await self._setup_admin_and_totp_user(async_client, db_session) + + plain_codes, hashed_codes = _generate_backup_codes() + result = await db_session.execute(_select(UserTOTP).where(UserTOTP.user_id == user_id)) + totp = result.scalar_one() + totp.backup_code_hashes = hashed_codes + await db_session.commit() + + monkeypatch.setattr(enc_mod, "_load_or_generate_key", lambda: (None, "none")) + enc_mod._fernet_instance = None + + resp = await async_client.post( + "/api/v1/auth/2fa/totp/regenerate-backup-codes", + json={"code": plain_codes[0]}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert "backup_codes" in body + assert len(body["backup_codes"]) == 10 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_disable_totp_wrong_code_with_seeded_hashes_returns_400_and_debits_counter( + self, async_client, db_session, monkeypatch + ): + """T2: with backup_code_hashes seeded AND a working encryption key, + a wrong code is rejected (400) AND the fail-counter IS incremented. + + This pins the behaviour that a future refactor swallowing + compare_digest mismatches would still let the existing 'no codes + configured' tests pass — only this assertion exercises the actual + pwd_context.verify mismatch path. + """ + from cryptography.fernet import Fernet + from sqlalchemy import select as _select + + import backend.app.core.encryption as enc_mod + from backend.app.api.routes.mfa import _generate_backup_codes + from backend.app.models.auth_ephemeral import AuthRateLimitEvent + from backend.app.models.user_totp import UserTOTP + + # Active key — secret can be decrypted, this is NOT key-loss. + monkeypatch.setenv("MFA_ENCRYPTION_KEY", Fernet.generate_key().decode()) + enc_mod._fernet_instance = None + + token, admin_username, user_id = await self._setup_admin_and_totp_user(async_client, db_session) + + # Replace stub fernet:-prefixed value with a real encrypted secret so + # disable_totp's TOTP-decrypt path doesn't throw, AND seed real hashes. + result = await db_session.execute(_select(UserTOTP).where(UserTOTP.user_id == user_id)) + totp = result.scalar_one() + totp.secret = "JBSWY3DPEHPK3PXP" # via setter -> mfa_encrypt + plain_codes, hashed_codes = _generate_backup_codes() + totp.backup_code_hashes = hashed_codes + await db_session.commit() + + # Submit a code that matches NEITHER the TOTP nor any backup-code hash. + resp = await async_client.post( + "/api/v1/auth/2fa/totp/disable", + json={"code": "WRONGCD1"}, # wrong but well-formed + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 400 + assert "invalid" in resp.json().get("detail", "").lower() + + # T2 + S3: with key intact, the fail-counter MUST increment for a + # real wrong-code attempt (this is the user-error path, not key-loss). + events = ( + ( + await db_session.execute( + _select(AuthRateLimitEvent).where(AuthRateLimitEvent.username == admin_username.lower()) + ) + ) + .scalars() + .all() + ) + assert len(events) >= 1, "T2: with key intact, wrong code must debit the fail-counter" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_regenerate_backup_codes_wrong_code_with_seeded_hashes_returns_400_and_debits_counter( + self, async_client, db_session, monkeypatch + ): + """T2: same as the disable_totp variant for /regenerate-backup-codes.""" + from cryptography.fernet import Fernet + from sqlalchemy import select as _select + + import backend.app.core.encryption as enc_mod + from backend.app.api.routes.mfa import _generate_backup_codes + from backend.app.models.auth_ephemeral import AuthRateLimitEvent + from backend.app.models.user_totp import UserTOTP + + monkeypatch.setenv("MFA_ENCRYPTION_KEY", Fernet.generate_key().decode()) + enc_mod._fernet_instance = None + + token, admin_username, user_id = await self._setup_admin_and_totp_user(async_client, db_session) + + result = await db_session.execute(_select(UserTOTP).where(UserTOTP.user_id == user_id)) + totp = result.scalar_one() + totp.secret = "JBSWY3DPEHPK3PXP" + plain_codes, hashed_codes = _generate_backup_codes() + totp.backup_code_hashes = hashed_codes + await db_session.commit() + + resp = await async_client.post( + "/api/v1/auth/2fa/totp/regenerate-backup-codes", + json={"code": "WRONGCD2"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 400 + assert "invalid" in resp.json().get("detail", "").lower() + + events = ( + ( + await db_session.execute( + _select(AuthRateLimitEvent).where(AuthRateLimitEvent.username == admin_username.lower()) + ) + ) + .scalars() + .all() + ) + assert len(events) >= 1, "T2: with key intact, wrong code must debit the fail-counter" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_disable_totp_wrong_code_with_seeded_hashes_at_keyloss_no_counter_debit( + self, async_client, db_session, monkeypatch + ): + """T2 + S3 cross-check: with hashes seeded but encryption key gone, + a wrong code returns 400 BUT the fail-counter MUST NOT increment. + + This is the dual of the test above — same wrong-code 400 outcome, + but the counter debit is gated on the cause of failure (server-side + key loss must NOT penalise the user). + """ + from sqlalchemy import select as _select + + import backend.app.core.encryption as enc_mod + from backend.app.api.routes.mfa import _generate_backup_codes + from backend.app.models.auth_ephemeral import AuthRateLimitEvent + from backend.app.models.user_totp import UserTOTP + + token, admin_username, user_id = await self._setup_admin_and_totp_user(async_client, db_session) + + # Seed real hashes on the existing TOTP row. + result = await db_session.execute(_select(UserTOTP).where(UserTOTP.user_id == user_id)) + totp = result.scalar_one() + plain_codes, hashed_codes = _generate_backup_codes() + totp.backup_code_hashes = hashed_codes + await db_session.commit() + + # Now simulate key loss. + monkeypatch.setattr(enc_mod, "_load_or_generate_key", lambda: (None, "none")) + enc_mod._fernet_instance = None + + resp = await async_client.post( + "/api/v1/auth/2fa/totp/disable", + json={"code": "WRONGCD3"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 400 + + # S3: counter MUST be unchanged — this is a server-side problem. + events = ( + ( + await db_session.execute( + _select(AuthRateLimitEvent).where(AuthRateLimitEvent.username == admin_username.lower()) + ) + ) + .scalars() + .all() + ) + assert len(events) == 0, "S3: must not debit fail-counter when cause is server-side key-loss" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_setup_totp_returns_500_when_decryption_broken(self, async_client, db_session, monkeypatch): + """B3: setup endpoint → 500 when an active TOTP secret can't be decrypted. + + Replacing an active authenticator requires verifying the current TOTP + code; with no recovery (backup-code) path on this endpoint, the only + safe outcome is a 500 surface to the operator. + """ + import backend.app.core.encryption as enc_mod + + token, _, _ = await self._setup_admin_and_totp_user(async_client, db_session) + + monkeypatch.setattr(enc_mod, "_load_or_generate_key", lambda: (None, "none")) + enc_mod._fernet_instance = None + + resp = await async_client.post( + "/api/v1/auth/2fa/totp/setup", + json={"code": "123456"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 500 + assert "unavailable" in resp.json().get("detail", "").lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_verify_2fa_returns_500_when_decryption_broken(self, async_client, db_session, monkeypatch): + """C9: verify endpoint (TOTP method) → 500 when TOTP secret unreadable.""" + from datetime import datetime, timedelta, timezone + + import backend.app.core.encryption as enc_mod + from backend.app.models.auth_ephemeral import AuthEphemeralToken + + token, admin_username, user_id = await self._setup_admin_and_totp_user(async_client, db_session) + + monkeypatch.setattr(enc_mod, "_load_or_generate_key", lambda: (None, "none")) + enc_mod._fernet_instance = None + + # Create a pre_auth token to simulate the post-login 2FA challenge step. + raw_token = secrets.token_urlsafe(32) + ephemeral = AuthEphemeralToken( + token=raw_token, + token_type="pre_auth", + username=admin_username, + expires_at=datetime.now(timezone.utc) + timedelta(minutes=5), + ) + db_session.add(ephemeral) + await db_session.commit() + + resp = await async_client.post( + "/api/v1/auth/2fa/verify", + json={"pre_auth_token": raw_token, "method": "totp", "code": "123456"}, + ) + assert resp.status_code == 500 + assert "unavailable" in resp.json().get("detail", "").lower() diff --git a/backend/tests/unit/test_config_env_warnings.py b/backend/tests/unit/test_config_env_warnings.py new file mode 100644 index 000000000..fc8aa3766 --- /dev/null +++ b/backend/tests/unit/test_config_env_warnings.py @@ -0,0 +1,52 @@ +"""S6: warn on unknown MFA_*/BAMBUDDY_* env vars so typos like +``MFA_ENCYPTION_KEY`` are not silently swallowed by ``extra="ignore"``.""" + +from __future__ import annotations + +import importlib +import logging + +import pytest + + +@pytest.mark.unit +def test_unknown_mfa_env_var_logs_info(monkeypatch, caplog): + """A typo'd MFA_* env var must be logged at INFO so operators see it.""" + monkeypatch.setenv("MFA_ENCYPTION_KEY", "typo-value") # missing R + + import backend.app.core.config as cfg_mod + + with caplog.at_level(logging.INFO): + importlib.reload(cfg_mod) + + assert any("MFA_ENCYPTION_KEY" in rec.message for rec in caplog.records) + + +@pytest.mark.unit +def test_unknown_bambuddy_env_var_logs_info(monkeypatch, caplog): + """An unrecognised BAMBUDDY_* env var must also be logged.""" + monkeypatch.setenv("BAMBUDDY_NEW_FEATURE", "v1") + + import backend.app.core.config as cfg_mod + + with caplog.at_level(logging.INFO): + importlib.reload(cfg_mod) + + assert any("BAMBUDDY_NEW_FEATURE" in rec.message for rec in caplog.records) + + +@pytest.mark.unit +def test_known_intentional_env_var_does_not_log(monkeypatch, caplog): + """MFA_ENCRYPTION_KEY is declared in _INTENTIONAL_UNSETTINGS — must be silent.""" + monkeypatch.setenv("MFA_ENCRYPTION_KEY", "x" * 44) # invalid but not a typo + + import backend.app.core.config as cfg_mod + + with caplog.at_level(logging.INFO): + importlib.reload(cfg_mod) + + # The intentional var must not produce a typo warning. + typo_warnings = [ + rec for rec in caplog.records if "MFA_ENCRYPTION_KEY" in rec.message and "typo" in rec.message.lower() + ] + assert typo_warnings == [] diff --git a/docker-compose.yml b/docker-compose.yml index 237577319..b7750e8d6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -88,6 +88,11 @@ services: # for the sidecars lives in the orca-slicer-api fork # (https://github.com/maziggy/orca-slicer-api). #- SLICER_API_URL=http://localhost:3003 + # + # MFA at-rest encryption key (#1219). Auto-generated to + # DATA_DIR/.mfa_encryption_key on first startup if unset. Override here + # to manage the key out-of-band (e.g. via a secret manager). + #- MFA_ENCRYPTION_KEY= restart: unless-stopped # Optional: External PostgreSQL database diff --git a/frontend/src/__tests__/components/SecurityStatusCard.test.tsx b/frontend/src/__tests__/components/SecurityStatusCard.test.tsx new file mode 100644 index 000000000..d0d5ca9c7 --- /dev/null +++ b/frontend/src/__tests__/components/SecurityStatusCard.test.tsx @@ -0,0 +1,262 @@ +/** + * Tests for SecurityStatusCard — verifies the five severity levels + * (green / yellow / orange / red / grey) are rendered for the right + * combinations of key_source, legacy_plaintext_rows, and decryption_broken. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { screen, waitFor } from '@testing-library/react'; +import { render } from '../utils'; +import { SecurityStatusCard } from '../../components/SecurityStatusCard'; +import { http, HttpResponse } from 'msw'; +import { server } from '../mocks/server'; +import type { EncryptionStatus } from '../../api/client'; + +const STATUS_URL = '/api/v1/auth/encryption-status'; + +function makeStatus(overrides: Partial = {}): EncryptionStatus { + return { + key_configured: true, + key_source: 'env', + legacy_plaintext_rows: { oidc_providers: 0, user_totp: 0 }, + encrypted_rows: { oidc_providers: 0, user_totp: 0 }, + decryption_broken: false, + migration_error_count: 0, + ...overrides, + }; +} + +describe('SecurityStatusCard', () => { + beforeEach(() => { + server.use(http.get(STATUS_URL, () => HttpResponse.json(makeStatus()))); + }); + + // E2: loading state + it('shows loading indicator while query is pending', () => { + // Delay the response so the component renders in loading state first. + server.use(http.get(STATUS_URL, async () => { + await new Promise(() => { /* never resolves — keeps loading state */ }); + return HttpResponse.json(makeStatus()); + })); + render(); + expect(screen.getByTestId('encryption-loading')).toBeInTheDocument(); + }); + + // E2: error state + it('shows error state when API returns 500', async () => { + server.use(http.get(STATUS_URL, () => new HttpResponse(null, { status: 500 }))); + render(); + await waitFor(() => { + expect(screen.getByTestId('encryption-error')).toBeInTheDocument(); + }); + }); + + // E1: data-testid on status div + it('renders encryption-status testid after data loads', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('encryption-status')).toBeInTheDocument(); + }); + }); + + it('renders enabled state with env source', async () => { + server.use( + http.get(STATUS_URL, () => + HttpResponse.json(makeStatus({ key_source: 'env', encrypted_rows: { oidc_providers: 2, user_totp: 5 } })), + ), + ); + render(); + await waitFor(() => { + expect(screen.getByTestId('encryption-status')).toBeInTheDocument(); + }); + expect(screen.getByText(/MFA_ENCRYPTION_KEY environment variable/i)).toBeInTheDocument(); + }); + + it('renders enabled state with file source', async () => { + server.use( + http.get(STATUS_URL, () => HttpResponse.json(makeStatus({ key_source: 'file' }))), + ); + render(); + await waitFor(() => { + expect(screen.getByTestId('encryption-status')).toBeInTheDocument(); + }); + expect(screen.getByText(/key loaded from data directory/i)).toBeInTheDocument(); + }); + + it('renders orange backup hint when key_source is generated', async () => { + server.use( + http.get(STATUS_URL, () => + HttpResponse.json(makeStatus({ key_source: 'generated' })), + ), + ); + render(); + await waitFor(() => { + expect(screen.getByTestId('encryption-status')).toBeInTheDocument(); + }); + expect(screen.getByText(/included in local backup ZIPs/i)).toBeInTheDocument(); + expect(screen.getByText(/DATA_DIR\/\.mfa_encryption_key/i)).toBeInTheDocument(); + }); + + // E4: concurrent warnings — generated key + legacy rows + it('shows backup hint AND legacy-rows warning when key is generated and legacy rows exist', async () => { + server.use( + http.get(STATUS_URL, () => + HttpResponse.json( + makeStatus({ + key_source: 'generated', + legacy_plaintext_rows: { oidc_providers: 2, user_totp: 0 }, + }), + ), + ), + ); + render(); + await waitFor(() => { + expect(screen.getByTestId('encryption-status')).toBeInTheDocument(); + }); + // Primary status: backup hint + expect(screen.getByText(/included in local backup ZIPs/i)).toBeInTheDocument(); + // Secondary: legacy-rows warning + expect(screen.getByTestId('encryption-legacy-warning')).toBeInTheDocument(); + }); + + it('renders yellow warning when legacy plaintext rows exist', async () => { + server.use( + http.get(STATUS_URL, () => + HttpResponse.json( + makeStatus({ + key_source: 'env', + legacy_plaintext_rows: { oidc_providers: 3, user_totp: 0 }, + }), + ), + ), + ); + render(); + await waitFor(() => { + expect(screen.getByText(/3 legacy plaintext row/i)).toBeInTheDocument(); + }); + }); + + it('renders red decryption-broken state when key missing but encrypted rows exist', async () => { + server.use( + http.get(STATUS_URL, () => + HttpResponse.json( + makeStatus({ + key_configured: false, + key_source: 'none', + encrypted_rows: { oidc_providers: 2, user_totp: 1 }, + decryption_broken: true, + }), + ), + ), + ); + render(); + await waitFor(() => { + expect(screen.getByText(/Encryption key missing/i)).toBeInTheDocument(); + }); + expect(screen.getByText(/3 encrypted record/i)).toBeInTheDocument(); + }); + + it('renders disabled (not configured) state', async () => { + server.use( + http.get(STATUS_URL, () => + HttpResponse.json(makeStatus({ key_configured: false, key_source: 'none' })), + ), + ); + render(); + await waitFor(() => { + expect(screen.getByText(/At-rest encryption not configured/i)).toBeInTheDocument(); + }); + }); + + // S5: manual retry button recovers from error state + it('renders a Retry button in the error state and recovers when clicked', async () => { + // First call → 500, every subsequent call → 200. + let calls = 0; + server.use( + http.get(STATUS_URL, () => { + calls += 1; + if (calls === 1) { + return new HttpResponse(null, { status: 500 }); + } + return HttpResponse.json(makeStatus({ key_source: 'env' })); + }), + ); + + render(); + + // Error state with retry button. + const retryButton = await screen.findByTestId('encryption-retry-button'); + expect(retryButton).toBeInTheDocument(); + expect(screen.getByTestId('encryption-error')).toBeInTheDocument(); + + // Click Retry → next response is 200, status card renders. + const user = userEvent.setup(); + await user.click(retryButton); + + await waitFor(() => { + expect(screen.getByTestId('encryption-status')).toBeInTheDocument(); + }); + }); + + // S5: bounded polling — after >3 consecutive errors, refetchInterval returns + // false so the card stops hammering a failing endpoint until the user clicks + // the Retry button or reloads the page. + it('polling stops after 3 consecutive errors', async () => { + // Persistent 500 from the API. + let calls = 0; + server.use( + http.get(STATUS_URL, () => { + calls += 1; + return new HttpResponse(null, { status: 500 }); + }), + ); + + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + render(); + + // First fetch errors immediately — wait for the error UI. + await screen.findByTestId('encryption-error'); + + // The first failure is `fetchFailureCount=1` → next refetch in 5s. + // 5s + 10s + 15s = 30s walks through failures 1→2→3. After failures + // exceed 3 the function returns false; advancing further must NOT + // produce additional calls. + const callsBeforeBackoff = calls; + + // Step the clock far past the entire backoff sequence. + vi.advanceTimersByTime(45_000); + await waitFor(() => { + expect(calls).toBeGreaterThanOrEqual(callsBeforeBackoff); + }); + const callsAfterFirstWalk = calls; + + // From here, polling must be quiescent — advancing another minute + // must add at most a small bounded number of calls (ideally 0). + vi.advanceTimersByTime(60_000); + // Allow react-query's microtasks to flush. + await Promise.resolve(); + + // Bounded retry: after the third failure the interval returns false, + // so additional polling calls in the second minute must be 0. + expect(calls - callsAfterFirstWalk).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + // S5: B2 migration_error_count surfaces a yellow warning banner. + it('renders a migration error warning when migration_error_count > 0', async () => { + server.use( + http.get(STATUS_URL, () => + HttpResponse.json(makeStatus({ migration_error_count: 3 })), + ), + ); + render(); + await waitFor(() => { + expect(screen.getByTestId('encryption-migration-warning')).toBeInTheDocument(); + }); + expect(screen.getByText(/3 legacy row/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d72454565..a6e0b0a91 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2809,6 +2809,22 @@ export interface LDAPStatus { ldap_configured: boolean; } +export interface EncryptionRowCounts { + oidc_providers: number; + user_totp: number; +} + +export interface EncryptionStatus { + key_configured: boolean; + key_source: 'env' | 'file' | 'generated' | 'none'; + legacy_plaintext_rows: EncryptionRowCounts; + encrypted_rows: EncryptionRowCounts; + decryption_broken: boolean; + // B2: count of rows skipped during the last legacy re-encryption migration. + // Surfaced via a yellow secondary banner in SecurityStatusCard. + migration_error_count: number; +} + export interface LDAPTestResponse { success: boolean; message: string; @@ -2871,6 +2887,7 @@ export const api = { getAdvancedAuthStatus: () => request('/auth/advanced-auth/status'), // LDAP Authentication getLDAPStatus: () => request('/auth/ldap/status'), + getEncryptionStatus: () => request('/auth/encryption-status'), testLDAP: () => request('/auth/ldap/test', { method: 'POST', diff --git a/frontend/src/components/SecurityStatusCard.tsx b/frontend/src/components/SecurityStatusCard.tsx new file mode 100644 index 000000000..10f89aa89 --- /dev/null +++ b/frontend/src/components/SecurityStatusCard.tsx @@ -0,0 +1,193 @@ +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { Shield, ShieldCheck, ShieldOff, AlertTriangle, XCircle, Loader2 } from 'lucide-react'; +import { api } from '../api/client'; +import type { EncryptionStatus } from '../api/client'; +import { Card, CardContent, CardHeader } from './Card'; +import { registerSettingsSearch } from '../lib/settingsSearch'; + +// Cross-tab search registration so this card surfaces in +// Settings → Search results under the users → security sub-tab. +registerSettingsSearch({ + labelKey: 'settings.encryption.title', + labelFallback: 'MFA Encryption Status', + tab: 'users', + subTab: 'security', + keywords: 'mfa encryption status security backup totp oidc fernet', + anchor: 'card-mfa-encryption', +}); + +/** + * Read-only status card showing the at-rest encryption state for + * OIDC client_secret and TOTP secret rows. Five severity levels: + * + * - Green: key configured, no legacy rows, no decryption-broken state. + * - Yellow: key configured but plaintext rows still need re-encryption. + * - Orange: key was auto-generated → operator must back up the key file + * (or set MFA_ENCRYPTION_KEY explicitly). + * - Red: encrypted rows exist but no key is loadable → recovery required. + * - Grey: encryption is not configured at all and no encrypted rows exist + * yet — a plain "not configured" disabled state. + */ +export function SecurityStatusCard() { + const { t } = useTranslation(); + + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ['encryptionStatus'], + queryFn: () => api.getEncryptionStatus(), + // S5: bounded auto-recovery via refetchInterval backoff + manual recovery + // via the "Retry" button rendered in the error branch below. Previously + // a single 5xx blip killed the live status indicator until a full page + // reload. The queryClient-level `retry` setting is left untouched so + // operators (production) get the default 3 internal retries while tests + // (which set retry:false) don't have to wait for them. + refetchInterval: (query) => { + if (!query.state.error) return 30_000; + // After the first error, back off: 5s, 10s, 15s, then stop until the + // user clicks Retry or the page reloads. + const failures = query.state.fetchFailureCount ?? 0; + if (failures <= 3) return Math.min(5_000 * Math.max(1, failures), 30_000); + return false; + }, + }); + + if (isLoading) { + return ( + + +
+ +

{t('settings.encryption.title')}

+
+
+ +
+ + {t('common.loading')} +
+
+
+ ); + } + + if (isError || !data) { + return ( + + +
+ +

{t('settings.encryption.title')}

+
+
+ +
{t('common.errorLoading')}
+ {/* S5: manual recovery button — the bounded auto-retry above stops + after 3 consecutive failures so the operator needs an explicit + way to reset polling without reloading the whole page. */} + +
+
+ ); + } + + const totalLegacy = data.legacy_plaintext_rows.oidc_providers + data.legacy_plaintext_rows.user_totp; + const totalEncrypted = data.encrypted_rows.oidc_providers + data.encrypted_rows.user_totp; + + // Severity selection — order matters: red first (recovery), then orange + // (backup hint for auto-generated key), then yellow (legacy rows), green + // (all good), grey (not configured at all and no encrypted rows). + let severityClasses: string; + let icon; + let statusLabel: string; + let statusBody: string; + + if (data.decryption_broken) { + severityClasses = 'bg-red-500/20 border-red-500/50 text-red-400'; + icon = ; + statusLabel = t('settings.encryption.decryptionBrokenTitle'); + statusBody = t('settings.encryption.decryptionBrokenError', { count: totalEncrypted }); + } else if (data.key_source === 'generated') { + severityClasses = 'bg-amber-500/10 border-amber-500/30 text-amber-400'; + icon = ; + statusLabel = t('settings.encryption.enabledGenerated'); + statusBody = t('settings.encryption.backupHint'); + } else if (totalLegacy > 0) { + severityClasses = 'bg-amber-500/10 border-amber-500/30 text-amber-400'; + icon = ; + statusLabel = data.key_source === 'env' ? t('settings.encryption.enabledFromEnv') : t('settings.encryption.enabledFromFile'); + statusBody = t('settings.encryption.legacyRowsWarning', { count: totalLegacy }); + } else if (data.key_configured) { + severityClasses = 'bg-green-500/20 border-green-500/30 text-green-400'; + icon = ; + statusLabel = data.key_source === 'env' ? t('settings.encryption.enabledFromEnv') : t('settings.encryption.enabledFromFile'); + statusBody = t('settings.encryption.allEncrypted'); + } else { + severityClasses = 'bg-gray-500/20 border-gray-500/30 text-gray-400'; + icon = ; + statusLabel = t('settings.encryption.notConfigured'); + statusBody = t('settings.encryption.notConfiguredDesc'); + } + + // E4: show legacy-rows warning as a secondary alert when key is auto-generated + // AND there are still unencrypted rows (both conditions can be true simultaneously). + const showConcurrentLegacyWarning = data.key_source === 'generated' && totalLegacy > 0; + + return ( + + +
+ {icon} +

{t('settings.encryption.title')}

+
+
+ +
+

{statusLabel}

+

{statusBody}

+
+ {showConcurrentLegacyWarning && ( +
+

{t('settings.encryption.legacyRowsWarning', { count: totalLegacy })}

+
+ )} + {data.migration_error_count > 0 && ( +
+

+ {t('settings.encryption.migrationErrorWarning', { count: data.migration_error_count })} +

+
+ )} +
+
+

{t('settings.encryption.encryptedRowsLabel')}

+

+ OIDC: {data.encrypted_rows.oidc_providers} · TOTP: {data.encrypted_rows.user_totp} +

+
+
+

{t('settings.encryption.legacyRowsLabel')}

+

+ OIDC: {data.legacy_plaintext_rows.oidc_providers} · TOTP: {data.legacy_plaintext_rows.user_totp} +

+
+
+
+
+ ); +} diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index d8af2ceee..d5f788bb2 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -40,6 +40,8 @@ export default { confirm: 'Bestätigen', loading: 'Lädt...', error: 'Fehler', + errorLoading: 'Fehler beim Laden', + retry: 'Erneut versuchen', success: 'Erfolg', warning: 'Warnung', enabled: 'Aktiviert', @@ -1379,6 +1381,7 @@ export default { ldap: 'LDAP', twoFa: 'Zwei-Faktor-Auth', oidc: 'SSO / OIDC', + security: 'Sicherheit', }, spoolbuddy: { infoTitle: 'SpoolBuddy-Geräte', @@ -2264,6 +2267,23 @@ export default { }, }, + encryption: { + title: 'MFA-Verschlüsselungsstatus', + enabledFromEnv: 'At-Rest-Verschlüsselung aktiv (Schlüssel aus Umgebungsvariable MFA_ENCRYPTION_KEY)', + enabledFromFile: 'At-Rest-Verschlüsselung aktiv (Schlüssel aus dem Datenverzeichnis geladen)', + enabledGenerated: 'At-Rest-Verschlüsselung aktiv mit automatisch generiertem Schlüssel', + notConfigured: 'At-Rest-Verschlüsselung nicht konfiguriert', + notConfiguredDesc: 'TOTP-Geheimnisse und OIDC-Client-Secrets werden im Klartext gespeichert. Setze MFA_ENCRYPTION_KEY oder starte Bambuddy mit beschreibbarem Datenverzeichnis neu, damit ein Schlüssel automatisch erzeugt wird.', + allEncrypted: 'Alle MFA-Geheimnisse sind verschlüsselt gespeichert.', + legacyRowsLabel: 'Klartext-Zeilen (Altbestand)', + encryptedRowsLabel: 'Verschlüsselte Zeilen', + legacyRowsWarning: '{{count}} Klartext-Zeile(n) erkannt. Den OIDC-Provider neu speichern oder den Authenticator des Benutzers neu einrichten, um die Daten verschlüsselt abzulegen.', + backupHint: 'Der automatisch erzeugte Schlüssel liegt unter DATA_DIR/.mfa_encryption_key und wird in lokalen Backup-ZIPs mitgesichert. Backups sicher aufbewahren oder MFA_ENCRYPTION_KEY explizit setzen.', + decryptionBrokenTitle: 'Verschlüsselungsschlüssel fehlt', + decryptionBrokenError: '{{count}} verschlüsselte Datensätze können nicht entschlüsselt werden, weil der Schlüssel nicht mehr verfügbar ist. Den vorherigen MFA_ENCRYPTION_KEY oder DATA_DIR/.mfa_encryption_key wiederherstellen.', + migrationErrorWarning: '{{count}} Legacy-Eintrag/Einträge konnten beim Start nicht verschlüsselt werden. Prüfen Sie die Server-Logs und starten Sie Bambuddy neu.', + }, + }, // Notifications (for push notifications) @@ -3698,6 +3718,7 @@ export default { // Backup backup: { + includesEncryptionKey: 'Lokale Sicherungen enthalten die MFA-Schlüsseldatei (DATA_DIR/.mfa_encryption_key), damit ein Backup-ZIP selbstkonsistent ist. Behandle das ZIP als sensibel — wer Zugriff auf die Datei hat, kann die darin enthaltenen OIDC-Client-Secrets und TOTP-Geheimnisse entschlüsseln.', title: 'Sichern & Wiederherstellen', createBackup: 'Sicherung erstellen', restoreBackup: 'Sicherung wiederherstellen', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 336b46bd7..d0d4b142b 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -40,6 +40,8 @@ export default { confirm: 'Confirm', loading: 'Loading...', error: 'Error', + errorLoading: 'Error loading data', + retry: 'Retry', success: 'Success', warning: 'Warning', enabled: 'Enabled', @@ -1380,6 +1382,7 @@ export default { ldap: 'LDAP', twoFa: 'Two-Factor Auth', oidc: 'SSO / OIDC', + security: 'Security', }, spoolbuddy: { infoTitle: 'SpoolBuddy devices', @@ -2267,6 +2270,23 @@ export default { }, }, + encryption: { + title: 'MFA Encryption Status', + enabledFromEnv: 'At-rest encryption enabled (key from MFA_ENCRYPTION_KEY environment variable)', + enabledFromFile: 'At-rest encryption enabled (key loaded from data directory)', + enabledGenerated: 'At-rest encryption enabled with auto-generated key', + notConfigured: 'At-rest encryption not configured', + notConfiguredDesc: 'TOTP secrets and OIDC client_secrets are stored in plaintext. Set MFA_ENCRYPTION_KEY or restart Bambuddy with a writable data directory to auto-generate one.', + allEncrypted: 'All MFA secrets are encrypted at rest.', + legacyRowsLabel: 'Legacy plaintext rows', + encryptedRowsLabel: 'Encrypted rows', + legacyRowsWarning: '{{count}} legacy plaintext row(s) detected. Re-save the OIDC provider or re-enroll the user’s authenticator app to migrate to encrypted storage.', + backupHint: 'The auto-generated key is stored at DATA_DIR/.mfa_encryption_key and is included in local backup ZIPs. Keep your backups secure or set MFA_ENCRYPTION_KEY explicitly.', + decryptionBrokenTitle: 'Encryption key missing', + decryptionBrokenError: '{{count}} encrypted record(s) cannot be decrypted because the encryption key is no longer available. Restore the previous MFA_ENCRYPTION_KEY or DATA_DIR/.mfa_encryption_key to recover.', + migrationErrorWarning: '{{count}} legacy row(s) failed to re-encrypt at startup. Check server logs and restart Bambuddy to retry.', + }, + }, // Notifications (for push notifications) @@ -3706,6 +3726,7 @@ export default { // Backup backup: { + includesEncryptionKey: 'Local backups include the MFA encryption key file (DATA_DIR/.mfa_encryption_key) so a backup ZIP is self-contained. Treat the ZIP as sensitive — anyone with the file can decrypt the OIDC client secrets and TOTP secrets stored inside.', title: 'Backup & Restore', createBackup: 'Create Backup', restoreBackup: 'Restore Backup', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 3a5dc7464..ba746bef3 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -40,6 +40,8 @@ export default { confirm: 'Confirmer', loading: 'Chargement...', error: 'Erreur', + errorLoading: 'Erreur de chargement', + retry: 'Réessayer', success: 'Succès', warning: 'Avertissement', enabled: 'Activé', @@ -1378,6 +1380,7 @@ export default { ldap: 'LDAP', twoFa: 'Authentification 2FA', oidc: 'SSO / OIDC', + security: 'Security', spoolbuddy: 'SpoolBuddy', }, ldap: { @@ -2208,6 +2211,25 @@ export default { }, }, + // TODO: translate encryption keys + encryption: { + title: 'MFA Encryption Status', + enabledFromEnv: 'At-rest encryption enabled (key from MFA_ENCRYPTION_KEY environment variable)', + enabledFromFile: 'At-rest encryption enabled (key loaded from data directory)', + enabledGenerated: 'At-rest encryption enabled with auto-generated key', + notConfigured: 'At-rest encryption not configured', + notConfiguredDesc: 'TOTP secrets and OIDC client_secrets are stored in plaintext. Set MFA_ENCRYPTION_KEY or restart Bambuddy with a writable data directory to auto-generate one.', + allEncrypted: 'All MFA secrets are encrypted at rest.', + legacyRowsLabel: 'Legacy plaintext rows', + encryptedRowsLabel: 'Encrypted rows', + legacyRowsWarning: '{{count}} legacy plaintext row(s) detected. Re-save the OIDC provider or re-enroll the user’s authenticator app to migrate to encrypted storage.', + backupHint: 'The auto-generated key is stored at DATA_DIR/.mfa_encryption_key and is included in local backup ZIPs. Keep your backups secure or set MFA_ENCRYPTION_KEY explicitly.', + decryptionBrokenTitle: 'Encryption key missing', + decryptionBrokenError: '{{count}} encrypted record(s) cannot be decrypted because the encryption key is no longer available. Restore the previous MFA_ENCRYPTION_KEY or DATA_DIR/.mfa_encryption_key to recover.', + migrationErrorWarning: "{{count}} ligne(s) ancienne(s) n'ont pas pu être rechiffrée(s) au démarrage. Vérifiez les journaux du serveur et redémarrez Bambuddy pour réessayer.", + }, + + spoolbuddy: { infoTitle: 'Périphériques SpoolBuddy', infoBody: 'Les bornes SpoolBuddy s\'enregistrent automatiquement via heartbeat. Désinscrivez ici un appareil qui n\'est plus utilisé ou un doublon obsolète laissé par un crash du daemon.', @@ -3685,6 +3707,7 @@ export default { // Backup backup: { + includesEncryptionKey: 'Local backups include the MFA encryption key file (DATA_DIR/.mfa_encryption_key) so a backup ZIP is self-contained. Treat the ZIP as sensitive — anyone with the file can decrypt the OIDC client secrets and TOTP secrets stored inside.', title: 'Sauvegarde & Restauration', createBackup: 'Créer Sauvegarde', restoreBackup: 'Restaurer Sauvegarde', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 2e5ddb9a2..ad3a6b911 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -40,6 +40,8 @@ export default { confirm: 'Conferma', loading: 'Caricamento...', error: 'Errore', + errorLoading: 'Errore di caricamento', + retry: 'Riprova', success: 'Successo', warning: 'Avviso', enabled: 'Abilitato', @@ -1378,6 +1380,7 @@ export default { ldap: 'LDAP', twoFa: 'Autenticazione 2FA', oidc: 'SSO / OIDC', + security: 'Security', spoolbuddy: 'SpoolBuddy', }, ldap: { @@ -2207,6 +2210,25 @@ export default { }, }, + // TODO: translate encryption keys + encryption: { + title: 'MFA Encryption Status', + enabledFromEnv: 'At-rest encryption enabled (key from MFA_ENCRYPTION_KEY environment variable)', + enabledFromFile: 'At-rest encryption enabled (key loaded from data directory)', + enabledGenerated: 'At-rest encryption enabled with auto-generated key', + notConfigured: 'At-rest encryption not configured', + notConfiguredDesc: 'TOTP secrets and OIDC client_secrets are stored in plaintext. Set MFA_ENCRYPTION_KEY or restart Bambuddy with a writable data directory to auto-generate one.', + allEncrypted: 'All MFA secrets are encrypted at rest.', + legacyRowsLabel: 'Legacy plaintext rows', + encryptedRowsLabel: 'Encrypted rows', + legacyRowsWarning: '{{count}} legacy plaintext row(s) detected. Re-save the OIDC provider or re-enroll the user’s authenticator app to migrate to encrypted storage.', + backupHint: 'The auto-generated key is stored at DATA_DIR/.mfa_encryption_key and is included in local backup ZIPs. Keep your backups secure or set MFA_ENCRYPTION_KEY explicitly.', + decryptionBrokenTitle: 'Encryption key missing', + decryptionBrokenError: '{{count}} encrypted record(s) cannot be decrypted because the encryption key is no longer available. Restore the previous MFA_ENCRYPTION_KEY or DATA_DIR/.mfa_encryption_key to recover.', + migrationErrorWarning: "{{count}} riga/righe legacy non sono state ricifrate all'avvio. Controlla i log del server e riavvia Bambuddy per riprovare.", + }, + + spoolbuddy: { infoTitle: 'Dispositivi SpoolBuddy', infoBody: 'I kiosk SpoolBuddy si registrano automaticamente tramite heartbeat. Annulla la registrazione qui se un dispositivo non è più in uso o se un duplicato obsoleto è rimasto dopo un crash del daemon.', @@ -3684,6 +3706,7 @@ export default { // Backup backup: { + includesEncryptionKey: 'Local backups include the MFA encryption key file (DATA_DIR/.mfa_encryption_key) so a backup ZIP is self-contained. Treat the ZIP as sensitive — anyone with the file can decrypt the OIDC client secrets and TOTP secrets stored inside.', title: 'Backup e ripristino', createBackup: 'Crea backup', restoreBackup: 'Ripristina backup', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 8d68f01df..49dc14ecc 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -40,6 +40,8 @@ export default { confirm: '確認', loading: '読み込み中...', error: 'エラー', + errorLoading: 'データの読み込みエラー', + retry: '再試行', success: '成功', warning: '警告', enabled: '有効', @@ -1378,6 +1380,7 @@ export default { ldap: 'LDAP', twoFa: '二段階認証', oidc: 'SSO / OIDC', + security: 'Security', }, spoolbuddy: { infoTitle: 'SpoolBuddy デバイス', @@ -2263,6 +2266,24 @@ export default { }, }, + // TODO: translate encryption keys + encryption: { + title: 'MFA Encryption Status', + enabledFromEnv: 'At-rest encryption enabled (key from MFA_ENCRYPTION_KEY environment variable)', + enabledFromFile: 'At-rest encryption enabled (key loaded from data directory)', + enabledGenerated: 'At-rest encryption enabled with auto-generated key', + notConfigured: 'At-rest encryption not configured', + notConfiguredDesc: 'TOTP secrets and OIDC client_secrets are stored in plaintext. Set MFA_ENCRYPTION_KEY or restart Bambuddy with a writable data directory to auto-generate one.', + allEncrypted: 'All MFA secrets are encrypted at rest.', + legacyRowsLabel: 'Legacy plaintext rows', + encryptedRowsLabel: 'Encrypted rows', + legacyRowsWarning: '{{count}} legacy plaintext row(s) detected. Re-save the OIDC provider or re-enroll the user’s authenticator app to migrate to encrypted storage.', + backupHint: 'The auto-generated key is stored at DATA_DIR/.mfa_encryption_key and is included in local backup ZIPs. Keep your backups secure or set MFA_ENCRYPTION_KEY explicitly.', + decryptionBrokenTitle: 'Encryption key missing', + decryptionBrokenError: '{{count}} encrypted record(s) cannot be decrypted because the encryption key is no longer available. Restore the previous MFA_ENCRYPTION_KEY or DATA_DIR/.mfa_encryption_key to recover.', + migrationErrorWarning: '{{count}} 件のレガシー行を起動時に再暗号化できませんでした。サーバーログを確認し、Bambuddy を再起動して再試行してください。', + }, + }, // Notifications (for push notifications) @@ -3697,6 +3718,7 @@ export default { // Backup backup: { + includesEncryptionKey: 'Local backups include the MFA encryption key file (DATA_DIR/.mfa_encryption_key) so a backup ZIP is self-contained. Treat the ZIP as sensitive — anyone with the file can decrypt the OIDC client secrets and TOTP secrets stored inside.', title: 'バックアップと復元', createBackup: 'バックアップを作成', restoreBackup: 'バックアップの復元', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 3357c683e..fe49dc5c9 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -40,6 +40,8 @@ export default { confirm: 'Confirmar', loading: 'Carregando...', error: 'Erro', + errorLoading: 'Erro ao carregar', + retry: 'Tentar novamente', success: 'Sucesso', warning: 'Aviso', enabled: 'Ativado', @@ -1378,6 +1380,7 @@ export default { ldap: 'LDAP', twoFa: 'Autenticação 2FA', oidc: 'SSO / OIDC', + security: 'Security', spoolbuddy: 'SpoolBuddy', }, ldap: { @@ -2207,6 +2210,25 @@ export default { }, }, + // TODO: translate encryption keys + encryption: { + title: 'MFA Encryption Status', + enabledFromEnv: 'At-rest encryption enabled (key from MFA_ENCRYPTION_KEY environment variable)', + enabledFromFile: 'At-rest encryption enabled (key loaded from data directory)', + enabledGenerated: 'At-rest encryption enabled with auto-generated key', + notConfigured: 'At-rest encryption not configured', + notConfiguredDesc: 'TOTP secrets and OIDC client_secrets are stored in plaintext. Set MFA_ENCRYPTION_KEY or restart Bambuddy with a writable data directory to auto-generate one.', + allEncrypted: 'All MFA secrets are encrypted at rest.', + legacyRowsLabel: 'Legacy plaintext rows', + encryptedRowsLabel: 'Encrypted rows', + legacyRowsWarning: '{{count}} legacy plaintext row(s) detected. Re-save the OIDC provider or re-enroll the user’s authenticator app to migrate to encrypted storage.', + backupHint: 'The auto-generated key is stored at DATA_DIR/.mfa_encryption_key and is included in local backup ZIPs. Keep your backups secure or set MFA_ENCRYPTION_KEY explicitly.', + decryptionBrokenTitle: 'Encryption key missing', + decryptionBrokenError: '{{count}} encrypted record(s) cannot be decrypted because the encryption key is no longer available. Restore the previous MFA_ENCRYPTION_KEY or DATA_DIR/.mfa_encryption_key to recover.', + migrationErrorWarning: '{{count}} linha(s) antiga(s) não puderam ser recriptografadas na inicialização. Verifique os logs do servidor e reinicie o Bambuddy para tentar novamente.', + }, + + spoolbuddy: { infoTitle: 'Dispositivos SpoolBuddy', infoBody: 'Os kiosks SpoolBuddy se registram automaticamente via heartbeat. Cancele o registro de um dispositivo aqui se não estiver mais em uso ou se um duplicado obsoleto foi deixado por uma falha do daemon.', @@ -3684,6 +3706,7 @@ export default { // Backup backup: { + includesEncryptionKey: 'Local backups include the MFA encryption key file (DATA_DIR/.mfa_encryption_key) so a backup ZIP is self-contained. Treat the ZIP as sensitive — anyone with the file can decrypt the OIDC client secrets and TOTP secrets stored inside.', title: 'Bakup e Restauração', createBackup: 'Criar Backup', restoreBackup: 'Restaurar Backup', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 9d2987002..19f1ee3bb 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -40,6 +40,8 @@ export default { confirm: '确认', loading: '加载中...', error: '错误', + errorLoading: '加载错误', + retry: '重试', success: '成功', warning: '警告', enabled: '已启用', @@ -1379,6 +1381,7 @@ export default { ldap: 'LDAP', twoFa: '双因素认证', oidc: 'SSO / OIDC', + security: 'Security', }, spoolbuddy: { infoTitle: 'SpoolBuddy 设备', @@ -2251,6 +2254,24 @@ export default { }, }, + // TODO: translate encryption keys + encryption: { + title: 'MFA Encryption Status', + enabledFromEnv: 'At-rest encryption enabled (key from MFA_ENCRYPTION_KEY environment variable)', + enabledFromFile: 'At-rest encryption enabled (key loaded from data directory)', + enabledGenerated: 'At-rest encryption enabled with auto-generated key', + notConfigured: 'At-rest encryption not configured', + notConfiguredDesc: 'TOTP secrets and OIDC client_secrets are stored in plaintext. Set MFA_ENCRYPTION_KEY or restart Bambuddy with a writable data directory to auto-generate one.', + allEncrypted: 'All MFA secrets are encrypted at rest.', + legacyRowsLabel: 'Legacy plaintext rows', + encryptedRowsLabel: 'Encrypted rows', + legacyRowsWarning: '{{count}} legacy plaintext row(s) detected. Re-save the OIDC provider or re-enroll the user’s authenticator app to migrate to encrypted storage.', + backupHint: 'The auto-generated key is stored at DATA_DIR/.mfa_encryption_key and is included in local backup ZIPs. Keep your backups secure or set MFA_ENCRYPTION_KEY explicitly.', + decryptionBrokenTitle: 'Encryption key missing', + decryptionBrokenError: '{{count}} encrypted record(s) cannot be decrypted because the encryption key is no longer available. Restore the previous MFA_ENCRYPTION_KEY or DATA_DIR/.mfa_encryption_key to recover.', + migrationErrorWarning: '{{count}} 行旧数据在启动时未能重新加密。请检查服务器日志并重启 Bambuddy 以重试。', + }, + }, // Notifications (for push notifications) @@ -3685,6 +3706,7 @@ export default { // Backup backup: { + includesEncryptionKey: 'Local backups include the MFA encryption key file (DATA_DIR/.mfa_encryption_key) so a backup ZIP is self-contained. Treat the ZIP as sensitive — anyone with the file can decrypt the OIDC client secrets and TOTP secrets stored inside.', title: '备份与恢复', createBackup: '创建备份', restoreBackup: '恢复备份', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index f9cce8f38..7b18bcc7a 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -40,6 +40,8 @@ export default { confirm: '確認', loading: '載入中...', error: '錯誤', + errorLoading: '載入錯誤', + retry: '重試', success: '成功', warning: '警告', enabled: '已啟用', @@ -1379,6 +1381,7 @@ export default { ldap: 'LDAP', twoFa: '雙因素認證', oidc: 'SSO / OIDC', + security: 'Security', }, spoolbuddy: { infoTitle: 'SpoolBuddy 裝置', @@ -2251,6 +2254,24 @@ export default { }, }, + // TODO: translate encryption keys + encryption: { + title: 'MFA Encryption Status', + enabledFromEnv: 'At-rest encryption enabled (key from MFA_ENCRYPTION_KEY environment variable)', + enabledFromFile: 'At-rest encryption enabled (key loaded from data directory)', + enabledGenerated: 'At-rest encryption enabled with auto-generated key', + notConfigured: 'At-rest encryption not configured', + notConfiguredDesc: 'TOTP secrets and OIDC client_secrets are stored in plaintext. Set MFA_ENCRYPTION_KEY or restart Bambuddy with a writable data directory to auto-generate one.', + allEncrypted: 'All MFA secrets are encrypted at rest.', + legacyRowsLabel: 'Legacy plaintext rows', + encryptedRowsLabel: 'Encrypted rows', + legacyRowsWarning: '{{count}} legacy plaintext row(s) detected. Re-save the OIDC provider or re-enroll the user’s authenticator app to migrate to encrypted storage.', + backupHint: 'The auto-generated key is stored at DATA_DIR/.mfa_encryption_key and is included in local backup ZIPs. Keep your backups secure or set MFA_ENCRYPTION_KEY explicitly.', + decryptionBrokenTitle: 'Encryption key missing', + decryptionBrokenError: '{{count}} encrypted record(s) cannot be decrypted because the encryption key is no longer available. Restore the previous MFA_ENCRYPTION_KEY or DATA_DIR/.mfa_encryption_key to recover.', + migrationErrorWarning: '{{count}} 行舊資料在啟動時未能重新加密。請檢查伺服器日誌並重新啟動 Bambuddy 以重試。', + }, + }, // Notifications (for push notifications) @@ -3685,6 +3706,7 @@ export default { // Backup backup: { + includesEncryptionKey: 'Local backups include the MFA encryption key file (DATA_DIR/.mfa_encryption_key) so a backup ZIP is self-contained. Treat the ZIP as sensitive — anyone with the file can decrypt the OIDC client secrets and TOTP secrets stored inside.', title: '備份與恢復', createBackup: '建立備份', restoreBackup: '恢復備份', diff --git a/frontend/src/lib/settingsSearch.ts b/frontend/src/lib/settingsSearch.ts index d96c47825..07ee7321c 100644 --- a/frontend/src/lib/settingsSearch.ts +++ b/frontend/src/lib/settingsSearch.ts @@ -22,7 +22,9 @@ export type SettingsSearchTab = | 'backup' | 'failure-detection'; -export type SettingsSearchSubTab = 'users' | 'email' | 'ldap' | 'oidc' | 'twofa'; +export type SettingsSearchSubTab = 'users' | 'email' | 'ldap' | 'oidc' | 'twofa' | 'security'; + +export type UsersSubTab = SettingsSearchSubTab; export interface SettingsSearchEntry { /** i18n key for the label. Resolved with t() at render time. */ diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 212f09214..7c4cef962 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -32,6 +32,7 @@ import { EmailSettings } from '../components/EmailSettings'; import { LDAPSettings } from '../components/LDAPSettings'; import { TwoFactorSettings } from '../components/TwoFactorSettings'; import { OIDCProviderSettings } from '../components/OIDCProviderSettings'; +import { SecurityStatusCard } from '../components/SecurityStatusCard'; import { APIBrowser } from '../components/APIBrowser'; import { Toggle } from '../components/Toggle'; import { virtualPrinterApi, spoolbuddyApi } from '../api/client'; @@ -42,10 +43,10 @@ import { useTheme, type ThemeStyle, type DarkBackground, type LightBackground, t import { useState, useEffect, useRef, useCallback } from 'react'; import { Palette } from 'lucide-react'; import { registerSettingsSearch, getSettingsSearchEntries } from '../lib/settingsSearch'; +import type { UsersSubTab } from '../lib/settingsSearch'; const validTabs = ['general', 'plugs', 'notifications', 'queue', 'filament', 'network', 'apikeys', 'virtual-printer', 'spoolbuddy', 'failure-detection', 'users', 'backup'] as const; type TabType = typeof validTabs[number]; -type UsersSubTab = 'users' | 'email' | 'ldap' | 'twofa' | 'oidc'; // Cross-tab search registrations for cards rendered inline in this file. // Adding a new settings card? Register it here (or, if the card lives in its @@ -4891,6 +4892,19 @@ export function SettingsPage() { /> )} + {isAdmin && ( + + )} {/* Users Sub-tab */} @@ -5229,6 +5243,12 @@ export function SettingsPage() { )} + + {usersSubTab === 'security' && isAdmin && ( +
+ +
+ )} )} @@ -5700,6 +5720,10 @@ export function SettingsPage() { {activeTab === 'backup' && (
+
+ +

{t('backup.includesEncryptionKey')}

+
)} diff --git a/static/assets/index-C_2KW3q0.js b/static/assets/index-C_2KW3q0.js new file mode 100644 index 000000000..29a6c0477 --- /dev/null +++ b/static/assets/index-C_2KW3q0.js @@ -0,0 +1,8744 @@ +function wue(t,e){for(var n=0;na[i]})}}}return Object.freeze(Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}))}(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))a(i);new MutationObserver(i=>{for(const s of i)if(s.type==="childList")for(const o of s.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&a(o)}).observe(document,{childList:!0,subtree:!0});function n(i){const s={};return i.integrity&&(s.integrity=i.integrity),i.referrerPolicy&&(s.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?s.credentials="include":i.crossOrigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function a(i){if(i.ep)return;i.ep=!0;const s=n(i);fetch(i.href,s)}})();var dk=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function gc(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var zM={exports:{}},Iv={};var cH;function Sue(){if(cH)return Iv;cH=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.fragment");function n(a,i,s){var o=null;if(s!==void 0&&(o=""+s),i.key!==void 0&&(o=""+i.key),"key"in i){s={};for(var l in i)l!=="key"&&(s[l]=i[l])}else s=i;return i=s.ref,{$$typeof:t,type:a,key:o,ref:i!==void 0?i:null,props:s}}return Iv.Fragment=e,Iv.jsx=n,Iv.jsxs=n,Iv}var dH;function _ue(){return dH||(dH=1,zM.exports=Sue()),zM.exports}var r=_ue(),UM={exports:{}},Gn={};var uH;function kue(){if(uH)return Gn;uH=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.portal"),n=Symbol.for("react.fragment"),a=Symbol.for("react.strict_mode"),i=Symbol.for("react.profiler"),s=Symbol.for("react.consumer"),o=Symbol.for("react.context"),l=Symbol.for("react.forward_ref"),c=Symbol.for("react.suspense"),d=Symbol.for("react.memo"),u=Symbol.for("react.lazy"),m=Symbol.for("react.activity"),h=Symbol.iterator;function f(V){return V===null||typeof V!="object"?null:(V=h&&V[h]||V["@@iterator"],typeof V=="function"?V:null)}var x={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},v=Object.assign,g={};function b(V,ie,me){this.props=V,this.context=ie,this.refs=g,this.updater=me||x}b.prototype.isReactComponent={},b.prototype.setState=function(V,ie){if(typeof V!="object"&&typeof V!="function"&&V!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,V,ie,"setState")},b.prototype.forceUpdate=function(V){this.updater.enqueueForceUpdate(this,V,"forceUpdate")};function S(){}S.prototype=b.prototype;function N(V,ie,me){this.props=V,this.context=ie,this.refs=g,this.updater=me||x}var P=N.prototype=new S;P.constructor=N,v(P,b.prototype),P.isPureReactComponent=!0;var C=Array.isArray;function T(){}var A={H:null,A:null,T:null,S:null},E=Object.prototype.hasOwnProperty;function k(V,ie,me){var be=me.ref;return{$$typeof:t,type:V,key:ie,ref:be!==void 0?be:null,props:me}}function j(V,ie){return k(V.type,ie,V.props)}function B(V){return typeof V=="object"&&V!==null&&V.$$typeof===t}function O(V){var ie={"=":"=0",":":"=2"};return"$"+V.replace(/[=:]/g,function(me){return ie[me]})}var W=/\/+/g;function L(V,ie){return typeof V=="object"&&V!==null&&V.key!=null?O(""+V.key):ie.toString(36)}function ee(V){switch(V.status){case"fulfilled":return V.value;case"rejected":throw V.reason;default:switch(typeof V.status=="string"?V.then(T,T):(V.status="pending",V.then(function(ie){V.status==="pending"&&(V.status="fulfilled",V.value=ie)},function(ie){V.status==="pending"&&(V.status="rejected",V.reason=ie)})),V.status){case"fulfilled":return V.value;case"rejected":throw V.reason}}throw V}function ne(V,ie,me,be,fe){var H=typeof V;(H==="undefined"||H==="boolean")&&(V=null);var X=!1;if(V===null)X=!0;else switch(H){case"bigint":case"string":case"number":X=!0;break;case"object":switch(V.$$typeof){case t:case e:X=!0;break;case u:return X=V._init,ne(X(V._payload),ie,me,be,fe)}}if(X)return fe=fe(V),X=be===""?"."+L(V,0):be,C(fe)?(me="",X!=null&&(me=X.replace(W,"$&/")+"/"),ne(fe,ie,me,"",function(U){return U})):fe!=null&&(B(fe)&&(fe=j(fe,me+(fe.key==null||V&&V.key===fe.key?"":(""+fe.key).replace(W,"$&/")+"/")+X)),ie.push(fe)),1;X=0;var F=be===""?".":be+":";if(C(V))for(var D=0;D>>1,oe=ne[ae];if(0>>1;aei(me,ce))bei(fe,me)?(ne[ae]=fe,ne[be]=ce,ae=be):(ne[ae]=me,ne[ie]=ce,ae=ie);else if(bei(fe,ce))ne[ae]=fe,ne[be]=ce,ae=be;else break e}}return Z}function i(ne,Z){var ce=ne.sortIndex-Z.sortIndex;return ce!==0?ce:ne.id-Z.id}if(t.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var s=performance;t.unstable_now=function(){return s.now()}}else{var o=Date,l=o.now();t.unstable_now=function(){return o.now()-l}}var c=[],d=[],u=1,m=null,h=3,f=!1,x=!1,v=!1,g=!1,b=typeof setTimeout=="function"?setTimeout:null,S=typeof clearTimeout=="function"?clearTimeout:null,N=typeof setImmediate<"u"?setImmediate:null;function P(ne){for(var Z=n(d);Z!==null;){if(Z.callback===null)a(d);else if(Z.startTime<=ne)a(d),Z.sortIndex=Z.expirationTime,e(c,Z);else break;Z=n(d)}}function C(ne){if(v=!1,P(ne),!x)if(n(c)!==null)x=!0,T||(T=!0,O());else{var Z=n(d);Z!==null&&ee(C,Z.startTime-ne)}}var T=!1,A=-1,E=5,k=-1;function j(){return g?!0:!(t.unstable_now()-kne&&j());){var ae=m.callback;if(typeof ae=="function"){m.callback=null,h=m.priorityLevel;var oe=ae(m.expirationTime<=ne);if(ne=t.unstable_now(),typeof oe=="function"){m.callback=oe,P(ne),Z=!0;break t}m===n(c)&&a(c),P(ne)}else a(c);m=n(c)}if(m!==null)Z=!0;else{var V=n(d);V!==null&&ee(C,V.startTime-ne),Z=!1}}break e}finally{m=null,h=ce,f=!1}Z=void 0}}finally{Z?O():T=!1}}}var O;if(typeof N=="function")O=function(){N(B)};else if(typeof MessageChannel<"u"){var W=new MessageChannel,L=W.port2;W.port1.onmessage=B,O=function(){L.postMessage(null)}}else O=function(){b(B,0)};function ee(ne,Z){A=b(function(){ne(t.unstable_now())},Z)}t.unstable_IdlePriority=5,t.unstable_ImmediatePriority=1,t.unstable_LowPriority=4,t.unstable_NormalPriority=3,t.unstable_Profiling=null,t.unstable_UserBlockingPriority=2,t.unstable_cancelCallback=function(ne){ne.callback=null},t.unstable_forceFrameRate=function(ne){0>ne||125ae?(ne.sortIndex=ce,e(d,ne),n(c)===null&&ne===n(d)&&(v?(S(A),A=-1):v=!0,ee(C,ce-ae))):(ne.sortIndex=oe,e(c,ne),x||f||(x=!0,T||(T=!0,O()))),ne},t.unstable_shouldYield=j,t.unstable_wrapCallback=function(ne){var Z=h;return function(){var ce=h;h=Z;try{return ne.apply(this,arguments)}finally{h=ce}}}})(qM)),qM}var pH;function Pue(){return pH||(pH=1,HM.exports=Cue()),HM.exports}var $M={exports:{}},Fs={};var fH;function Tue(){if(fH)return Fs;fH=1;var t=Vg();function e(c){var d="https://react.dev/errors/"+c;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(t)}catch(e){console.error(e)}}return t(),$M.exports=Tue(),$M.exports}var bH;function Aue(){if(bH)return Ov;bH=1;var t=Pue(),e=Vg(),n=PQ();function a(p){var y="https://react.dev/errors/"+p;if(1oe||(p.current=ae[oe],ae[oe]=null,oe--)}function me(p,y){oe++,ae[oe]=p.current,p.current=y}var be=V(null),fe=V(null),H=V(null),X=V(null);function F(p,y){switch(me(H,y),me(fe,p),me(be,null),y.nodeType){case 9:case 11:p=(p=y.documentElement)&&(p=p.namespaceURI)?E8(p):0;break;default:if(p=y.tagName,y=y.namespaceURI)y=E8(y),p=D8(y,p);else switch(p){case"svg":p=1;break;case"math":p=2;break;default:p=0}}ie(be),me(be,p)}function D(){ie(be),ie(fe),ie(H)}function U(p){p.memoizedState!==null&&me(X,p);var y=be.current,_=D8(y,p.type);y!==_&&(me(fe,p),me(be,_))}function Q(p){fe.current===p&&(ie(be),ie(fe)),X.current===p&&(ie(X),Dv._currentValue=ce)}var I,de;function z(p){if(I===void 0)try{throw Error()}catch(_){var y=_.stack.trim().match(/\n( *(at )?)/);I=y&&y[1]||"",de=-1<_.stack.indexOf(` + at`)?" ()":-1<_.stack.indexOf("@")?"@unknown:0:0":""}return` +`+I+p+de}var Y=!1;function $(p,y){if(!p||Y)return"";Y=!0;var _=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var M={DetermineComponentFrameRoot:function(){try{if(y){var Vt=function(){throw Error()};if(Object.defineProperty(Vt.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(Vt,[])}catch(Lt){var Mt=Lt}Reflect.construct(p,[],Vt)}else{try{Vt.call()}catch(Lt){Mt=Lt}p.call(Vt.prototype)}}else{try{throw Error()}catch(Lt){Mt=Lt}(Vt=p())&&typeof Vt.catch=="function"&&Vt.catch(function(){})}}catch(Lt){if(Lt&&Mt&&typeof Lt.stack=="string")return[Lt.stack,Mt.stack]}return[null,null]}};M.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var G=Object.getOwnPropertyDescriptor(M.DetermineComponentFrameRoot,"name");G&&G.configurable&&Object.defineProperty(M.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var J=M.DetermineComponentFrameRoot(),ke=J[0],He=J[1];if(ke&&He){var dt=ke.split(` +`),At=He.split(` +`);for(G=M=0;MG||dt[M]!==At[G]){var Ut=` +`+dt[M].replace(" at new "," at ");return p.displayName&&Ut.includes("")&&(Ut=Ut.replace("",p.displayName)),Ut}while(1<=M&&0<=G);break}}}finally{Y=!1,Error.prepareStackTrace=_}return(_=p?p.displayName||p.name:"")?z(_):""}function K(p,y){switch(p.tag){case 26:case 27:case 5:return z(p.type);case 16:return z("Lazy");case 13:return p.child!==y&&y!==null?z("Suspense Fallback"):z("Suspense");case 19:return z("SuspenseList");case 0:case 15:return $(p.type,!1);case 11:return $(p.type.render,!1);case 1:return $(p.type,!0);case 31:return z("Activity");default:return""}}function te(p){try{var y="",_=null;do y+=K(p,_),_=p,p=p.return;while(p);return y}catch(M){return` +Error generating stack: `+M.message+` +`+M.stack}}var re=Object.prototype.hasOwnProperty,xe=t.unstable_scheduleCallback,he=t.unstable_cancelCallback,ue=t.unstable_shouldYield,q=t.unstable_requestPaint,R=t.unstable_now,se=t.unstable_getCurrentPriorityLevel,we=t.unstable_ImmediatePriority,Se=t.unstable_UserBlockingPriority,Pe=t.unstable_NormalPriority,ye=t.unstable_LowPriority,ge=t.unstable_IdlePriority,Me=t.log,Re=t.unstable_setDisableYieldValue,_e=null,ze=null;function Te(p){if(typeof Me=="function"&&Re(p),ze&&typeof ze.setStrictMode=="function")try{ze.setStrictMode(_e,p)}catch{}}var Ue=Math.clz32?Math.clz32:Fe,Ge=Math.log,rt=Math.LN2;function Fe(p){return p>>>=0,p===0?32:31-(Ge(p)/rt|0)|0}var nt=256,Ne=262144,Ye=4194304;function Oe(p){var y=p&42;if(y!==0)return y;switch(p&-p){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return p&261888;case 262144:case 524288:case 1048576:case 2097152:return p&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return p&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return p}}function pe(p,y,_){var M=p.pendingLanes;if(M===0)return 0;var G=0,J=p.suspendedLanes,ke=p.pingedLanes;p=p.warmLanes;var He=M&134217727;return He!==0?(M=He&~J,M!==0?G=Oe(M):(ke&=He,ke!==0?G=Oe(ke):_||(_=He&~p,_!==0&&(G=Oe(_))))):(He=M&~J,He!==0?G=Oe(He):ke!==0?G=Oe(ke):_||(_=M&~p,_!==0&&(G=Oe(_)))),G===0?0:y!==0&&y!==G&&(y&J)===0&&(J=G&-G,_=y&-y,J>=_||J===32&&(_&4194048)!==0)?y:G}function Ae(p,y){return(p.pendingLanes&~(p.suspendedLanes&~p.pingedLanes)&y)===0}function Ve(p,y){switch(p){case 1:case 2:case 4:case 8:case 64:return y+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return y+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function ot(){var p=Ye;return Ye<<=1,(Ye&62914560)===0&&(Ye=4194304),p}function bt(p){for(var y=[],_=0;31>_;_++)y.push(p);return y}function Rt(p,y){p.pendingLanes|=y,y!==268435456&&(p.suspendedLanes=0,p.pingedLanes=0,p.warmLanes=0)}function Ct(p,y,_,M,G,J){var ke=p.pendingLanes;p.pendingLanes=_,p.suspendedLanes=0,p.pingedLanes=0,p.warmLanes=0,p.expiredLanes&=_,p.entangledLanes&=_,p.errorRecoveryDisabledLanes&=_,p.shellSuspendCounter=0;var He=p.entanglements,dt=p.expirationTimes,At=p.hiddenUpdates;for(_=ke&~_;0<_;){var Ut=31-Ue(_),Vt=1<"u")return null;try{return p.activeElement||p.body}catch{return p.body}}var Pr=/[\n"\\]/g;function zn(p){return p.replace(Pr,function(y){return"\\"+y.charCodeAt(0).toString(16)+" "})}function Ra(p,y,_,M,G,J,ke,He){p.name="",ke!=null&&typeof ke!="function"&&typeof ke!="symbol"&&typeof ke!="boolean"?p.type=ke:p.removeAttribute("type"),y!=null?ke==="number"?(y===0&&p.value===""||p.value!=y)&&(p.value=""+ln(y)):p.value!==""+ln(y)&&(p.value=""+ln(y)):ke!=="submit"&&ke!=="reset"||p.removeAttribute("value"),y!=null?Or(p,ke,ln(y)):_!=null?Or(p,ke,ln(_)):M!=null&&p.removeAttribute("value"),G==null&&J!=null&&(p.defaultChecked=!!J),G!=null&&(p.checked=G&&typeof G!="function"&&typeof G!="symbol"),He!=null&&typeof He!="function"&&typeof He!="symbol"&&typeof He!="boolean"?p.name=""+ln(He):p.removeAttribute("name")}function qn(p,y,_,M,G,J,ke,He){if(J!=null&&typeof J!="function"&&typeof J!="symbol"&&typeof J!="boolean"&&(p.type=J),y!=null||_!=null){if(!(J!=="submit"&&J!=="reset"||y!=null)){Pn(p);return}_=_!=null?""+ln(_):"",y=y!=null?""+ln(y):_,He||y===p.value||(p.value=y),p.defaultValue=y}M=M??G,M=typeof M!="function"&&typeof M!="symbol"&&!!M,p.checked=He?p.checked:!!M,p.defaultChecked=!!M,ke!=null&&typeof ke!="function"&&typeof ke!="symbol"&&typeof ke!="boolean"&&(p.name=ke),Pn(p)}function Or(p,y,_){y==="number"&&gr(p.ownerDocument)===p||p.defaultValue===""+_||(p.defaultValue=""+_)}function Wr(p,y,_,M){if(p=p.options,y){y={};for(var G=0;G<_.length;G++)y["$"+_[G]]=!0;for(_=0;_"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Oi=!1;if($o)try{var Sl={};Object.defineProperty(Sl,"passive",{get:function(){Oi=!0}}),window.addEventListener("test",Sl,Sl),window.removeEventListener("test",Sl,Sl)}catch{Oi=!1}var yo=null,ha=null,vc=null;function Ym(){if(vc)return vc;var p,y=ha,_=y.length,M,G="value"in yo?yo.value:yo.textContent,J=G.length;for(p=0;p<_&&y[p]===G[p];p++);var ke=_-p;for(M=1;M<=ke&&y[_-M]===G[J-M];M++);return vc=G.slice(p,1=gu),yt=" ",zt=!1;function lt(p,y){switch(p){case"keyup":return th.indexOf(y.keyCode)!==-1;case"keydown":return y.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function cn(p){return p=p.detail,typeof p=="object"&&"data"in p?p.data:null}var Dn=!1;function gn(p,y){switch(p){case"compositionend":return cn(y);case"keypress":return y.which!==32?null:(zt=!0,yt);case"textInput":return p=y.data,p===yt&&zt?null:p;default:return null}}function Rr(p,y){if(Dn)return p==="compositionend"||!fu&<(p,y)?(p=Ym(),vc=ha=yo=null,Dn=!1,p):null;switch(p){case"paste":return null;case"keypress":if(!(y.ctrlKey||y.altKey||y.metaKey)||y.ctrlKey&&y.altKey){if(y.char&&1=y)return{node:_,offset:y-p};p=M}e:{for(;_;){if(_.nextSibling){_=_.nextSibling;break e}_=_.parentNode}_=void 0}_=WA(_)}}function af(p,y){return p&&y?p===y?!0:p&&p.nodeType===3?!1:y&&y.nodeType===3?af(p,y.parentNode):"contains"in p?p.contains(y):p.compareDocumentPosition?!!(p.compareDocumentPosition(y)&16):!1:!1}function rv(p){p=p!=null&&p.ownerDocument!=null&&p.ownerDocument.defaultView!=null?p.ownerDocument.defaultView:window;for(var y=gr(p.document);y instanceof p.HTMLIFrameElement;){try{var _=typeof y.contentWindow.location.href=="string"}catch{_=!1}if(_)p=y.contentWindow;else break;y=gr(p.document)}return y}function ab(p){var y=p&&p.nodeName&&p.nodeName.toLowerCase();return y&&(y==="input"&&(p.type==="text"||p.type==="search"||p.type==="tel"||p.type==="url"||p.type==="password")||y==="textarea"||p.contentEditable==="true")}var KA=$o&&"documentMode"in document&&11>=document.documentMode,ib=null,YA=null,av=null,XA=!1;function eU(p,y,_){var M=_.window===_?_.document:_.nodeType===9?_:_.ownerDocument;XA||ib==null||ib!==gr(M)||(M=ib,"selectionStart"in M&&ab(M)?M={start:M.selectionStart,end:M.selectionEnd}:(M=(M.ownerDocument&&M.ownerDocument.defaultView||window).getSelection(),M={anchorNode:M.anchorNode,anchorOffset:M.anchorOffset,focusNode:M.focusNode,focusOffset:M.focusOffset}),av&&kn(av,M)||(av=M,M=Y_(YA,"onSelect"),0>=ke,G-=ke,hd=1<<32-Ue(y)+G|_<Xn?(xr=Nn,Nn=null):xr=Nn.sibling;var jr=Mt(xt,Nn,Tt[Xn],Ht);if(jr===null){Nn===null&&(Nn=xr);break}p&&Nn&&jr.alternate===null&&y(xt,Nn),ft=J(jr,ft,Xn),Ar===null?Mn=jr:Ar.sibling=jr,Ar=jr,Nn=xr}if(Xn===Tt.length)return _(xt,Nn),wr&&yu(xt,Xn),Mn;if(Nn===null){for(;XnXn?(xr=Nn,Nn=null):xr=Nn.sibling;var _h=Mt(xt,Nn,jr.value,Ht);if(_h===null){Nn===null&&(Nn=xr);break}p&&Nn&&_h.alternate===null&&y(xt,Nn),ft=J(_h,ft,Xn),Ar===null?Mn=_h:Ar.sibling=_h,Ar=_h,Nn=xr}if(jr.done)return _(xt,Nn),wr&&yu(xt,Xn),Mn;if(Nn===null){for(;!jr.done;Xn++,jr=Tt.next())jr=Vt(xt,jr.value,Ht),jr!==null&&(ft=J(jr,ft,Xn),Ar===null?Mn=jr:Ar.sibling=jr,Ar=jr);return wr&&yu(xt,Xn),Mn}for(Nn=M(Nn);!jr.done;Xn++,jr=Tt.next())jr=Lt(Nn,xt,Xn,jr.value,Ht),jr!==null&&(p&&jr.alternate!==null&&Nn.delete(jr.key===null?Xn:jr.key),ft=J(jr,ft,Xn),Ar===null?Mn=jr:Ar.sibling=jr,Ar=jr);return p&&Nn.forEach(function(vue){return y(xt,vue)}),wr&&yu(xt,Xn),Mn}function Xr(xt,ft,Tt,Ht){if(typeof Tt=="object"&&Tt!==null&&Tt.type===v&&Tt.key===null&&(Tt=Tt.props.children),typeof Tt=="object"&&Tt!==null){switch(Tt.$$typeof){case f:e:{for(var Mn=Tt.key;ft!==null;){if(ft.key===Mn){if(Mn=Tt.type,Mn===v){if(ft.tag===7){_(xt,ft.sibling),Ht=G(ft,Tt.props.children),Ht.return=xt,xt=Ht;break e}}else if(ft.elementType===Mn||typeof Mn=="object"&&Mn!==null&&Mn.$$typeof===E&&pf(Mn)===ft.type){_(xt,ft.sibling),Ht=G(ft,Tt.props),dv(Ht,Tt),Ht.return=xt,xt=Ht;break e}_(xt,ft);break}else y(xt,ft);ft=ft.sibling}Tt.type===v?(Ht=cf(Tt.props.children,xt.mode,Ht,Tt.key),Ht.return=xt,xt=Ht):(Ht=m_(Tt.type,Tt.key,Tt.props,null,xt.mode,Ht),dv(Ht,Tt),Ht.return=xt,xt=Ht)}return ke(xt);case x:e:{for(Mn=Tt.key;ft!==null;){if(ft.key===Mn)if(ft.tag===4&&ft.stateNode.containerInfo===Tt.containerInfo&&ft.stateNode.implementation===Tt.implementation){_(xt,ft.sibling),Ht=G(ft,Tt.children||[]),Ht.return=xt,xt=Ht;break e}else{_(xt,ft);break}else y(xt,ft);ft=ft.sibling}Ht=rj(Tt,xt.mode,Ht),Ht.return=xt,xt=Ht}return ke(xt);case E:return Tt=pf(Tt),Xr(xt,ft,Tt,Ht)}if(ee(Tt))return bn(xt,ft,Tt,Ht);if(O(Tt)){if(Mn=O(Tt),typeof Mn!="function")throw Error(a(150));return Tt=Mn.call(Tt),On(xt,ft,Tt,Ht)}if(typeof Tt.then=="function")return Xr(xt,ft,y_(Tt),Ht);if(Tt.$$typeof===N)return Xr(xt,ft,f_(xt,Tt),Ht);v_(xt,Tt)}return typeof Tt=="string"&&Tt!==""||typeof Tt=="number"||typeof Tt=="bigint"?(Tt=""+Tt,ft!==null&&ft.tag===6?(_(xt,ft.sibling),Ht=G(ft,Tt),Ht.return=xt,xt=Ht):(_(xt,ft),Ht=nj(Tt,xt.mode,Ht),Ht.return=xt,xt=Ht),ke(xt)):_(xt,ft)}return function(xt,ft,Tt,Ht){try{cv=0;var Mn=Xr(xt,ft,Tt,Ht);return gb=null,Mn}catch(Nn){if(Nn===fb||Nn===b_)throw Nn;var Ar=Ko(29,Nn,null,xt.mode);return Ar.lanes=Ht,Ar.return=xt,Ar}}}var gf=_U(!0),kU=_U(!1),sh=!1;function fj(p){p.updateQueue={baseState:p.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function gj(p,y){p=p.updateQueue,y.updateQueue===p&&(y.updateQueue={baseState:p.baseState,firstBaseUpdate:p.firstBaseUpdate,lastBaseUpdate:p.lastBaseUpdate,shared:p.shared,callbacks:null})}function oh(p){return{lane:p,tag:0,payload:null,callback:null,next:null}}function lh(p,y,_){var M=p.updateQueue;if(M===null)return null;if(M=M.shared,(Fr&2)!==0){var G=M.pending;return G===null?y.next=y:(y.next=G.next,G.next=y),M.pending=y,y=u_(p),oU(p,null,_),y}return d_(p,M,y,_),u_(p)}function uv(p,y,_){if(y=y.updateQueue,y!==null&&(y=y.shared,(_&4194048)!==0)){var M=y.lanes;M&=p.pendingLanes,_|=M,y.lanes=_,tt(p,_)}}function bj(p,y){var _=p.updateQueue,M=p.alternate;if(M!==null&&(M=M.updateQueue,_===M)){var G=null,J=null;if(_=_.firstBaseUpdate,_!==null){do{var ke={lane:_.lane,tag:_.tag,payload:_.payload,callback:null,next:null};J===null?G=J=ke:J=J.next=ke,_=_.next}while(_!==null);J===null?G=J=y:J=J.next=y}else G=J=y;_={baseState:M.baseState,firstBaseUpdate:G,lastBaseUpdate:J,shared:M.shared,callbacks:M.callbacks},p.updateQueue=_;return}p=_.lastBaseUpdate,p===null?_.firstBaseUpdate=y:p.next=y,_.lastBaseUpdate=y}var xj=!1;function mv(){if(xj){var p=pb;if(p!==null)throw p}}function hv(p,y,_,M){xj=!1;var G=p.updateQueue;sh=!1;var J=G.firstBaseUpdate,ke=G.lastBaseUpdate,He=G.shared.pending;if(He!==null){G.shared.pending=null;var dt=He,At=dt.next;dt.next=null,ke===null?J=At:ke.next=At,ke=dt;var Ut=p.alternate;Ut!==null&&(Ut=Ut.updateQueue,He=Ut.lastBaseUpdate,He!==ke&&(He===null?Ut.firstBaseUpdate=At:He.next=At,Ut.lastBaseUpdate=dt))}if(J!==null){var Vt=G.baseState;ke=0,Ut=At=dt=null,He=J;do{var Mt=He.lane&-536870913,Lt=Mt!==He.lane;if(Lt?(br&Mt)===Mt:(M&Mt)===Mt){Mt!==0&&Mt===hb&&(xj=!0),Ut!==null&&(Ut=Ut.next={lane:0,tag:He.tag,payload:He.payload,callback:null,next:null});e:{var bn=p,On=He;Mt=y;var Xr=_;switch(On.tag){case 1:if(bn=On.payload,typeof bn=="function"){Vt=bn.call(Xr,Vt,Mt);break e}Vt=bn;break e;case 3:bn.flags=bn.flags&-65537|128;case 0:if(bn=On.payload,Mt=typeof bn=="function"?bn.call(Xr,Vt,Mt):bn,Mt==null)break e;Vt=m({},Vt,Mt);break e;case 2:sh=!0}}Mt=He.callback,Mt!==null&&(p.flags|=64,Lt&&(p.flags|=8192),Lt=G.callbacks,Lt===null?G.callbacks=[Mt]:Lt.push(Mt))}else Lt={lane:Mt,tag:He.tag,payload:He.payload,callback:He.callback,next:null},Ut===null?(At=Ut=Lt,dt=Vt):Ut=Ut.next=Lt,ke|=Mt;if(He=He.next,He===null){if(He=G.shared.pending,He===null)break;Lt=He,He=Lt.next,Lt.next=null,G.lastBaseUpdate=Lt,G.shared.pending=null}}while(!0);Ut===null&&(dt=Vt),G.baseState=dt,G.firstBaseUpdate=At,G.lastBaseUpdate=Ut,J===null&&(G.shared.lanes=0),hh|=ke,p.lanes=ke,p.memoizedState=Vt}}function NU(p,y){if(typeof p!="function")throw Error(a(191,p));p.call(y)}function CU(p,y){var _=p.callbacks;if(_!==null)for(p.callbacks=null,p=0;p<_.length;p++)NU(_[p],y)}var bb=V(null),w_=V(0);function PU(p,y){p=ju,me(w_,p),me(bb,y),ju=p|y.baseLanes}function yj(){me(w_,ju),me(bb,bb.current)}function vj(){ju=w_.current,ie(bb),ie(w_)}var Yo=V(null),El=null;function ch(p){var y=p.alternate;me(ti,ti.current&1),me(Yo,p),El===null&&(y===null||bb.current!==null||y.memoizedState!==null)&&(El=p)}function wj(p){me(ti,ti.current),me(Yo,p),El===null&&(El=p)}function TU(p){p.tag===22?(me(ti,ti.current),me(Yo,p),El===null&&(El=p)):dh()}function dh(){me(ti,ti.current),me(Yo,Yo.current)}function Xo(p){ie(Yo),El===p&&(El=null),ie(ti)}var ti=V(0);function S_(p){for(var y=p;y!==null;){if(y.tag===13){var _=y.memoizedState;if(_!==null&&(_=_.dehydrated,_===null||PM(_)||TM(_)))return y}else if(y.tag===19&&(y.memoizedProps.revealOrder==="forwards"||y.memoizedProps.revealOrder==="backwards"||y.memoizedProps.revealOrder==="unstable_legacy-backwards"||y.memoizedProps.revealOrder==="together")){if((y.flags&128)!==0)return y}else if(y.child!==null){y.child.return=y,y=y.child;continue}if(y===p)break;for(;y.sibling===null;){if(y.return===null||y.return===p)return null;y=y.return}y.sibling.return=y.return,y=y.sibling}return null}var Su=0,Kn=null,Kr=null,li=null,__=!1,xb=!1,bf=!1,k_=0,pv=0,yb=null,dde=0;function qa(){throw Error(a(321))}function Sj(p,y){if(y===null)return!1;for(var _=0;_J?J:8;var ke=ne.T,He={};ne.T=He,Ij(p,!1,y,_);try{var dt=G(),At=ne.S;if(At!==null&&At(He,dt),dt!==null&&typeof dt=="object"&&typeof dt.then=="function"){var Ut=cde(dt,M);gv(p,y,Ut,Jo(p))}else gv(p,y,M,Jo(p))}catch(Vt){gv(p,y,{then:function(){},status:"rejected",reason:Vt},Jo())}finally{Z.p=J,ke!==null&&He.types!==null&&(ke.types=He.types),ne.T=ke}}function fde(){}function Rj(p,y,_,M){if(p.tag!==5)throw Error(a(476));var G=aB(p).queue;rB(p,G,y,ce,_===null?fde:function(){return iB(p),_(M)})}function aB(p){var y=p.memoizedState;if(y!==null)return y;y={memoizedState:ce,baseState:ce,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:_u,lastRenderedState:ce},next:null};var _={};return y.next={memoizedState:_,baseState:_,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:_u,lastRenderedState:_},next:null},p.memoizedState=y,p=p.alternate,p!==null&&(p.memoizedState=y),y}function iB(p){var y=aB(p);y.next===null&&(y=p.alternate.memoizedState),gv(p,y.next.queue,{},Jo())}function Lj(){return ms(Dv)}function sB(){return ni().memoizedState}function oB(){return ni().memoizedState}function gde(p){for(var y=p.return;y!==null;){switch(y.tag){case 24:case 3:var _=Jo();p=oh(_);var M=lh(y,p,_);M!==null&&(Po(M,y,_),uv(M,y,_)),y={cache:uj()},p.payload=y;return}y=y.return}}function bde(p,y,_){var M=Jo();_={lane:M,revertLane:0,gesture:null,action:_,hasEagerState:!1,eagerState:null,next:null},j_(p)?cB(y,_):(_=ej(p,y,_,M),_!==null&&(Po(_,p,M),dB(_,y,M)))}function lB(p,y,_){var M=Jo();gv(p,y,_,M)}function gv(p,y,_,M){var G={lane:M,revertLane:0,gesture:null,action:_,hasEagerState:!1,eagerState:null,next:null};if(j_(p))cB(y,G);else{var J=p.alternate;if(p.lanes===0&&(J===null||J.lanes===0)&&(J=y.lastRenderedReducer,J!==null))try{var ke=y.lastRenderedState,He=J(ke,_);if(G.hasEagerState=!0,G.eagerState=He,_i(He,ke))return d_(p,y,G,0),oa===null&&c_(),!1}catch{}if(_=ej(p,y,G,M),_!==null)return Po(_,p,M),dB(_,y,M),!0}return!1}function Ij(p,y,_,M){if(M={lane:2,revertLane:fM(),gesture:null,action:M,hasEagerState:!1,eagerState:null,next:null},j_(p)){if(y)throw Error(a(479))}else y=ej(p,_,M,2),y!==null&&Po(y,p,2)}function j_(p){var y=p.alternate;return p===Kn||y!==null&&y===Kn}function cB(p,y){xb=__=!0;var _=p.pending;_===null?y.next=y:(y.next=_.next,_.next=y),p.pending=y}function dB(p,y,_){if((_&4194048)!==0){var M=y.lanes;M&=p.pendingLanes,_|=M,y.lanes=_,tt(p,_)}}var bv={readContext:ms,use:C_,useCallback:qa,useContext:qa,useEffect:qa,useImperativeHandle:qa,useLayoutEffect:qa,useInsertionEffect:qa,useMemo:qa,useReducer:qa,useRef:qa,useState:qa,useDebugValue:qa,useDeferredValue:qa,useTransition:qa,useSyncExternalStore:qa,useId:qa,useHostTransitionStatus:qa,useFormState:qa,useActionState:qa,useOptimistic:qa,useMemoCache:qa,useCacheRefresh:qa};bv.useEffectEvent=qa;var uB={readContext:ms,use:C_,useCallback:function(p,y){return Zs().memoizedState=[p,y===void 0?null:y],p},useContext:ms,useEffect:KU,useImperativeHandle:function(p,y,_){_=_!=null?_.concat([p]):null,T_(4194308,4,ZU.bind(null,y,p),_)},useLayoutEffect:function(p,y){return T_(4194308,4,p,y)},useInsertionEffect:function(p,y){T_(4,2,p,y)},useMemo:function(p,y){var _=Zs();y=y===void 0?null:y;var M=p();if(bf){Te(!0);try{p()}finally{Te(!1)}}return _.memoizedState=[M,y],M},useReducer:function(p,y,_){var M=Zs();if(_!==void 0){var G=_(y);if(bf){Te(!0);try{_(y)}finally{Te(!1)}}}else G=y;return M.memoizedState=M.baseState=G,p={pending:null,lanes:0,dispatch:null,lastRenderedReducer:p,lastRenderedState:G},M.queue=p,p=p.dispatch=bde.bind(null,Kn,p),[M.memoizedState,p]},useRef:function(p){var y=Zs();return p={current:p},y.memoizedState=p},useState:function(p){p=jj(p);var y=p.queue,_=lB.bind(null,Kn,y);return y.dispatch=_,[p.memoizedState,_]},useDebugValue:Dj,useDeferredValue:function(p,y){var _=Zs();return Fj(_,p,y)},useTransition:function(){var p=jj(!1);return p=rB.bind(null,Kn,p.queue,!0,!1),Zs().memoizedState=p,[!1,p]},useSyncExternalStore:function(p,y,_){var M=Kn,G=Zs();if(wr){if(_===void 0)throw Error(a(407));_=_()}else{if(_=y(),oa===null)throw Error(a(349));(br&127)!==0||EU(M,y,_)}G.memoizedState=_;var J={value:_,getSnapshot:y};return G.queue=J,KU(FU.bind(null,M,J,p),[p]),M.flags|=2048,vb(9,{destroy:void 0},DU.bind(null,M,J,_,y),null),_},useId:function(){var p=Zs(),y=oa.identifierPrefix;if(wr){var _=pd,M=hd;_=(M&~(1<<32-Ue(M)-1)).toString(32)+_,y="_"+y+"R_"+_,_=k_++,0<_&&(y+="H"+_.toString(32)),y+="_"}else _=dde++,y="_"+y+"r_"+_.toString(32)+"_";return p.memoizedState=y},useHostTransitionStatus:Lj,useFormState:qU,useActionState:qU,useOptimistic:function(p){var y=Zs();y.memoizedState=y.baseState=p;var _={pending:null,lanes:0,dispatch:null,lastRenderedReducer:null,lastRenderedState:null};return y.queue=_,y=Ij.bind(null,Kn,!0,_),_.dispatch=y,[p,y]},useMemoCache:Pj,useCacheRefresh:function(){return Zs().memoizedState=gde.bind(null,Kn)},useEffectEvent:function(p){var y=Zs(),_={impl:p};return y.memoizedState=_,function(){if((Fr&2)!==0)throw Error(a(440));return _.impl.apply(void 0,arguments)}}},Oj={readContext:ms,use:C_,useCallback:eB,useContext:ms,useEffect:Ej,useImperativeHandle:JU,useInsertionEffect:XU,useLayoutEffect:QU,useMemo:tB,useReducer:P_,useRef:WU,useState:function(){return P_(_u)},useDebugValue:Dj,useDeferredValue:function(p,y){var _=ni();return nB(_,Kr.memoizedState,p,y)},useTransition:function(){var p=P_(_u)[0],y=ni().memoizedState;return[typeof p=="boolean"?p:fv(p),y]},useSyncExternalStore:MU,useId:sB,useHostTransitionStatus:Lj,useFormState:$U,useActionState:$U,useOptimistic:function(p,y){var _=ni();return IU(_,Kr,p,y)},useMemoCache:Pj,useCacheRefresh:oB};Oj.useEffectEvent=YU;var mB={readContext:ms,use:C_,useCallback:eB,useContext:ms,useEffect:Ej,useImperativeHandle:JU,useInsertionEffect:XU,useLayoutEffect:QU,useMemo:tB,useReducer:Aj,useRef:WU,useState:function(){return Aj(_u)},useDebugValue:Dj,useDeferredValue:function(p,y){var _=ni();return Kr===null?Fj(_,p,y):nB(_,Kr.memoizedState,p,y)},useTransition:function(){var p=Aj(_u)[0],y=ni().memoizedState;return[typeof p=="boolean"?p:fv(p),y]},useSyncExternalStore:MU,useId:sB,useHostTransitionStatus:Lj,useFormState:GU,useActionState:GU,useOptimistic:function(p,y){var _=ni();return Kr!==null?IU(_,Kr,p,y):(_.baseState=p,[p,_.queue.dispatch])},useMemoCache:Pj,useCacheRefresh:oB};mB.useEffectEvent=YU;function zj(p,y,_,M){y=p.memoizedState,_=_(M,y),_=_==null?y:m({},y,_),p.memoizedState=_,p.lanes===0&&(p.updateQueue.baseState=_)}var Uj={enqueueSetState:function(p,y,_){p=p._reactInternals;var M=Jo(),G=oh(M);G.payload=y,_!=null&&(G.callback=_),y=lh(p,G,M),y!==null&&(Po(y,p,M),uv(y,p,M))},enqueueReplaceState:function(p,y,_){p=p._reactInternals;var M=Jo(),G=oh(M);G.tag=1,G.payload=y,_!=null&&(G.callback=_),y=lh(p,G,M),y!==null&&(Po(y,p,M),uv(y,p,M))},enqueueForceUpdate:function(p,y){p=p._reactInternals;var _=Jo(),M=oh(_);M.tag=2,y!=null&&(M.callback=y),y=lh(p,M,_),y!==null&&(Po(y,p,_),uv(y,p,_))}};function hB(p,y,_,M,G,J,ke){return p=p.stateNode,typeof p.shouldComponentUpdate=="function"?p.shouldComponentUpdate(M,J,ke):y.prototype&&y.prototype.isPureReactComponent?!kn(_,M)||!kn(G,J):!0}function pB(p,y,_,M){p=y.state,typeof y.componentWillReceiveProps=="function"&&y.componentWillReceiveProps(_,M),typeof y.UNSAFE_componentWillReceiveProps=="function"&&y.UNSAFE_componentWillReceiveProps(_,M),y.state!==p&&Uj.enqueueReplaceState(y,y.state,null)}function xf(p,y){var _=y;if("ref"in y){_={};for(var M in y)M!=="ref"&&(_[M]=y[M])}if(p=p.defaultProps){_===y&&(_=m({},_));for(var G in p)_[G]===void 0&&(_[G]=p[G])}return _}function fB(p){l_(p)}function gB(p){console.error(p)}function bB(p){l_(p)}function M_(p,y){try{var _=p.onUncaughtError;_(y.value,{componentStack:y.stack})}catch(M){setTimeout(function(){throw M})}}function xB(p,y,_){try{var M=p.onCaughtError;M(_.value,{componentStack:_.stack,errorBoundary:y.tag===1?y.stateNode:null})}catch(G){setTimeout(function(){throw G})}}function Bj(p,y,_){return _=oh(_),_.tag=3,_.payload={element:null},_.callback=function(){M_(p,y)},_}function yB(p){return p=oh(p),p.tag=3,p}function vB(p,y,_,M){var G=_.type.getDerivedStateFromError;if(typeof G=="function"){var J=M.value;p.payload=function(){return G(J)},p.callback=function(){xB(y,_,M)}}var ke=_.stateNode;ke!==null&&typeof ke.componentDidCatch=="function"&&(p.callback=function(){xB(y,_,M),typeof G!="function"&&(ph===null?ph=new Set([this]):ph.add(this));var He=M.stack;this.componentDidCatch(M.value,{componentStack:He!==null?He:""})})}function xde(p,y,_,M,G){if(_.flags|=32768,M!==null&&typeof M=="object"&&typeof M.then=="function"){if(y=_.alternate,y!==null&&mb(y,_,G,!0),_=Yo.current,_!==null){switch(_.tag){case 31:case 13:return El===null?q_():_.alternate===null&&$a===0&&($a=3),_.flags&=-257,_.flags|=65536,_.lanes=G,M===x_?_.flags|=16384:(y=_.updateQueue,y===null?_.updateQueue=new Set([M]):y.add(M),mM(p,M,G)),!1;case 22:return _.flags|=65536,M===x_?_.flags|=16384:(y=_.updateQueue,y===null?(y={transitions:null,markerInstances:null,retryQueue:new Set([M])},_.updateQueue=y):(_=y.retryQueue,_===null?y.retryQueue=new Set([M]):_.add(M)),mM(p,M,G)),!1}throw Error(a(435,_.tag))}return mM(p,M,G),q_(),!1}if(wr)return y=Yo.current,y!==null?((y.flags&65536)===0&&(y.flags|=256),y.flags|=65536,y.lanes=G,M!==sj&&(p=Error(a(422),{cause:M}),sv(Tl(p,_)))):(M!==sj&&(y=Error(a(423),{cause:M}),sv(Tl(y,_))),p=p.current.alternate,p.flags|=65536,G&=-G,p.lanes|=G,M=Tl(M,_),G=Bj(p.stateNode,M,G),bj(p,G),$a!==4&&($a=2)),!1;var J=Error(a(520),{cause:M});if(J=Tl(J,_),Nv===null?Nv=[J]:Nv.push(J),$a!==4&&($a=2),y===null)return!0;M=Tl(M,_),_=y;do{switch(_.tag){case 3:return _.flags|=65536,p=G&-G,_.lanes|=p,p=Bj(_.stateNode,M,p),bj(_,p),!1;case 1:if(y=_.type,J=_.stateNode,(_.flags&128)===0&&(typeof y.getDerivedStateFromError=="function"||J!==null&&typeof J.componentDidCatch=="function"&&(ph===null||!ph.has(J))))return _.flags|=65536,G&=-G,_.lanes|=G,G=yB(G),vB(G,p,_,M),bj(_,G),!1}_=_.return}while(_!==null);return!1}var Hj=Error(a(461)),ci=!1;function hs(p,y,_,M){y.child=p===null?kU(y,null,_,M):gf(y,p.child,_,M)}function wB(p,y,_,M,G){_=_.render;var J=y.ref;if("ref"in M){var ke={};for(var He in M)He!=="ref"&&(ke[He]=M[He])}else ke=M;return mf(y),M=_j(p,y,_,ke,J,G),He=kj(),p!==null&&!ci?(Nj(p,y,G),ku(p,y,G)):(wr&&He&&aj(y),y.flags|=1,hs(p,y,M,G),y.child)}function SB(p,y,_,M,G){if(p===null){var J=_.type;return typeof J=="function"&&!tj(J)&&J.defaultProps===void 0&&_.compare===null?(y.tag=15,y.type=J,_B(p,y,J,M,G)):(p=m_(_.type,null,M,y,y.mode,G),p.ref=y.ref,p.return=y,y.child=p)}if(J=p.child,!Xj(p,G)){var ke=J.memoizedProps;if(_=_.compare,_=_!==null?_:kn,_(ke,M)&&p.ref===y.ref)return ku(p,y,G)}return y.flags|=1,p=xu(J,M),p.ref=y.ref,p.return=y,y.child=p}function _B(p,y,_,M,G){if(p!==null){var J=p.memoizedProps;if(kn(J,M)&&p.ref===y.ref)if(ci=!1,y.pendingProps=M=J,Xj(p,G))(p.flags&131072)!==0&&(ci=!0);else return y.lanes=p.lanes,ku(p,y,G)}return qj(p,y,_,M,G)}function kB(p,y,_,M){var G=M.children,J=p!==null?p.memoizedState:null;if(p===null&&y.stateNode===null&&(y.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),M.mode==="hidden"){if((y.flags&128)!==0){if(J=J!==null?J.baseLanes|_:_,p!==null){for(M=y.child=p.child,G=0;M!==null;)G=G|M.lanes|M.childLanes,M=M.sibling;M=G&~J}else M=0,y.child=null;return NB(p,y,J,_,M)}if((_&536870912)!==0)y.memoizedState={baseLanes:0,cachePool:null},p!==null&&g_(y,J!==null?J.cachePool:null),J!==null?PU(y,J):yj(),TU(y);else return M=y.lanes=536870912,NB(p,y,J!==null?J.baseLanes|_:_,_,M)}else J!==null?(g_(y,J.cachePool),PU(y,J),dh(),y.memoizedState=null):(p!==null&&g_(y,null),yj(),dh());return hs(p,y,G,_),y.child}function xv(p,y){return p!==null&&p.tag===22||y.stateNode!==null||(y.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),y.sibling}function NB(p,y,_,M,G){var J=hj();return J=J===null?null:{parent:oi._currentValue,pool:J},y.memoizedState={baseLanes:_,cachePool:J},p!==null&&g_(y,null),yj(),TU(y),p!==null&&mb(p,y,M,!0),y.childLanes=G,null}function E_(p,y){return y=F_({mode:y.mode,children:y.children},p.mode),y.ref=p.ref,p.child=y,y.return=p,y}function CB(p,y,_){return gf(y,p.child,null,_),p=E_(y,y.pendingProps),p.flags|=2,Xo(y),y.memoizedState=null,p}function yde(p,y,_){var M=y.pendingProps,G=(y.flags&128)!==0;if(y.flags&=-129,p===null){if(wr){if(M.mode==="hidden")return p=E_(y,M),y.lanes=536870912,xv(null,p);if(wj(y),(p=xa)?(p=O8(p,Ml),p=p!==null&&p.data==="&"?p:null,p!==null&&(y.memoizedState={dehydrated:p,treeContext:nh!==null?{id:hd,overflow:pd}:null,retryLane:536870912,hydrationErrors:null},_=cU(p),_.return=y,y.child=_,us=y,xa=null)):p=null,p===null)throw ah(y);return y.lanes=536870912,null}return E_(y,M)}var J=p.memoizedState;if(J!==null){var ke=J.dehydrated;if(wj(y),G)if(y.flags&256)y.flags&=-257,y=CB(p,y,_);else if(y.memoizedState!==null)y.child=p.child,y.flags|=128,y=null;else throw Error(a(558));else if(ci||mb(p,y,_,!1),G=(_&p.childLanes)!==0,ci||G){if(M=oa,M!==null&&(ke=qe(M,_),ke!==0&&ke!==J.retryLane))throw J.retryLane=ke,lf(p,ke),Po(M,p,ke),Hj;q_(),y=CB(p,y,_)}else p=J.treeContext,xa=Dl(ke.nextSibling),us=y,wr=!0,rh=null,Ml=!1,p!==null&&mU(y,p),y=E_(y,M),y.flags|=4096;return y}return p=xu(p.child,{mode:M.mode,children:M.children}),p.ref=y.ref,y.child=p,p.return=y,p}function D_(p,y){var _=y.ref;if(_===null)p!==null&&p.ref!==null&&(y.flags|=4194816);else{if(typeof _!="function"&&typeof _!="object")throw Error(a(284));(p===null||p.ref!==_)&&(y.flags|=4194816)}}function qj(p,y,_,M,G){return mf(y),_=_j(p,y,_,M,void 0,G),M=kj(),p!==null&&!ci?(Nj(p,y,G),ku(p,y,G)):(wr&&M&&aj(y),y.flags|=1,hs(p,y,_,G),y.child)}function PB(p,y,_,M,G,J){return mf(y),y.updateQueue=null,_=jU(y,M,_,G),AU(p),M=kj(),p!==null&&!ci?(Nj(p,y,J),ku(p,y,J)):(wr&&M&&aj(y),y.flags|=1,hs(p,y,_,J),y.child)}function TB(p,y,_,M,G){if(mf(y),y.stateNode===null){var J=lb,ke=_.contextType;typeof ke=="object"&&ke!==null&&(J=ms(ke)),J=new _(M,J),y.memoizedState=J.state!==null&&J.state!==void 0?J.state:null,J.updater=Uj,y.stateNode=J,J._reactInternals=y,J=y.stateNode,J.props=M,J.state=y.memoizedState,J.refs={},fj(y),ke=_.contextType,J.context=typeof ke=="object"&&ke!==null?ms(ke):lb,J.state=y.memoizedState,ke=_.getDerivedStateFromProps,typeof ke=="function"&&(zj(y,_,ke,M),J.state=y.memoizedState),typeof _.getDerivedStateFromProps=="function"||typeof J.getSnapshotBeforeUpdate=="function"||typeof J.UNSAFE_componentWillMount!="function"&&typeof J.componentWillMount!="function"||(ke=J.state,typeof J.componentWillMount=="function"&&J.componentWillMount(),typeof J.UNSAFE_componentWillMount=="function"&&J.UNSAFE_componentWillMount(),ke!==J.state&&Uj.enqueueReplaceState(J,J.state,null),hv(y,M,J,G),mv(),J.state=y.memoizedState),typeof J.componentDidMount=="function"&&(y.flags|=4194308),M=!0}else if(p===null){J=y.stateNode;var He=y.memoizedProps,dt=xf(_,He);J.props=dt;var At=J.context,Ut=_.contextType;ke=lb,typeof Ut=="object"&&Ut!==null&&(ke=ms(Ut));var Vt=_.getDerivedStateFromProps;Ut=typeof Vt=="function"||typeof J.getSnapshotBeforeUpdate=="function",He=y.pendingProps!==He,Ut||typeof J.UNSAFE_componentWillReceiveProps!="function"&&typeof J.componentWillReceiveProps!="function"||(He||At!==ke)&&pB(y,J,M,ke),sh=!1;var Mt=y.memoizedState;J.state=Mt,hv(y,M,J,G),mv(),At=y.memoizedState,He||Mt!==At||sh?(typeof Vt=="function"&&(zj(y,_,Vt,M),At=y.memoizedState),(dt=sh||hB(y,_,dt,M,Mt,At,ke))?(Ut||typeof J.UNSAFE_componentWillMount!="function"&&typeof J.componentWillMount!="function"||(typeof J.componentWillMount=="function"&&J.componentWillMount(),typeof J.UNSAFE_componentWillMount=="function"&&J.UNSAFE_componentWillMount()),typeof J.componentDidMount=="function"&&(y.flags|=4194308)):(typeof J.componentDidMount=="function"&&(y.flags|=4194308),y.memoizedProps=M,y.memoizedState=At),J.props=M,J.state=At,J.context=ke,M=dt):(typeof J.componentDidMount=="function"&&(y.flags|=4194308),M=!1)}else{J=y.stateNode,gj(p,y),ke=y.memoizedProps,Ut=xf(_,ke),J.props=Ut,Vt=y.pendingProps,Mt=J.context,At=_.contextType,dt=lb,typeof At=="object"&&At!==null&&(dt=ms(At)),He=_.getDerivedStateFromProps,(At=typeof He=="function"||typeof J.getSnapshotBeforeUpdate=="function")||typeof J.UNSAFE_componentWillReceiveProps!="function"&&typeof J.componentWillReceiveProps!="function"||(ke!==Vt||Mt!==dt)&&pB(y,J,M,dt),sh=!1,Mt=y.memoizedState,J.state=Mt,hv(y,M,J,G),mv();var Lt=y.memoizedState;ke!==Vt||Mt!==Lt||sh||p!==null&&p.dependencies!==null&&p_(p.dependencies)?(typeof He=="function"&&(zj(y,_,He,M),Lt=y.memoizedState),(Ut=sh||hB(y,_,Ut,M,Mt,Lt,dt)||p!==null&&p.dependencies!==null&&p_(p.dependencies))?(At||typeof J.UNSAFE_componentWillUpdate!="function"&&typeof J.componentWillUpdate!="function"||(typeof J.componentWillUpdate=="function"&&J.componentWillUpdate(M,Lt,dt),typeof J.UNSAFE_componentWillUpdate=="function"&&J.UNSAFE_componentWillUpdate(M,Lt,dt)),typeof J.componentDidUpdate=="function"&&(y.flags|=4),typeof J.getSnapshotBeforeUpdate=="function"&&(y.flags|=1024)):(typeof J.componentDidUpdate!="function"||ke===p.memoizedProps&&Mt===p.memoizedState||(y.flags|=4),typeof J.getSnapshotBeforeUpdate!="function"||ke===p.memoizedProps&&Mt===p.memoizedState||(y.flags|=1024),y.memoizedProps=M,y.memoizedState=Lt),J.props=M,J.state=Lt,J.context=dt,M=Ut):(typeof J.componentDidUpdate!="function"||ke===p.memoizedProps&&Mt===p.memoizedState||(y.flags|=4),typeof J.getSnapshotBeforeUpdate!="function"||ke===p.memoizedProps&&Mt===p.memoizedState||(y.flags|=1024),M=!1)}return J=M,D_(p,y),M=(y.flags&128)!==0,J||M?(J=y.stateNode,_=M&&typeof _.getDerivedStateFromError!="function"?null:J.render(),y.flags|=1,p!==null&&M?(y.child=gf(y,p.child,null,G),y.child=gf(y,null,_,G)):hs(p,y,_,G),y.memoizedState=J.state,p=y.child):p=ku(p,y,G),p}function AB(p,y,_,M){return df(),y.flags|=256,hs(p,y,_,M),y.child}var $j={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function Vj(p){return{baseLanes:p,cachePool:xU()}}function Gj(p,y,_){return p=p!==null?p.childLanes&~_:0,y&&(p|=Zo),p}function jB(p,y,_){var M=y.pendingProps,G=!1,J=(y.flags&128)!==0,ke;if((ke=J)||(ke=p!==null&&p.memoizedState===null?!1:(ti.current&2)!==0),ke&&(G=!0,y.flags&=-129),ke=(y.flags&32)!==0,y.flags&=-33,p===null){if(wr){if(G?ch(y):dh(),(p=xa)?(p=O8(p,Ml),p=p!==null&&p.data!=="&"?p:null,p!==null&&(y.memoizedState={dehydrated:p,treeContext:nh!==null?{id:hd,overflow:pd}:null,retryLane:536870912,hydrationErrors:null},_=cU(p),_.return=y,y.child=_,us=y,xa=null)):p=null,p===null)throw ah(y);return TM(p)?y.lanes=32:y.lanes=536870912,null}var He=M.children;return M=M.fallback,G?(dh(),G=y.mode,He=F_({mode:"hidden",children:He},G),M=cf(M,G,_,null),He.return=y,M.return=y,He.sibling=M,y.child=He,M=y.child,M.memoizedState=Vj(_),M.childLanes=Gj(p,ke,_),y.memoizedState=$j,xv(null,M)):(ch(y),Wj(y,He))}var dt=p.memoizedState;if(dt!==null&&(He=dt.dehydrated,He!==null)){if(J)y.flags&256?(ch(y),y.flags&=-257,y=Kj(p,y,_)):y.memoizedState!==null?(dh(),y.child=p.child,y.flags|=128,y=null):(dh(),He=M.fallback,G=y.mode,M=F_({mode:"visible",children:M.children},G),He=cf(He,G,_,null),He.flags|=2,M.return=y,He.return=y,M.sibling=He,y.child=M,gf(y,p.child,null,_),M=y.child,M.memoizedState=Vj(_),M.childLanes=Gj(p,ke,_),y.memoizedState=$j,y=xv(null,M));else if(ch(y),TM(He)){if(ke=He.nextSibling&&He.nextSibling.dataset,ke)var At=ke.dgst;ke=At,M=Error(a(419)),M.stack="",M.digest=ke,sv({value:M,source:null,stack:null}),y=Kj(p,y,_)}else if(ci||mb(p,y,_,!1),ke=(_&p.childLanes)!==0,ci||ke){if(ke=oa,ke!==null&&(M=qe(ke,_),M!==0&&M!==dt.retryLane))throw dt.retryLane=M,lf(p,M),Po(ke,p,M),Hj;PM(He)||q_(),y=Kj(p,y,_)}else PM(He)?(y.flags|=192,y.child=p.child,y=null):(p=dt.treeContext,xa=Dl(He.nextSibling),us=y,wr=!0,rh=null,Ml=!1,p!==null&&mU(y,p),y=Wj(y,M.children),y.flags|=4096);return y}return G?(dh(),He=M.fallback,G=y.mode,dt=p.child,At=dt.sibling,M=xu(dt,{mode:"hidden",children:M.children}),M.subtreeFlags=dt.subtreeFlags&65011712,At!==null?He=xu(At,He):(He=cf(He,G,_,null),He.flags|=2),He.return=y,M.return=y,M.sibling=He,y.child=M,xv(null,M),M=y.child,He=p.child.memoizedState,He===null?He=Vj(_):(G=He.cachePool,G!==null?(dt=oi._currentValue,G=G.parent!==dt?{parent:dt,pool:dt}:G):G=xU(),He={baseLanes:He.baseLanes|_,cachePool:G}),M.memoizedState=He,M.childLanes=Gj(p,ke,_),y.memoizedState=$j,xv(p.child,M)):(ch(y),_=p.child,p=_.sibling,_=xu(_,{mode:"visible",children:M.children}),_.return=y,_.sibling=null,p!==null&&(ke=y.deletions,ke===null?(y.deletions=[p],y.flags|=16):ke.push(p)),y.child=_,y.memoizedState=null,_)}function Wj(p,y){return y=F_({mode:"visible",children:y},p.mode),y.return=p,p.child=y}function F_(p,y){return p=Ko(22,p,null,y),p.lanes=0,p}function Kj(p,y,_){return gf(y,p.child,null,_),p=Wj(y,y.pendingProps.children),p.flags|=2,y.memoizedState=null,p}function MB(p,y,_){p.lanes|=y;var M=p.alternate;M!==null&&(M.lanes|=y),cj(p.return,y,_)}function Yj(p,y,_,M,G,J){var ke=p.memoizedState;ke===null?p.memoizedState={isBackwards:y,rendering:null,renderingStartTime:0,last:M,tail:_,tailMode:G,treeForkCount:J}:(ke.isBackwards=y,ke.rendering=null,ke.renderingStartTime=0,ke.last=M,ke.tail=_,ke.tailMode=G,ke.treeForkCount=J)}function EB(p,y,_){var M=y.pendingProps,G=M.revealOrder,J=M.tail;M=M.children;var ke=ti.current,He=(ke&2)!==0;if(He?(ke=ke&1|2,y.flags|=128):ke&=1,me(ti,ke),hs(p,y,M,_),M=wr?iv:0,!He&&p!==null&&(p.flags&128)!==0)e:for(p=y.child;p!==null;){if(p.tag===13)p.memoizedState!==null&&MB(p,_,y);else if(p.tag===19)MB(p,_,y);else if(p.child!==null){p.child.return=p,p=p.child;continue}if(p===y)break e;for(;p.sibling===null;){if(p.return===null||p.return===y)break e;p=p.return}p.sibling.return=p.return,p=p.sibling}switch(G){case"forwards":for(_=y.child,G=null;_!==null;)p=_.alternate,p!==null&&S_(p)===null&&(G=_),_=_.sibling;_=G,_===null?(G=y.child,y.child=null):(G=_.sibling,_.sibling=null),Yj(y,!1,G,_,J,M);break;case"backwards":case"unstable_legacy-backwards":for(_=null,G=y.child,y.child=null;G!==null;){if(p=G.alternate,p!==null&&S_(p)===null){y.child=G;break}p=G.sibling,G.sibling=_,_=G,G=p}Yj(y,!0,_,null,J,M);break;case"together":Yj(y,!1,null,null,void 0,M);break;default:y.memoizedState=null}return y.child}function ku(p,y,_){if(p!==null&&(y.dependencies=p.dependencies),hh|=y.lanes,(_&y.childLanes)===0)if(p!==null){if(mb(p,y,_,!1),(_&y.childLanes)===0)return null}else return null;if(p!==null&&y.child!==p.child)throw Error(a(153));if(y.child!==null){for(p=y.child,_=xu(p,p.pendingProps),y.child=_,_.return=y;p.sibling!==null;)p=p.sibling,_=_.sibling=xu(p,p.pendingProps),_.return=y;_.sibling=null}return y.child}function Xj(p,y){return(p.lanes&y)!==0?!0:(p=p.dependencies,!!(p!==null&&p_(p)))}function vde(p,y,_){switch(y.tag){case 3:F(y,y.stateNode.containerInfo),ih(y,oi,p.memoizedState.cache),df();break;case 27:case 5:U(y);break;case 4:F(y,y.stateNode.containerInfo);break;case 10:ih(y,y.type,y.memoizedProps.value);break;case 31:if(y.memoizedState!==null)return y.flags|=128,wj(y),null;break;case 13:var M=y.memoizedState;if(M!==null)return M.dehydrated!==null?(ch(y),y.flags|=128,null):(_&y.child.childLanes)!==0?jB(p,y,_):(ch(y),p=ku(p,y,_),p!==null?p.sibling:null);ch(y);break;case 19:var G=(p.flags&128)!==0;if(M=(_&y.childLanes)!==0,M||(mb(p,y,_,!1),M=(_&y.childLanes)!==0),G){if(M)return EB(p,y,_);y.flags|=128}if(G=y.memoizedState,G!==null&&(G.rendering=null,G.tail=null,G.lastEffect=null),me(ti,ti.current),M)break;return null;case 22:return y.lanes=0,kB(p,y,_,y.pendingProps);case 24:ih(y,oi,p.memoizedState.cache)}return ku(p,y,_)}function DB(p,y,_){if(p!==null)if(p.memoizedProps!==y.pendingProps)ci=!0;else{if(!Xj(p,_)&&(y.flags&128)===0)return ci=!1,vde(p,y,_);ci=(p.flags&131072)!==0}else ci=!1,wr&&(y.flags&1048576)!==0&&uU(y,iv,y.index);switch(y.lanes=0,y.tag){case 16:e:{var M=y.pendingProps;if(p=pf(y.elementType),y.type=p,typeof p=="function")tj(p)?(M=xf(p,M),y.tag=1,y=TB(null,y,p,M,_)):(y.tag=0,y=qj(null,y,p,M,_));else{if(p!=null){var G=p.$$typeof;if(G===P){y.tag=11,y=wB(null,y,p,M,_);break e}else if(G===A){y.tag=14,y=SB(null,y,p,M,_);break e}}throw y=L(p)||p,Error(a(306,y,""))}}return y;case 0:return qj(p,y,y.type,y.pendingProps,_);case 1:return M=y.type,G=xf(M,y.pendingProps),TB(p,y,M,G,_);case 3:e:{if(F(y,y.stateNode.containerInfo),p===null)throw Error(a(387));M=y.pendingProps;var J=y.memoizedState;G=J.element,gj(p,y),hv(y,M,null,_);var ke=y.memoizedState;if(M=ke.cache,ih(y,oi,M),M!==J.cache&&dj(y,[oi],_,!0),mv(),M=ke.element,J.isDehydrated)if(J={element:M,isDehydrated:!1,cache:ke.cache},y.updateQueue.baseState=J,y.memoizedState=J,y.flags&256){y=AB(p,y,M,_);break e}else if(M!==G){G=Tl(Error(a(424)),y),sv(G),y=AB(p,y,M,_);break e}else for(p=y.stateNode.containerInfo,p.nodeType===9?p=p.body:p=p.nodeName==="HTML"?p.ownerDocument.body:p,xa=Dl(p.firstChild),us=y,wr=!0,rh=null,Ml=!0,_=kU(y,null,M,_),y.child=_;_;)_.flags=_.flags&-3|4096,_=_.sibling;else{if(df(),M===G){y=ku(p,y,_);break e}hs(p,y,M,_)}y=y.child}return y;case 26:return D_(p,y),p===null?(_=$8(y.type,null,y.pendingProps,null))?y.memoizedState=_:wr||(_=y.type,p=y.pendingProps,M=X_(H.current).createElement(_),M[vt]=y,M[Kt]=p,ps(M,_,p),it(M),y.stateNode=M):y.memoizedState=$8(y.type,p.memoizedProps,y.pendingProps,p.memoizedState),null;case 27:return U(y),p===null&&wr&&(M=y.stateNode=B8(y.type,y.pendingProps,H.current),us=y,Ml=!0,G=xa,xh(y.type)?(AM=G,xa=Dl(M.firstChild)):xa=G),hs(p,y,y.pendingProps.children,_),D_(p,y),p===null&&(y.flags|=4194304),y.child;case 5:return p===null&&wr&&((G=M=xa)&&(M=Xde(M,y.type,y.pendingProps,Ml),M!==null?(y.stateNode=M,us=y,xa=Dl(M.firstChild),Ml=!1,G=!0):G=!1),G||ah(y)),U(y),G=y.type,J=y.pendingProps,ke=p!==null?p.memoizedProps:null,M=J.children,kM(G,J)?M=null:ke!==null&&kM(G,ke)&&(y.flags|=32),y.memoizedState!==null&&(G=_j(p,y,ude,null,null,_),Dv._currentValue=G),D_(p,y),hs(p,y,M,_),y.child;case 6:return p===null&&wr&&((p=_=xa)&&(_=Qde(_,y.pendingProps,Ml),_!==null?(y.stateNode=_,us=y,xa=null,p=!0):p=!1),p||ah(y)),null;case 13:return jB(p,y,_);case 4:return F(y,y.stateNode.containerInfo),M=y.pendingProps,p===null?y.child=gf(y,null,M,_):hs(p,y,M,_),y.child;case 11:return wB(p,y,y.type,y.pendingProps,_);case 7:return hs(p,y,y.pendingProps,_),y.child;case 8:return hs(p,y,y.pendingProps.children,_),y.child;case 12:return hs(p,y,y.pendingProps.children,_),y.child;case 10:return M=y.pendingProps,ih(y,y.type,M.value),hs(p,y,M.children,_),y.child;case 9:return G=y.type._context,M=y.pendingProps.children,mf(y),G=ms(G),M=M(G),y.flags|=1,hs(p,y,M,_),y.child;case 14:return SB(p,y,y.type,y.pendingProps,_);case 15:return _B(p,y,y.type,y.pendingProps,_);case 19:return EB(p,y,_);case 31:return yde(p,y,_);case 22:return kB(p,y,_,y.pendingProps);case 24:return mf(y),M=ms(oi),p===null?(G=hj(),G===null&&(G=oa,J=uj(),G.pooledCache=J,J.refCount++,J!==null&&(G.pooledCacheLanes|=_),G=J),y.memoizedState={parent:M,cache:G},fj(y),ih(y,oi,G)):((p.lanes&_)!==0&&(gj(p,y),hv(y,null,null,_),mv()),G=p.memoizedState,J=y.memoizedState,G.parent!==M?(G={parent:M,cache:M},y.memoizedState=G,y.lanes===0&&(y.memoizedState=y.updateQueue.baseState=G),ih(y,oi,M)):(M=J.cache,ih(y,oi,M),M!==G.cache&&dj(y,[oi],_,!0))),hs(p,y,y.pendingProps.children,_),y.child;case 29:throw y.pendingProps}throw Error(a(156,y.tag))}function Nu(p){p.flags|=4}function Qj(p,y,_,M,G){if((y=(p.mode&32)!==0)&&(y=!1),y){if(p.flags|=16777216,(G&335544128)===G)if(p.stateNode.complete)p.flags|=8192;else if(s8())p.flags|=8192;else throw ff=x_,pj}else p.flags&=-16777217}function FB(p,y){if(y.type!=="stylesheet"||(y.state.loading&4)!==0)p.flags&=-16777217;else if(p.flags|=16777216,!Y8(y))if(s8())p.flags|=8192;else throw ff=x_,pj}function R_(p,y){y!==null&&(p.flags|=4),p.flags&16384&&(y=p.tag!==22?ot():536870912,p.lanes|=y,kb|=y)}function yv(p,y){if(!wr)switch(p.tailMode){case"hidden":y=p.tail;for(var _=null;y!==null;)y.alternate!==null&&(_=y),y=y.sibling;_===null?p.tail=null:_.sibling=null;break;case"collapsed":_=p.tail;for(var M=null;_!==null;)_.alternate!==null&&(M=_),_=_.sibling;M===null?y||p.tail===null?p.tail=null:p.tail.sibling=null:M.sibling=null}}function ya(p){var y=p.alternate!==null&&p.alternate.child===p.child,_=0,M=0;if(y)for(var G=p.child;G!==null;)_|=G.lanes|G.childLanes,M|=G.subtreeFlags&65011712,M|=G.flags&65011712,G.return=p,G=G.sibling;else for(G=p.child;G!==null;)_|=G.lanes|G.childLanes,M|=G.subtreeFlags,M|=G.flags,G.return=p,G=G.sibling;return p.subtreeFlags|=M,p.childLanes=_,y}function wde(p,y,_){var M=y.pendingProps;switch(ij(y),y.tag){case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return ya(y),null;case 1:return ya(y),null;case 3:return _=y.stateNode,M=null,p!==null&&(M=p.memoizedState.cache),y.memoizedState.cache!==M&&(y.flags|=2048),wu(oi),D(),_.pendingContext&&(_.context=_.pendingContext,_.pendingContext=null),(p===null||p.child===null)&&(ub(y)?Nu(y):p===null||p.memoizedState.isDehydrated&&(y.flags&256)===0||(y.flags|=1024,oj())),ya(y),null;case 26:var G=y.type,J=y.memoizedState;return p===null?(Nu(y),J!==null?(ya(y),FB(y,J)):(ya(y),Qj(y,G,null,M,_))):J?J!==p.memoizedState?(Nu(y),ya(y),FB(y,J)):(ya(y),y.flags&=-16777217):(p=p.memoizedProps,p!==M&&Nu(y),ya(y),Qj(y,G,p,M,_)),null;case 27:if(Q(y),_=H.current,G=y.type,p!==null&&y.stateNode!=null)p.memoizedProps!==M&&Nu(y);else{if(!M){if(y.stateNode===null)throw Error(a(166));return ya(y),null}p=be.current,ub(y)?hU(y):(p=B8(G,M,_),y.stateNode=p,Nu(y))}return ya(y),null;case 5:if(Q(y),G=y.type,p!==null&&y.stateNode!=null)p.memoizedProps!==M&&Nu(y);else{if(!M){if(y.stateNode===null)throw Error(a(166));return ya(y),null}if(J=be.current,ub(y))hU(y);else{var ke=X_(H.current);switch(J){case 1:J=ke.createElementNS("http://www.w3.org/2000/svg",G);break;case 2:J=ke.createElementNS("http://www.w3.org/1998/Math/MathML",G);break;default:switch(G){case"svg":J=ke.createElementNS("http://www.w3.org/2000/svg",G);break;case"math":J=ke.createElementNS("http://www.w3.org/1998/Math/MathML",G);break;case"script":J=ke.createElement("div"),J.innerHTML=" - + + +