Files
Unraid-AppOps-Gateway-Public/backend/app/services/secret_store.py
T

198 lines
6.9 KiB
Python

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