from __future__ import annotations import base64 import hashlib import re from typing import Any from cryptography.fernet import Fernet, InvalidToken from sqlmodel import Session, select from ..core.config import settings from ..core.redaction import redact_message from ..core.time import utc_now from ..db.models import Secret SECRET_LIKE_PATTERN = re.compile(r"(secret|token|api[_-]?key|password|x-api-key)", re.IGNORECASE) REDACTED = "[REDACTED]" def _derive_fernet_key(value: str) -> bytes: value = value.strip() try: Fernet(value.encode("utf-8")) return value.encode("utf-8") except (ValueError, TypeError): digest = hashlib.sha256(value.encode("utf-8")).digest() return base64.urlsafe_b64encode(digest) def _fallback_material() -> str: return f"appops-development-secret-store:{settings.db_path}:{settings.data_dir}" def _active_key_material() -> str: if settings.secret_key: if settings.environment == "production" and len(settings.secret_key) < 32: raise RuntimeError("APPOPS_SECRET_KEY is too weak for production") return settings.secret_key return _fallback_material() def _fernet() -> Fernet: return Fernet(_derive_fernet_key(_active_key_material())) def secret_storage_status() -> dict[str, Any]: key_source = settings.secret_key_source if settings.secret_key else "missing" encrypted = bool(settings.secret_key) or settings.environment != "production" key_strong = bool(settings.secret_key and len(settings.secret_key) >= 32) production_ready = key_strong and key_source in {"env", "file"} warnings: list[str] = [] if settings.environment == "production" and not settings.secret_key: warnings.append("APPOPS_SECRET_KEY is required for production encrypted secret storage.") elif settings.environment == "production" and not key_strong: warnings.append("APPOPS_SECRET_KEY must contain at least 32 characters in production.") if settings.secret_key_source == "file_unreadable": warnings.append("APPOPS_SECRET_KEY_FILE could not be read.") if settings.environment != "production" and not settings.secret_key: key_source = "development_fallback" warnings.append("Development fallback key is active; set APPOPS_SECRET_KEY before production.") return { "encryptedAtRest": encrypted, "enabled": encrypted, "keySource": key_source, "rotationEnabled": settings.secret_rotation_enabled, "productionReady": production_ready, "status": "ready" if production_ready or settings.environment != "production" else "warning", "warnings": warnings, } def set_secret( session: Session, name: str, value: str, *, used_by: list[str] | None = None, metadata: dict[str, Any] | None = None, commit: bool = True, ) -> Secret: clean_name = name.strip().upper() clean_value = value.strip() now = utc_now() encrypted = _fernet().encrypt(clean_value.encode("utf-8")).decode("utf-8") row = session.get(Secret, clean_name) if not row: row = Secret(id=clean_name, name=clean_name, encrypted_value=encrypted, created_at=now) row.name = clean_name row.encrypted_value = encrypted row.updated_at = now row.used_by_json = sorted(set(used_by or row.used_by_json or [])) row.metadata_json = metadata or row.metadata_json or {} session.add(row) if commit: session.commit() return row def get_secret(session: Session, name: str, *, touch: bool = True) -> str | None: row = session.get(Secret, name.strip().upper()) if not row: return None try: value = _fernet().decrypt(row.encrypted_value.encode("utf-8")).decode("utf-8") except InvalidToken: return None if touch: row.last_used_at = utc_now() session.add(row) session.commit() return value def has_secret(session: Session, name: str) -> bool: row = session.get(Secret, name.strip().upper()) return bool(row and row.encrypted_value) def delete_secret(session: Session, name: str) -> None: row = session.get(Secret, name.strip().upper()) if row: session.delete(row) session.commit() def rotate_secret(session: Session, name: str, new_value: str, *, used_by: list[str] | None = None) -> Secret: row = session.get(Secret, name.strip().upper()) return set_secret(session, name, new_value, used_by=used_by or (row.used_by_json if row else None)) def secret_status(row: Secret) -> dict[str, Any]: updated_at = row.updated_at or row.created_at age_days = max(0, (utc_now() - updated_at).days) if updated_at else None return { "secretRef": row.id, "name": row.name, "hasSecret": bool(row.encrypted_value), "status": "configured" if row.encrypted_value else "missing", "usedBy": row.used_by_json or [], "lastUpdated": row.updated_at.isoformat() if row.updated_at else None, "lastUsed": row.last_used_at.isoformat() if row.last_used_at else None, "ageDays": age_days, "rotationRecommended": bool(age_days is not None and age_days >= 90), "metadata": { key: value for key, value in (row.metadata_json or {}).items() if not SECRET_LIKE_PATTERN.search(str(key)) }, } def empty_secret_status(name: str, *, used_by: list[str] | None = None) -> dict[str, Any]: return { "secretRef": name.strip().upper(), "name": name.strip().upper(), "hasSecret": False, "status": "missing", "usedBy": used_by or [], "lastUpdated": None, "lastUsed": None, "ageDays": None, "rotationRecommended": False, "metadata": {}, } def list_secret_status(session: Session) -> list[dict[str, Any]]: rows = session.exec(select(Secret).order_by(Secret.name)).all() return [secret_status(row) for row in rows] def redact_secret_value(value: str | None) -> str | None: return REDACTED if value else value def _known_secret_values(session: Session | None) -> list[str]: if session is None: return [] values: list[str] = [] rows = session.exec(select(Secret)).all() for row in rows: value = get_secret(session, row.id) if value: values.append(value) return values def redact_text(text: str, *, session: Session | None = None, known_values: list[str] | None = None) -> str: redacted = redact_message(text) redacted = re.sub(r"(x-api-key\s*[:=]\s*)([^\s\"']+)", rf"\1{REDACTED}", redacted, flags=re.IGNORECASE) redacted = re.sub(r"(apiKey\s*[:=]\s*)([^\s\"']+)", rf"\1{REDACTED}", redacted, flags=re.IGNORECASE) redacted = re.sub(r"(token\s*[:=]\s*)([^\s\"']+)", rf"\1{REDACTED}", redacted, flags=re.IGNORECASE) for value in [*(known_values or []), *_known_secret_values(session)]: if value: redacted = redacted.replace(value, REDACTED) return redacted