Explorar el Código

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 hace 2 semanas
padre
commit
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
 # on port 8000 are different origins to the browser. Wildcards, paths, and
 # non-http(s) schemes are rejected at startup with a warning.
 # non-http(s) schemes are rejected at startup with a warning.
 # TRUSTED_FRAME_ORIGINS=http://homeassistant.local:8123
 # 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 file (should be in data dir, but protect project root too)
 .jwt_secret
 .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 keys (generated at runtime for remote updates)
 spoolbuddy/ssh/
 spoolbuddy/ssh/
 
 

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 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 fastapi.security import HTTPAuthorizationCredentials
 from jwt.exceptions import PyJWTError
 from jwt.exceptions import PyJWTError
 from sqlalchemy import delete, select
 from sqlalchemy import delete, select
+from sqlalchemy.exc import SQLAlchemyError
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 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.settings import Settings
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.schemas.auth import (
 from backend.app.schemas.auth import (
+    EncryptionRowCounts,
+    EncryptionStatusResponse,
     ForgotPasswordConfirmRequest,
     ForgotPasswordConfirmRequest,
     ForgotPasswordRequest,
     ForgotPasswordRequest,
     ForgotPasswordResponse,
     ForgotPasswordResponse,
@@ -1473,3 +1476,102 @@ async def revoke_long_lived_token(
         current_user.username,
         current_user.username,
     )
     )
     return Response(status_code=status.HTTP_204_NO_CONTENT)
     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:
     if existing and existing.is_enabled:
         await check_rate_limit(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
         await check_rate_limit(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
         supplied_code = (body.code if body else None) or ""
         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)
             await record_failed_attempt(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
             raise HTTPException(
             raise HTTPException(
                 status_code=status.HTTP_400_BAD_REQUEST,
                 status_code=status.HTTP_400_BAD_REQUEST,
                 detail="Current TOTP code required to replace an active authenticator",
                 detail="Current TOTP code required to replace an active authenticator",
             )
             )
         await clear_failed_attempts(db, current_user.username, event_type=EventType.TWO_FA_ATTEMPT)
         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
         await db.flush()  # L-3: persist last_totp_counter immediately to block replay
 
 
     secret = pyotp.random_base32()
     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."
             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)
         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")
         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:
     if not totp_record or not totp_record.is_enabled:
         raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOTP is not 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)
         _assert_totp_not_replayed(totp_obj, totp_record, body.code)
         await db.flush()  # L-3: persist last_totp_counter immediately to block replay
         await db.flush()  # L-3: persist last_totp_counter immediately to block replay
     else:
     else:
@@ -652,7 +685,12 @@ async def disable_totp(
                 code_valid = True
                 code_valid = True
 
 
     if not code_valid:
     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")
         raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid code")
 
 
     await db.execute(delete(UserTOTP).where(UserTOTP.user_id == current_user.id))
     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:
     if not totp_record or not totp_record.is_enabled:
         raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOTP is not 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)
         _assert_totp_not_replayed(totp_obj, totp_record, body.code)
         await db.flush()  # L-3: persist last_totp_counter immediately to block replay
         await db.flush()  # L-3: persist last_totp_counter immediately to block replay
     else:
     else:
@@ -692,7 +745,10 @@ async def regenerate_backup_codes(
             if pwd_context.verify(body.code, hashed) and matched_index is None:
             if pwd_context.verify(body.code, hashed) and matched_index is None:
                 matched_index = idx
                 matched_index = idx
         if matched_index is None:
         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")
             raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid TOTP or backup code")
         # Remove the used 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]
         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:
         if not totp_record or not totp_record.is_enabled:
             await record_failed_attempt(db, username)
             await record_failed_attempt(db, username)
             raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOTP is not enabled for this user")
             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):
         if not totp_obj.verify(body.code, valid_window=1):
             await record_failed_attempt(db, username)
             await record_failed_attempt(db, username)
             raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid TOTP code")
             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:
                 except PermissionError as e:
                     logger.warning("Permission denied copying %s: %s", name, 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
         # Create ZIP
         if output_path is not None:
         if output_path is not None:
             zip_file = output_path / filename
             zip_file = output_path / filename
@@ -723,6 +741,18 @@ async def restore_backup(
 
 
         try:
         try:
             with zipfile.ZipFile(io.BytesIO(content), "r") as zf:
             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)
                 zf.extractall(temp_path)
         except zipfile.BadZipFile:
         except zipfile.BadZipFile:
             raise HTTPException(400, "Invalid backup file: not a valid ZIP")
             raise HTTPException(400, "Invalid backup file: not a valid ZIP")
@@ -748,6 +778,54 @@ async def restore_backup(
             logger.info("Closing database connections...")
             logger.info("Closing database connections...")
             await close_all_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
             # 5. Replace database
             logger.info("Restoring database from backup...")
             logger.info("Restoring database from backup...")
             if is_sqlite():
             if is_sqlite():
@@ -828,7 +906,17 @@ async def restore_backup(
                         logger.warning("Could not restore %s directory: %s", name, e)
                         logger.warning("Could not restore %s directory: %s", name, e)
                         skipped_dirs.append(name)
                         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
             # tables added after the backup was created (e.g. ams_labels) exist
             # immediately, without requiring a manual restart.
             # immediately, without requiring a manual restart.
             await reinitialize_database()
             await reinitialize_database()
@@ -843,6 +931,12 @@ async def restore_backup(
                 "message": message,
                 "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:
         except Exception as e:
             logger.error("Restore failed: %s", e, exc_info=True)
             logger.error("Restore failed: %s", e, exc_info=True)
             return JSONResponse(
             return JSONResponse(

+ 3 - 8
backend/app/core/auth.py

@@ -4,7 +4,6 @@ import logging
 import os
 import os
 import secrets
 import secrets
 from datetime import datetime, timedelta, timezone
 from datetime import datetime, timedelta, timezone
-from pathlib import Path
 from typing import Annotated
 from typing import Annotated
 
 
 import jwt
 import jwt
@@ -49,13 +48,9 @@ def _get_jwt_secret() -> str:
         return env_secret
         return env_secret
 
 
     # 2. Check for secret file in data directory
     # 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"
     secret_file = data_dir / ".jwt_secret"
 
 
     if secret_file.exists():
     if secret_file.exists():

+ 30 - 0
backend/app/core/config.py

@@ -1,5 +1,6 @@
 import logging
 import logging
 import os
 import os
+import re as _re
 from pathlib import Path
 from pathlib import Path
 
 
 from pydantic_settings import BaseSettings
 from pydantic_settings import BaseSettings
@@ -91,10 +92,39 @@ class Settings(BaseSettings):
     class Config:
     class Config:
         env_file = ".env"
         env_file = ".env"
         env_file_encoding = "utf-8"
         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()
 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
 # Ensure directories exist
 settings.archive_dir.mkdir(parents=True, exist_ok=True)
 settings.archive_dir.mkdir(parents=True, exist_ok=True)
 settings.plate_calibration_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)
         # Run migrations for new columns (SQLite doesn't auto-add columns)
         await run_migrations(conn)
         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
     # Seed default notification templates
     await seed_notification_templates()
     await seed_notification_templates()
 
 
@@ -229,6 +236,134 @@ async def init_db():
     await seed_color_catalog()
     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):
 async def _safe_execute(conn, sql):
     """Execute a DDL migration statement, silently ignoring idempotency errors.
     """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).
 """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
 from __future__ import annotations
 
 
+import base64
+import binascii
 import logging
 import logging
 import os
 import os
+from typing import Literal
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 _FERNET_PREFIX = "fernet:"
 _FERNET_PREFIX = "fernet:"
 _fernet_instance = None
 _fernet_instance = None
 _warn_shown = False
 _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
         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:
 def mfa_encrypt(plaintext: str) -> str:
     """Encrypt a secret value. Returns the ciphertext with a ``fernet:`` prefix,
     """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()
     f = _get_fernet()
     if f is None:
     if f is None:
         return plaintext
         return plaintext
@@ -60,12 +209,13 @@ def mfa_decrypt(value: str) -> str:
     Raises ``RuntimeError`` if the prefix is present but no key is configured.
     Raises ``RuntimeError`` if the prefix is present but no key is configured.
     """
     """
     if not value.startswith(_FERNET_PREFIX):
     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
         # 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:
         if _get_fernet() is not None:
             logger.warning(
             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 "
                 "'fernet:' prefix — returning legacy plaintext. Consider re-enrolling "
                 "this secret to store it encrypted."
                 "this secret to store it encrypted."
             )
             )
@@ -80,9 +230,9 @@ def mfa_decrypt(value: str) -> str:
 
 
     try:
     try:
         return f.decrypt(value[len(_FERNET_PREFIX) :].encode()).decode()
         return f.decrypt(value[len(_FERNET_PREFIX) :].encode()).decode()
-    except InvalidToken:
+    except InvalidToken as exc:
         raise RuntimeError(
         raise RuntimeError(
             "MFA secret was encrypted under a different MFA_ENCRYPTION_KEY. "
             "MFA secret was encrypted under a different MFA_ENCRYPTION_KEY. "
             "Key rotation is not currently supported — restore the previous key "
             "Key rotation is not currently supported — restore the previous key "
             "or have users re-enroll."
             "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_name: str
     provider_email: str | None = None
     provider_email: str | None = None
     created_at: str
     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:"
 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")
 @pytest.fixture(scope="session")
 def event_loop():
 def event_loop():
     """Create an instance of the default event loop for each test session."""
     """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:
 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
         from cryptography.fernet import Fernet
 
 
+        import backend.app.core.encryption as enc_mod
+
         test_key = Fernet.generate_key().decode()
         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
         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
         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
         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, (
         assert status_codes[-1] == 429, (
             f"Expected 429 after {MAX_LOGIN_ATTEMPTS} username-spray failures, got: {status_codes}"
             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
       # for the sidecars lives in the orca-slicer-api fork
       # (https://github.com/maziggy/orca-slicer-api).
       # (https://github.com/maziggy/orca-slicer-api).
       #- SLICER_API_URL=http://localhost:3003
       #- 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
     restart: unless-stopped
 
 
   # Optional: External PostgreSQL database
   # 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;
   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 {
 export interface LDAPTestResponse {
   success: boolean;
   success: boolean;
   message: string;
   message: string;
@@ -2871,6 +2887,7 @@ export const api = {
   getAdvancedAuthStatus: () => request<AdvancedAuthStatus>('/auth/advanced-auth/status'),
   getAdvancedAuthStatus: () => request<AdvancedAuthStatus>('/auth/advanced-auth/status'),
   // LDAP Authentication
   // LDAP Authentication
   getLDAPStatus: () => request<LDAPStatus>('/auth/ldap/status'),
   getLDAPStatus: () => request<LDAPStatus>('/auth/ldap/status'),
+  getEncryptionStatus: () => request<EncryptionStatus>('/auth/encryption-status'),
   testLDAP: () =>
   testLDAP: () =>
     request<LDAPTestResponse>('/auth/ldap/test', {
     request<LDAPTestResponse>('/auth/ldap/test', {
       method: 'POST',
       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',
     confirm: 'Bestätigen',
     loading: 'Lädt...',
     loading: 'Lädt...',
     error: 'Fehler',
     error: 'Fehler',
+    errorLoading: 'Fehler beim Laden',
+    retry: 'Erneut versuchen',
     success: 'Erfolg',
     success: 'Erfolg',
     warning: 'Warnung',
     warning: 'Warnung',
     enabled: 'Aktiviert',
     enabled: 'Aktiviert',
@@ -1379,6 +1381,7 @@ export default {
       ldap: 'LDAP',
       ldap: 'LDAP',
       twoFa: 'Zwei-Faktor-Auth',
       twoFa: 'Zwei-Faktor-Auth',
       oidc: 'SSO / OIDC',
       oidc: 'SSO / OIDC',
+      security: 'Sicherheit',
     },
     },
     spoolbuddy: {
     spoolbuddy: {
       infoTitle: 'SpoolBuddy-Geräte',
       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)
   // Notifications (for push notifications)
@@ -3698,6 +3718,7 @@ export default {
 
 
   // Backup
   // Backup
   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',
     title: 'Sichern & Wiederherstellen',
     createBackup: 'Sicherung erstellen',
     createBackup: 'Sicherung erstellen',
     restoreBackup: 'Sicherung wiederherstellen',
     restoreBackup: 'Sicherung wiederherstellen',

+ 21 - 0
frontend/src/i18n/locales/en.ts

@@ -40,6 +40,8 @@ export default {
     confirm: 'Confirm',
     confirm: 'Confirm',
     loading: 'Loading...',
     loading: 'Loading...',
     error: 'Error',
     error: 'Error',
+    errorLoading: 'Error loading data',
+    retry: 'Retry',
     success: 'Success',
     success: 'Success',
     warning: 'Warning',
     warning: 'Warning',
     enabled: 'Enabled',
     enabled: 'Enabled',
@@ -1380,6 +1382,7 @@ export default {
       ldap: 'LDAP',
       ldap: 'LDAP',
       twoFa: 'Two-Factor Auth',
       twoFa: 'Two-Factor Auth',
       oidc: 'SSO / OIDC',
       oidc: 'SSO / OIDC',
+      security: 'Security',
     },
     },
     spoolbuddy: {
     spoolbuddy: {
       infoTitle: 'SpoolBuddy devices',
       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)
   // Notifications (for push notifications)
@@ -3706,6 +3726,7 @@ export default {
 
 
   // Backup
   // Backup
   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',
     title: 'Backup & Restore',
     createBackup: 'Create Backup',
     createBackup: 'Create Backup',
     restoreBackup: 'Restore Backup',
     restoreBackup: 'Restore Backup',

+ 23 - 0
frontend/src/i18n/locales/fr.ts

@@ -40,6 +40,8 @@ export default {
     confirm: 'Confirmer',
     confirm: 'Confirmer',
     loading: 'Chargement...',
     loading: 'Chargement...',
     error: 'Erreur',
     error: 'Erreur',
+    errorLoading: 'Erreur de chargement',
+    retry: 'Réessayer',
     success: 'Succès',
     success: 'Succès',
     warning: 'Avertissement',
     warning: 'Avertissement',
     enabled: 'Activé',
     enabled: 'Activé',
@@ -1378,6 +1380,7 @@ export default {
       ldap: 'LDAP',
       ldap: 'LDAP',
       twoFa: 'Authentification 2FA',
       twoFa: 'Authentification 2FA',
       oidc: 'SSO / OIDC',
       oidc: 'SSO / OIDC',
+      security: 'Security',
       spoolbuddy: 'SpoolBuddy',
       spoolbuddy: 'SpoolBuddy',
     },
     },
     ldap: {
     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: {
     spoolbuddy: {
       infoTitle: 'Périphériques 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.',
       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
   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',
     title: 'Sauvegarde & Restauration',
     createBackup: 'Créer Sauvegarde',
     createBackup: 'Créer Sauvegarde',
     restoreBackup: 'Restaurer Sauvegarde',
     restoreBackup: 'Restaurer Sauvegarde',

+ 23 - 0
frontend/src/i18n/locales/it.ts

@@ -40,6 +40,8 @@ export default {
     confirm: 'Conferma',
     confirm: 'Conferma',
     loading: 'Caricamento...',
     loading: 'Caricamento...',
     error: 'Errore',
     error: 'Errore',
+    errorLoading: 'Errore di caricamento',
+    retry: 'Riprova',
     success: 'Successo',
     success: 'Successo',
     warning: 'Avviso',
     warning: 'Avviso',
     enabled: 'Abilitato',
     enabled: 'Abilitato',
@@ -1378,6 +1380,7 @@ export default {
       ldap: 'LDAP',
       ldap: 'LDAP',
       twoFa: 'Autenticazione 2FA',
       twoFa: 'Autenticazione 2FA',
       oidc: 'SSO / OIDC',
       oidc: 'SSO / OIDC',
+      security: 'Security',
       spoolbuddy: 'SpoolBuddy',
       spoolbuddy: 'SpoolBuddy',
     },
     },
     ldap: {
     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: {
     spoolbuddy: {
       infoTitle: 'Dispositivi 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.',
       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
   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',
     title: 'Backup e ripristino',
     createBackup: 'Crea backup',
     createBackup: 'Crea backup',
     restoreBackup: 'Ripristina backup',
     restoreBackup: 'Ripristina backup',

+ 22 - 0
frontend/src/i18n/locales/ja.ts

@@ -40,6 +40,8 @@ export default {
     confirm: '確認',
     confirm: '確認',
     loading: '読み込み中...',
     loading: '読み込み中...',
     error: 'エラー',
     error: 'エラー',
+    errorLoading: 'データの読み込みエラー',
+    retry: '再試行',
     success: '成功',
     success: '成功',
     warning: '警告',
     warning: '警告',
     enabled: '有効',
     enabled: '有効',
@@ -1378,6 +1380,7 @@ export default {
       ldap: 'LDAP',
       ldap: 'LDAP',
       twoFa: '二段階認証',
       twoFa: '二段階認証',
       oidc: 'SSO / OIDC',
       oidc: 'SSO / OIDC',
+      security: 'Security',
     },
     },
     spoolbuddy: {
     spoolbuddy: {
       infoTitle: '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)
   // Notifications (for push notifications)
@@ -3697,6 +3718,7 @@ export default {
 
 
   // Backup
   // Backup
   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: 'バックアップと復元',
     title: 'バックアップと復元',
     createBackup: 'バックアップを作成',
     createBackup: 'バックアップを作成',
     restoreBackup: 'バックアップの復元',
     restoreBackup: 'バックアップの復元',

+ 23 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -40,6 +40,8 @@ export default {
     confirm: 'Confirmar',
     confirm: 'Confirmar',
     loading: 'Carregando...',
     loading: 'Carregando...',
     error: 'Erro',
     error: 'Erro',
+    errorLoading: 'Erro ao carregar',
+    retry: 'Tentar novamente',
     success: 'Sucesso',
     success: 'Sucesso',
     warning: 'Aviso',
     warning: 'Aviso',
     enabled: 'Ativado',
     enabled: 'Ativado',
@@ -1378,6 +1380,7 @@ export default {
       ldap: 'LDAP',
       ldap: 'LDAP',
       twoFa: 'Autenticação 2FA',
       twoFa: 'Autenticação 2FA',
       oidc: 'SSO / OIDC',
       oidc: 'SSO / OIDC',
+      security: 'Security',
       spoolbuddy: 'SpoolBuddy',
       spoolbuddy: 'SpoolBuddy',
     },
     },
     ldap: {
     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: {
     spoolbuddy: {
       infoTitle: 'Dispositivos 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.',
       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
   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',
     title: 'Bakup e Restauração',
     createBackup: 'Criar Backup',
     createBackup: 'Criar Backup',
     restoreBackup: 'Restaurar Backup',
     restoreBackup: 'Restaurar Backup',

+ 22 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -40,6 +40,8 @@ export default {
     confirm: '确认',
     confirm: '确认',
     loading: '加载中...',
     loading: '加载中...',
     error: '错误',
     error: '错误',
+    errorLoading: '加载错误',
+    retry: '重试',
     success: '成功',
     success: '成功',
     warning: '警告',
     warning: '警告',
     enabled: '已启用',
     enabled: '已启用',
@@ -1379,6 +1381,7 @@ export default {
       ldap: 'LDAP',
       ldap: 'LDAP',
       twoFa: '双因素认证',
       twoFa: '双因素认证',
       oidc: 'SSO / OIDC',
       oidc: 'SSO / OIDC',
+      security: 'Security',
     },
     },
     spoolbuddy: {
     spoolbuddy: {
       infoTitle: '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)
   // Notifications (for push notifications)
@@ -3685,6 +3706,7 @@ export default {
 
 
   // Backup
   // Backup
   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: '备份与恢复',
     title: '备份与恢复',
     createBackup: '创建备份',
     createBackup: '创建备份',
     restoreBackup: '恢复备份',
     restoreBackup: '恢复备份',

+ 22 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -40,6 +40,8 @@ export default {
     confirm: '確認',
     confirm: '確認',
     loading: '載入中...',
     loading: '載入中...',
     error: '錯誤',
     error: '錯誤',
+    errorLoading: '載入錯誤',
+    retry: '重試',
     success: '成功',
     success: '成功',
     warning: '警告',
     warning: '警告',
     enabled: '已啟用',
     enabled: '已啟用',
@@ -1379,6 +1381,7 @@ export default {
       ldap: 'LDAP',
       ldap: 'LDAP',
       twoFa: '雙因素認證',
       twoFa: '雙因素認證',
       oidc: 'SSO / OIDC',
       oidc: 'SSO / OIDC',
+      security: 'Security',
     },
     },
     spoolbuddy: {
     spoolbuddy: {
       infoTitle: '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)
   // Notifications (for push notifications)
@@ -3685,6 +3706,7 @@ export default {
 
 
   // Backup
   // Backup
   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: '備份與恢復',
     title: '備份與恢復',
     createBackup: '建立備份',
     createBackup: '建立備份',
     restoreBackup: '恢復備份',
     restoreBackup: '恢復備份',

+ 3 - 1
frontend/src/lib/settingsSearch.ts

@@ -22,7 +22,9 @@ export type SettingsSearchTab =
   | 'backup'
   | 'backup'
   | 'failure-detection';
   | '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 {
 export interface SettingsSearchEntry {
   /** i18n key for the label. Resolved with t() at render time. */
   /** 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 { LDAPSettings } from '../components/LDAPSettings';
 import { TwoFactorSettings } from '../components/TwoFactorSettings';
 import { TwoFactorSettings } from '../components/TwoFactorSettings';
 import { OIDCProviderSettings } from '../components/OIDCProviderSettings';
 import { OIDCProviderSettings } from '../components/OIDCProviderSettings';
+import { SecurityStatusCard } from '../components/SecurityStatusCard';
 import { APIBrowser } from '../components/APIBrowser';
 import { APIBrowser } from '../components/APIBrowser';
 import { Toggle } from '../components/Toggle';
 import { Toggle } from '../components/Toggle';
 import { virtualPrinterApi, spoolbuddyApi } from '../api/client';
 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 { useState, useEffect, useRef, useCallback } from 'react';
 import { Palette } from 'lucide-react';
 import { Palette } from 'lucide-react';
 import { registerSettingsSearch, getSettingsSearchEntries } from '../lib/settingsSearch';
 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;
 const validTabs = ['general', 'plugs', 'notifications', 'queue', 'filament', 'network', 'apikeys', 'virtual-printer', 'spoolbuddy', 'failure-detection', 'users', 'backup'] as const;
 type TabType = typeof validTabs[number];
 type TabType = typeof validTabs[number];
-type UsersSubTab = 'users' | 'email' | 'ldap' | 'twofa' | 'oidc';
 
 
 // Cross-tab search registrations for cards rendered inline in this file.
 // 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
 // Adding a new settings card? Register it here (or, if the card lives in its
@@ -4891,6 +4892,19 @@ export function SettingsPage() {
                 />
                 />
               </button>
               </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>
           </div>
 
 
           {/* Users Sub-tab */}
           {/* Users Sub-tab */}
@@ -5229,6 +5243,12 @@ export function SettingsPage() {
               <OIDCProviderSettings />
               <OIDCProviderSettings />
             </div>
             </div>
           )}
           )}
+
+          {usersSubTab === 'security' && isAdmin && (
+            <div className="max-w-3xl">
+              <SecurityStatusCard />
+            </div>
+          )}
         </div>
         </div>
       )}
       )}
 
 
@@ -5700,6 +5720,10 @@ export function SettingsPage() {
 
 
       {activeTab === 'backup' && (
       {activeTab === 'backup' && (
         <div id="card-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 />
           <GitHubBackupSettings />
         </div>
         </div>
       )}
       )}

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-C_2KW3q0.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 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" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
 
 
     <!-- Splash screens for iOS -->
     <!-- 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>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio