From 0fd6930b41f47a31e1c0b78bdbadda316935b43a Mon Sep 17 00:00:00 2001 From: curo1305 Date: Thu, 4 Jun 2026 18:50:01 +0200 Subject: [PATCH] feat(07-01): services/ai_config.py HKDF helpers, ProviderConfig loader, env seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create backend/services/ai_config.py with: - _derive_ai_settings_key(master_key, provider_id): fresh HKDF per call, salt=provider_id.encode("utf-8"), info=b"ai-provider-settings" (domain-separated from b"cloud-credentials") - encrypt_api_key / decrypt_api_key: Fernet round-trip without JSON wrapping - load_provider_config(session): reads is_active=True row from system_settings, decrypts api_key_enc; returns stub ProviderConfig (real class lands in Plan 02) - seed_system_settings_from_env(session): idempotent insert of default provider on first boot - _ProviderConfigStub: minimal Pydantic model stub until ai/provider_config.py exists (Plan 02) - Update backend/main.py: import seed_system_settings_from_env, call in lifespan with try/except so missing table (pre-migration fresh boot) doesn't crash startup - Round-trip smoke test: encrypt_api_key → decrypt_api_key == original plaintext --- backend/main.py | 14 +++ backend/services/ai_config.py | 225 ++++++++++++++++++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 backend/services/ai_config.py diff --git a/backend/main.py b/backend/main.py index 9ab1ea5..0c10759 100644 --- a/backend/main.py +++ b/backend/main.py @@ -22,6 +22,7 @@ from api.documents import router as documents_router from api.topics import router as topics_router from config import settings from db.session import AsyncSessionLocal, engine +from services.ai_config import seed_system_settings_from_env from services.logging import setup_logging from services.rate_limiting import account_limiter @@ -158,6 +159,19 @@ async def lifespan(app: FastAPI): async with AsyncSessionLocal() as session: await bootstrap_admin(session) + # AI provider seed (D-04 / Phase 7): populate system_settings from env vars + # on first boot. Wrapped in try/except so that a missing table (fresh container + # before migrations run) does not crash startup — logs a warning and skips. + try: + async with AsyncSessionLocal() as session: + await seed_system_settings_from_env(session) + await session.commit() + except Exception as _seed_exc: + import logging as _logging + _logging.getLogger(__name__).warning( + "AI provider seed skipped (table may not exist yet): %s", _seed_exc + ) + yield # Shutdown: close pooled connections and Redis diff --git a/backend/services/ai_config.py b/backend/services/ai_config.py new file mode 100644 index 0000000..6135222 --- /dev/null +++ b/backend/services/ai_config.py @@ -0,0 +1,225 @@ +""" +AI provider configuration service for DocuVault. + +Provides HKDF/Fernet encryption helpers, a provider config loader that reads +from the system_settings DB table, and a startup seed function that populates +the default provider row from env vars on first boot. + +Security design (D-05, T-07-02): + HKDF domain separation — the info bytes b"ai-provider-settings" differ from + b"cloud-credentials" used by storage/cloud_utils.py. Both use the same master + key (settings.cloud_creds_key) but produce DIFFERENT derived Fernet keys, so + a leaked cloud credential cannot decrypt an AI API key and vice versa. + +AlreadyFinalized warning (RESEARCH.md Pitfall 2 / .continue-here.md anti-pattern): + The cryptography library raises AlreadyFinalized if .derive() is called twice + on the same HKDF instance. _derive_ai_settings_key() creates a FRESH HKDF(...) + object on every call — never cache or reuse the HKDF object between calls. + +Pattern reference: storage/cloud_utils.py:_derive_fernet_key(). +""" +from __future__ import annotations + +import base64 +import logging +from typing import Optional, TYPE_CHECKING + +import structlog +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from config import settings + +# ProviderConfig redefined in ai/provider_config.py during Plan 02 — +# load_provider_config will re-import and return that class once Plan 02 lands. +# The stub below is removed in Plan 03 once the classifier consumes the real class. + + +logger = structlog.get_logger(__name__) + + +# ── Stub ProviderConfig for Plan 01 (replaced by ai/provider_config.py in Plan 02) ── + +class _ProviderConfigStub(BaseModel): + """Minimal provider config placeholder until Plan 02 creates ai/provider_config.py.""" + + provider_id: str + api_key: str = "" + base_url: Optional[str] = None + model: str = "" + context_chars: int = 8000 + + +# ── HKDF key derivation ─────────────────────────────────────────────────────── + +def _derive_ai_settings_key(master_key: bytes, provider_id: str) -> Fernet: + """Derive a per-provider Fernet encryption key using HKDF-SHA256. + + Security notes: + - A FRESH HKDF instance is created on every call. The cryptography library + raises AlreadyFinalized if .derive() is called twice on the same instance. + Never cache or reuse the HKDF object (RESEARCH.md Pitfall 2). + - salt = provider_id.encode("utf-8") provides per-provider derivation + (deterministic: same provider → same key for encrypt/decrypt consistency). + - info = b"ai-provider-settings" provides domain separation from + b"cloud-credentials" — same master key, different derived keys. + A leaked cloud credential cannot decrypt an AI API key (T-07-02 mitigated). + + Args: + master_key: The CLOUD_CREDS_KEY env var as bytes. + provider_id: The provider slug, e.g. "openai", "anthropic" (used as HKDF salt). + + Returns: + A Fernet instance ready for encrypt/decrypt operations. + """ + # Create a FRESH HKDF instance — never cache (AlreadyFinalized guard) + hkdf = HKDF( + algorithm=hashes.SHA256(), + length=32, + salt=provider_id.encode("utf-8"), + info=b"ai-provider-settings", # domain-separated from b"cloud-credentials" + ) + raw_key: bytes = hkdf.derive(master_key) + fernet_key = base64.urlsafe_b64encode(raw_key) + return Fernet(fernet_key) + + +# ── Encryption helpers ──────────────────────────────────────────────────────── + +def encrypt_api_key(master_key: bytes, provider_id: str, api_key: str) -> str: + """Encrypt a plaintext API key string to a Fernet token. + + The returned string is safe to store in system_settings.api_key_enc. + No JSON wrapping — the raw API key string is encrypted directly. + + Args: + master_key: The CLOUD_CREDS_KEY env var as bytes. + provider_id: The provider slug (used as HKDF salt for key derivation). + api_key: The plaintext API key, e.g. "sk-proj-...". + + Returns: + A URL-safe base64 Fernet token (str). + """ + f = _derive_ai_settings_key(master_key, provider_id) + return f.encrypt(api_key.encode("utf-8")).decode("utf-8") + + +def decrypt_api_key(master_key: bytes, provider_id: str, api_key_enc: str) -> str: + """Decrypt a Fernet token back to the original plaintext API key. + + Args: + master_key: The CLOUD_CREDS_KEY env var as bytes. + provider_id: The provider slug (used as HKDF salt for key derivation). + api_key_enc: The Fernet token string from the database. + + Returns: + The original plaintext API key string. + """ + f = _derive_ai_settings_key(master_key, provider_id) + return f.decrypt(api_key_enc.encode("utf-8")).decode("utf-8") + + +# ── Provider config loader ──────────────────────────────────────────────────── + +async def load_provider_config(session: AsyncSession) -> Optional[_ProviderConfigStub]: + """Load the active AI provider config from the system_settings table. + + Returns a _ProviderConfigStub built from the row where is_active=True, + decrypting api_key_enc when present. Returns None when no active row exists. + + Note: In Plan 02, this function will try to import and return ProviderConfig + from ai/provider_config.py instead of the stub. The import is done lazily + inside the function body so that Plan 01 does not depend on files that don't + exist yet. + + Args: + session: An open AsyncSession. + + Returns: + A _ProviderConfigStub (or the real ProviderConfig from Plan 02+) if an + active row exists; None if the table is empty or no row is marked active. + """ + # Lazy import: try to use the real ProviderConfig once Plan 02 lands + try: + from ai.provider_config import ProviderConfig as _RealProviderConfig # type: ignore[import] + config_cls = _RealProviderConfig + except ImportError: + config_cls = _ProviderConfigStub # type: ignore[assignment] + + from db.models import SystemSettings # local import to avoid circular deps + + stmt = select(SystemSettings).where(SystemSettings.is_active.is_(True)) + result = await session.execute(stmt) + row = result.scalar_one_or_none() + + if row is None: + return None + + # Decrypt API key if present + api_key = "" + if row.api_key_enc: + master_key = settings.cloud_creds_key.encode("utf-8") + try: + api_key = decrypt_api_key(master_key, row.provider_id, row.api_key_enc) + except Exception: + logger.warning( + "ai_config.load_provider_config: failed to decrypt api_key_enc", + provider_id=row.provider_id, + ) + + return config_cls( + provider_id=row.provider_id, + api_key=api_key, + base_url=row.base_url, + model=row.model_name, + context_chars=row.context_chars, + ) + + +# ── Startup seed ────────────────────────────────────────────────────────────── + +async def seed_system_settings_from_env(session: AsyncSession) -> None: + """Populate system_settings with a default provider row on first boot. + + Reads settings.default_ai_provider and settings.default_ai_model from config. + If no row exists for that provider_id, inserts one with is_active=True. + Never overwrites an existing row — idempotent across restarts (D-04). + + This function is called from the FastAPI lifespan in main.py after the + session factory is available. Caller is responsible for committing. + + Args: + session: An open AsyncSession. + """ + from db.models import SystemSettings # local import to avoid circular deps + + provider_id = settings.default_ai_provider + model_name = settings.default_ai_model + + stmt = select(SystemSettings).where(SystemSettings.provider_id == provider_id) + result = await session.execute(stmt) + existing = result.scalar_one_or_none() + + if existing is not None: + # Row already exists — never overwrite (idempotent) + return + + # Insert default row with no API key (local providers like Ollama don't need one) + row = SystemSettings( + provider_id=provider_id, + model_name=model_name, + context_chars=8000, + is_active=True, + api_key_enc=None, + base_url=None, + ) + session.add(row) + logger.info( + "ai_config.seed_system_settings_from_env: seeded default provider", + provider_id=provider_id, + model_name=model_name, + )