Selaa lähdekoodia

feat(encryption): MFA at-rest encryption auto-bootstrap with status UI (#1219) (#1231)

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.
Sn0rrii 2 viikkoa sitten
vanhempi
sitoutus
90743cfa39

+ 12 - 0
.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=

+ 3 - 0
.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/
 

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
CHANGELOG.md


+ 102 - 0
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(),
+    )

+ 73 - 13
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")

+ 95 - 1
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(

+ 3 - 8
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():

+ 30 - 0
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)

+ 135 - 0
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.
 

+ 179 - 29
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.
-
-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).
+The encryption key is resolved on first use in this priority order:
+
+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"
 
-def _get_fernet():
-    global _fernet_instance, _warn_shown
+    # 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"
 
-    if _fernet_instance is not None:
-        return _fernet_instance
 
-    key = os.environ.get("MFA_ENCRYPTION_KEY")
-    if key:
-        from cryptography.fernet import Fernet
+def get_key_source() -> _PublicSource | None:
+    return _key_source
+
+
+def is_encryption_active() -> bool:
+    return _get_fernet() is not None
+
 
-        _fernet_instance = Fernet(key.encode() if isinstance(key, str) else key)
+def _get_fernet():
+    global _fernet_instance, _warn_shown, _key_source
+
+    if _fernet_instance is not None:
         return _fernet_instance
 
-    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
+    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
+
+    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
+
+    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

+ 26 - 0
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 ``<project_root>/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"

+ 19 - 0
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

+ 36 - 0
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."""

+ 2049 - 45
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
 
-        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
+        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
 
-    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
+        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_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
+        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
+
+        import backend.app.core.encryption as enc_mod
+
+        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()

+ 52 - 0
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 == []

+ 5 - 0
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

+ 262 - 0
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> = {}): 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(<SecurityStatusCard />);
+    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(<SecurityStatusCard />);
+    await waitFor(() => {
+      expect(screen.getByTestId('encryption-error')).toBeInTheDocument();
+    });
+  });
+
+  // E1: data-testid on status div
+  it('renders encryption-status testid after data loads', async () => {
+    render(<SecurityStatusCard />);
+    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(<SecurityStatusCard />);
+    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(<SecurityStatusCard />);
+    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(<SecurityStatusCard />);
+    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(<SecurityStatusCard />);
+    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(<SecurityStatusCard />);
+    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(<SecurityStatusCard />);
+    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(<SecurityStatusCard />);
+    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(<SecurityStatusCard />);
+
+    // 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(<SecurityStatusCard />);
+
+      // 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(<SecurityStatusCard />);
+    await waitFor(() => {
+      expect(screen.getByTestId('encryption-migration-warning')).toBeInTheDocument();
+    });
+    expect(screen.getByText(/3 legacy row/i)).toBeInTheDocument();
+  });
+});

+ 17 - 0
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<AdvancedAuthStatus>('/auth/advanced-auth/status'),
   // LDAP Authentication
   getLDAPStatus: () => request<LDAPStatus>('/auth/ldap/status'),
+  getEncryptionStatus: () => request<EncryptionStatus>('/auth/encryption-status'),
   testLDAP: () =>
     request<LDAPTestResponse>('/auth/ldap/test', {
       method: 'POST',

+ 193 - 0
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<EncryptionStatus>({
+    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 (
+      <Card id="card-mfa-encryption" data-testid="encryption-status-card">
+        <CardHeader>
+          <div className="flex items-center gap-2">
+            <Shield className="text-bambu-gray" size={20} />
+            <h2 className="text-lg font-semibold">{t('settings.encryption.title')}</h2>
+          </div>
+        </CardHeader>
+        <CardContent>
+          <div className="flex items-center gap-2 text-bambu-gray" data-testid="encryption-loading">
+            <Loader2 className="animate-spin" size={16} />
+            <span>{t('common.loading')}</span>
+          </div>
+        </CardContent>
+      </Card>
+    );
+  }
+
+  if (isError || !data) {
+    return (
+      <Card id="card-mfa-encryption" data-testid="encryption-status-card">
+        <CardHeader>
+          <div className="flex items-center gap-2">
+            <Shield className="text-bambu-gray" size={20} />
+            <h2 className="text-lg font-semibold">{t('settings.encryption.title')}</h2>
+          </div>
+        </CardHeader>
+        <CardContent>
+          <div className="text-red-400" data-testid="encryption-error">{t('common.errorLoading')}</div>
+          {/* 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. */}
+          <button
+            type="button"
+            onClick={() => refetch()}
+            className="mt-2 text-sm text-blue-400 underline hover:text-blue-300"
+            data-testid="encryption-retry-button"
+          >
+            {t('common.retry')}
+          </button>
+        </CardContent>
+      </Card>
+    );
+  }
+
+  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 = <XCircle className="text-red-400" size={20} />;
+    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 = <ShieldCheck className="text-amber-400" size={20} />;
+    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 = <AlertTriangle className="text-amber-400" size={20} />;
+    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 = <ShieldCheck className="text-green-400" size={20} />;
+    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 = <ShieldOff className="text-gray-400" size={20} />;
+    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 (
+    <Card id="card-mfa-encryption" data-testid="encryption-status-card">
+      <CardHeader>
+        <div className="flex items-center gap-2">
+          {icon}
+          <h2 className="text-lg font-semibold">{t('settings.encryption.title')}</h2>
+        </div>
+      </CardHeader>
+      <CardContent>
+        <div
+          className={`p-3 border rounded-lg ${severityClasses}`}
+          data-testid="encryption-status"
+        >
+          <p className="font-medium mb-1">{statusLabel}</p>
+          <p className="text-sm">{statusBody}</p>
+        </div>
+        {showConcurrentLegacyWarning && (
+          <div
+            className="mt-2 p-3 border rounded-lg bg-amber-500/10 border-amber-500/30 text-amber-400"
+            data-testid="encryption-legacy-warning"
+          >
+            <p className="text-sm">{t('settings.encryption.legacyRowsWarning', { count: totalLegacy })}</p>
+          </div>
+        )}
+        {data.migration_error_count > 0 && (
+          <div
+            className="mt-2 p-3 border rounded-lg bg-amber-500/10 border-amber-500/30 text-amber-400"
+            data-testid="encryption-migration-warning"
+          >
+            <p className="text-sm">
+              {t('settings.encryption.migrationErrorWarning', { count: data.migration_error_count })}
+            </p>
+          </div>
+        )}
+        <div className="mt-4 grid grid-cols-2 gap-4 text-sm">
+          <div>
+            <p className="text-bambu-gray">{t('settings.encryption.encryptedRowsLabel')}</p>
+            <p className="font-medium">
+              OIDC: {data.encrypted_rows.oidc_providers} · TOTP: {data.encrypted_rows.user_totp}
+            </p>
+          </div>
+          <div>
+            <p className="text-bambu-gray">{t('settings.encryption.legacyRowsLabel')}</p>
+            <p className="font-medium">
+              OIDC: {data.legacy_plaintext_rows.oidc_providers} · TOTP: {data.legacy_plaintext_rows.user_totp}
+            </p>
+          </div>
+        </div>
+      </CardContent>
+    </Card>
+  );
+}

+ 21 - 0
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',

+ 21 - 0
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',

+ 23 - 0
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',

+ 23 - 0
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',

+ 22 - 0
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: 'バックアップの復元',

+ 23 - 0
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',

+ 22 - 0
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: '恢复备份',

+ 22 - 0
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: '恢復備份',

+ 3 - 1
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. */

+ 25 - 1
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() {
                 />
               </button>
             )}
+            {isAdmin && (
+              <button
+                onClick={() => setUsersSubTab('security')}
+                className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
+                  usersSubTab === 'security'
+                    ? 'text-bambu-green border-bambu-green'
+                    : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
+                }`}
+              >
+                <Shield className="w-4 h-4" />
+                {t('settings.tabs.security')}
+              </button>
+            )}
           </div>
 
           {/* Users Sub-tab */}
@@ -5229,6 +5243,12 @@ export function SettingsPage() {
               <OIDCProviderSettings />
             </div>
           )}
+
+          {usersSubTab === 'security' && isAdmin && (
+            <div className="max-w-3xl">
+              <SecurityStatusCard />
+            </div>
+          )}
         </div>
       )}
 
@@ -5700,6 +5720,10 @@ export function SettingsPage() {
 
       {activeTab === 'backup' && (
         <div id="card-backup">
+          <div className="mb-4 p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg flex items-start gap-2">
+            <Shield className="text-amber-400 flex-shrink-0 mt-0.5" size={16} />
+            <p className="text-sm text-amber-400">{t('backup.includesEncryptionKey')}</p>
+          </div>
           <GitHubBackupSettings />
         </div>
       )}

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/index-C_2KW3q0.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/index-Dlmc9CRg.css


+ 3 - 3
static/index.html

@@ -25,9 +25,9 @@
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
 
     <!-- Splash screens for iOS -->
-    <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BofTUuqf.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-CacBES2t.css">
+    <link rel="apple-touch-startup-image" href="./img/android-chrome-512x512.png" />
+    <script type="module" crossorigin src="./assets/index-C_2KW3q0.js"></script>
+    <link rel="stylesheet" crossorigin href="./assets/index-Dlmc9CRg.css">
   </head>
   <body>
     <div id="root"></div>

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä