From 86330e8f65bf9f95d2f5019173a931bf48b21126 Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 27 Oct 2026 23:44:43 -0400 Subject: [PATCH 01/10] Phase 2 coffre: module migration api_keys.json -> coffre d'instance (import + purge confirmee) --- backend/coffre_migration.py | 306 ++++++++++++++++++++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 backend/coffre_migration.py diff --git a/backend/coffre_migration.py b/backend/coffre_migration.py new file mode 100644 index 0000000..edab6fa --- /dev/null +++ b/backend/coffre_migration.py @@ -0,0 +1,306 @@ +"""Migration Phase 2 du coffre Secrets ObsiGate (issue #20). + +Bascule des clés aujourd'hui en clair dans ``data/api_keys.json`` vers le +coffre d'instance « ObsiGate interne » (slug ``obsigate-interne``). + +Format source (constaté dans ``backend/ai.py`` et +``backend/tools/secrets.py``) : un dictionnaire plat ``{NOM: valeur}``. +Seules les entrées dont le nom ressemble à un secret sont migrables : +clés IA et clés d'outils. Les valeurs non secrètes (par exemple +``OBSIGATE_GITEA_URL``) ne sont JAMAIS migrées et ne sont jamais +touchées par la purge. + +Règles de sécurité, non négociables : +- Aucune fonction de ce module ne renvoie une valeur, ne la journalise, + ni ne l'écrit ailleurs que dans le coffre (chiffrée, via le store). + Les aperçus ne contiennent que des noms, des types et ``has_value``. +- L'import ne supprime rien : il crée des secrets dans le coffre et + laisse ``api_keys.json`` intact. La purge est une étape séparée, qui + exige ``confirmer=True`` et ne supprime que les noms listés. +- Un secret déjà présent dans le coffre n'est JAMAIS écrasé : il est + signalé dans ``deja_existants`` et la valeur du coffre est conservée. + +Limite connue, documentée dans ``docs/COFFRE-sauvegarde-restauration.md`` : +les lecteurs actuels (``backend/ai.py``, ``backend/tools/secrets.py``) +lisent encore ``api_keys.json`` (chemin relatif ``data/``, sans passer +par ``OBSIGATE_DATA_DIR``). Purger le clair sans avoir branché le +lecteur concerné sur le coffre rend donc la clé inaccessible à ce +lecteur : la purge reste un choix explicite de l'opérateur, confirmé +deux fois côté interface (import, puis purge). +""" + +from __future__ import annotations + +import json +import logging +import os +import threading +from pathlib import Path +from typing import Any + +from backend.coffre_store import ( + ValidationError, + create_coffre, + create_secret, + get_coffre, + get_secret, + resolve_data_dir, +) + +logger = logging.getLogger("obsigate.coffre_migration") + +_lock = threading.RLock() + +COFFRE_INTERNE_SLUG = "obsigate-interne" +COFFRE_INTERNE_NOM = "ObsiGate interne" + +# (nom dans api_keys.json, type de secret, service d'affichage). +# Miroir des clés lues par backend/ai.py (clés IA) et par +# backend/tools/secrets.py TOOL_KEY_NAMES (clés d'outils). L'URL Gitea +# est volontairement absente : ce n'est pas un secret. +CLES_MIGRABLES: tuple[tuple[str, str, str], ...] = ( + ("DEEPSEEK_API_KEY", "api_key", "DeepSeek"), + ("OPENROUTER_API_KEY", "api_key", "OpenRouter"), + ("GEMINI_API_KEY", "api_key", "Gemini"), + ("NVIDIA_API_KEY", "api_key", "NVIDIA"), + ("QWENCLOUD_API_KEY", "api_key", "QwenCloud"), + ("XIAOMI_API_KEY", "api_key", "Xiaomi"), + ("MISTRAL_API_KEY", "api_key", "Mistral"), + ("OBSIGATE_TAVILY_API_KEY", "api_key", "Tavily"), + ("OBSIGATE_BRAVE_API_KEY", "api_key", "Brave"), + ("OBSIGATE_SERPAPI_API_KEY", "api_key", "SerpAPI"), + ("OBSIGATE_EXA_API_KEY", "api_key", "Exa"), + ("OBSIGATE_GITEA_TOKEN", "token", "Gitea"), + ("OBSIGATE_GITHUB_TOKEN", "token", "GitHub"), +) + +_NOMS_MIGRABLES = {nom for nom, _, _ in CLES_MIGRABLES} + + +def _nom_secret(nom_source: str) -> str: + """Nom de secret dérivé du nom source (minuscules, valide NOM_SECRET_RE).""" + return nom_source.lower() + + +def _api_keys_path(base: Path) -> Path: + return base / "api_keys.json" + + +def _read_api_keys(base: Path) -> dict: + path = _api_keys_path(base) + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + logger.warning("api_keys.json illisible (%s) : %s", path, exc) + return {} + return data if isinstance(data, dict) else {} + + +def _write_api_keys(base: Path, data: dict) -> None: + """Écriture atomique tmp+rename, fichier final en 0600 (best-effort).""" + path = _api_keys_path(base) + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + try: + os.chmod(tmp, 0o600) + except OSError: + pass + os.replace(tmp, path) + try: + os.chmod(path, 0o600) + except OSError: + pass + + +def lister_candidats(data_dir: str | Path | None = None) -> list: + """Aperçu de migration : métadonnées seules, jamais de valeur. + + Chaque candidat porte ``nom`` (nom dans le coffre), ``source`` (nom + dans api_keys.json), ``type``, ``service``, ``has_value`` (une valeur + non vide est présente dans le fichier) et ``deja_existant`` (un + secret de ce nom existe déjà dans le coffre d'instance). + """ + base = resolve_data_dir(data_dir) + with _lock: + cles = _read_api_keys(base) + coffre = get_coffre(COFFRE_INTERNE_SLUG, base) + candidats = [] + for source, type_secret, service in CLES_MIGRABLES: + nom = _nom_secret(source) + valeur = cles.get(source) + candidats.append( + { + "nom": nom, + "source": source, + "type": type_secret, + "service": service, + "has_value": isinstance(valeur, str) and bool(valeur.strip()), + "deja_existant": ( + get_secret(coffre["id"], nom, base) is not None + if coffre is not None + else False + ), + } + ) + return candidats + + +def importer_api_keys( + data_dir: str | Path | None = None, cree_par: str = "" +) -> dict: + """Importe les entrées avec valeur vers le coffre d'instance. + + Ne supprime rien dans ``api_keys.json``. Retourne uniquement des + noms et des comptes : ``migres``, ``deja_existants``, ``sans_valeur``. + Lève ``KEKAbsenteError`` / ``CryptoIndisponibleError`` si le coffre + ne peut pas chiffrer (le routeur les traduit en 503). + """ + base = resolve_data_dir(data_dir) + with _lock: + cles = _read_api_keys(base) + coffre = get_coffre(COFFRE_INTERNE_SLUG, base) + if coffre is None: + coffre = create_coffre( + slug=COFFRE_INTERNE_SLUG, + nom=COFFRE_INTERNE_NOM, + proprietaire=(cree_par or "admin").strip(), + perimetre="instance", + description="Coffre d'instance : clés IA et clés d'outils migrées depuis api_keys.json (Phase 2).", + data_dir=base, + ) + migres: list[str] = [] + deja_existants: list[str] = [] + sans_valeur: list[str] = [] + for source, type_secret, service in CLES_MIGRABLES: + nom = _nom_secret(source) + valeur = cles.get(source) + if not (isinstance(valeur, str) and valeur.strip()): + sans_valeur.append(nom) + continue + if get_secret(coffre["id"], nom, base) is not None: + deja_existants.append(nom) + continue + create_secret( + coffre, + nom, + type_secret, + valeur, + service=service, + note="Migré depuis api_keys.json (Phase 2).", + data_dir=base, + ) + migres.append(nom) + return { + "coffre": COFFRE_INTERNE_SLUG, + "migres": migres, + "deja_existants": deja_existants, + "sans_valeur": sans_valeur, + } + + +def purger_api_keys( + noms: list[str] | None, + data_dir: str | Path | None = None, + confirmer: bool = False, +) -> dict: + """Supprime du clair les noms listés, après confirmation explicite. + + ``confirmer`` doit valoir True, sinon refus (jamais automatique). + Seuls les noms migrables sont acceptés ; un nom inconnu refuse + l'opération entière, pour ne jamais supprimer une clé par erreur. + Retourne ``supprimes`` et ``absents`` (noms, jamais de valeurs). + """ + if not confirmer: + raise ValidationError( + "Suppression du clair refusée : confirmation explicite requise" + ) + demandes = [(n or "").strip().lower() for n in (noms or [])] + # Accepte indifféremment le nom source (DEEPSEEK_API_KEY) ou le nom + # de secret (deepseek_api_key) ; tout le reste refuse l'opération. + noms_secrets = {_nom_secret(source) for source in _NOMS_MIGRABLES} + inconnus = [ + n + for n in demandes + if n.upper() not in _NOMS_MIGRABLES and n not in noms_secrets + ] + if inconnus: + raise ValidationError(f"Noms inconnus, purge annulée : {', '.join(inconnus)}") + sources_a_supprimer = set() + for demande in demandes: + for source, _, _ in CLES_MIGRABLES: + if demande in (source.lower(), _nom_secret(source)): + sources_a_supprimer.add(source) + base = resolve_data_dir(data_dir) + with _lock: + cles = _read_api_keys(base) + supprimes: list[str] = [] + absents: list[str] = [] + for source in sorted(sources_a_supprimer): + if source in cles: + del cles[source] + supprimes.append(_nom_secret(source)) + else: + absents.append(_nom_secret(source)) + _write_api_keys(base, cles) + return {"supprimes": supprimes, "absents": absents} + + +def creer_consommateur_avec_octrois( + nom: str, + octrois_demandes: list[dict[str, Any]] | None, + cree_par: str, + data_dir: str | Path | None = None, +) -> tuple[dict, str, list, list]: + """Enregistre un consommateur et ses octrois en une seule opération. + + Retourne (consommateur public, jeton à usage unique, octrois créés, + erreurs). Une erreur sur un octroi (coffre ou secret introuvable) + n'annule pas le consommateur ni les autres octrois : elle est + signalée dans ``erreurs`` (noms seuls, jamais de valeurs). Le coffre + lui-même n'est jamais modifié par cette opération. + """ + from backend.coffre_store import ( # import local : évite un cycle + IntrouvableError, + create_consommateur, + create_octroi, + ) + + base = resolve_data_dir(data_dir) + consommateur, jeton = create_consommateur(nom, cree_par, base) + crees: list[dict] = [] + erreurs: list[dict] = [] + for demande in octrois_demandes or []: + slug = str(demande.get("coffre_slug", "") or "") + secret_nom = str(demande.get("secret_nom", "") or "") or None + try: + coffre = get_coffre(slug, base) + if coffre is None: + raise IntrouvableError(f"Coffre introuvable : {slug}") + secret_id = None + if secret_nom is not None: + secret = get_secret(coffre["id"], secret_nom, base) + if secret is None: + raise IntrouvableError(f"Secret introuvable : {secret_nom}") + secret_id = secret["id"] + octroi = create_octroi( + coffre=coffre, + cible_type="consommateur", + cible_id=consommateur["id"], + secret_id=secret_id, + cree_par=cree_par, + expiration=demande.get("expiration"), + data_dir=base, + ) + crees.append(octroi) + except (IntrouvableError, ValidationError) as exc: + erreurs.append( + { + "coffre_slug": slug, + "secret_nom": secret_nom, + "erreur": str(exc), + } + ) + return consommateur, jeton, crees, erreurs From 71221e055bf6bc26596a10dd4a96525715742cc5 Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 27 Oct 2026 23:44:58 -0400 Subject: [PATCH 02/10] Phase 2 coffre: migration, consommateur+octrois, contexte Spark imbrique --- backend/routers/coffres.py | 131 ++++++++++++++++++++++++++++++++++++- 1 file changed, 128 insertions(+), 3 deletions(-) diff --git a/backend/routers/coffres.py b/backend/routers/coffres.py index b3aa91c..5375d2b 100644 --- a/backend/routers/coffres.py +++ b/backend/routers/coffres.py @@ -19,6 +19,14 @@ Règles d'or, appliquées côté serveur à chaque requête : utilisé ici, volontairement). - Feature flag ``OBSIGATE_SECRETS_ENABLED`` (défaut désactivé) : tant qu'il n'est pas activé, toutes les routes répondent 503. +- Phase 2 (issues #20) : migration ``api_keys.json`` -> coffre + d'instance, en deux temps (import, puis purge confirmée), et + enregistrement d'un consommateur avec ses octrois en un appel. +- Phase 3 (issue #21) : ``/distribuer`` accepte le contexte Spark à + plat ou imbriqué (``contexte: {type: "mission", id}``) ; les codes + d'erreur restent ceux de la Phase 1b (voir + ``docs/COFFRE-contrat-distribution.md`` pour la correspondance avec + l'état ``acces_requis`` côté Spark). """ from __future__ import annotations @@ -26,15 +34,22 @@ from __future__ import annotations import logging import os +from backend.auth.middleware import require_admin, require_auth from fastapi import APIRouter, Body, Depends, HTTPException, Request from backend.audit import get_recent_entries, log_coffre_event -from backend.auth.middleware import require_admin, require_auth from backend.coffre_crypto import ( CryptoIndisponibleError, KEKAbsenteError, generate_kek_b64, ) +from backend.coffre_migration import ( + COFFRE_INTERNE_SLUG, + creer_consommateur_avec_octrois, + importer_api_keys, + lister_candidats, + purger_api_keys, +) from backend.coffre_store import ( CoffreExisteError, IntrouvableError, @@ -273,6 +288,43 @@ async def api_create_consommateur( return {**consommateur, "jeton": jeton} +@router.post("/api/coffres/consommateurs/avec-octrois") +async def api_create_consommateur_avec_octrois( + body: dict = Body(...), current_user=Depends(require_admin) +): + """Phase 4 : enregistrer une app = un consommateur + ses octrois. + + Le coffre n'est jamais modifié par cette opération. Le jeton n'est + rendu qu'ici, jamais stocké en clair, jamais relisté. Un octroi en + erreur (coffre ou secret introuvable) est signalé dans ``erreurs`` + sans annuler le consommateur ni les autres octrois. + """ + _exiger_actives() + try: + consommateur, jeton, octrois, erreurs = creer_consommateur_avec_octrois( + nom=body.get("nom", ""), + octrois_demandes=body.get("octrois") or [], + cree_par=_username(current_user), + ) + except ValidationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + _audit( + _username(current_user), + "consommateur_cree", + consommateur=consommateur["nom"], + resultat="ok", + ) + for octroi in octrois: + coffre_octroi = get_coffre_by_id(octroi["coffre_id"]) + _audit( + _username(current_user), + "octroi_cree", + coffre=coffre_octroi["slug"] if coffre_octroi else None, + resultat="ok", + ) + return {**consommateur, "jeton": jeton, "octrois": octrois, "erreurs": erreurs} + + @router.delete("/api/coffres/consommateurs/{consommateur_id}") async def api_revoke_consommateur( consommateur_id: str, current_user=Depends(require_admin) @@ -291,6 +343,67 @@ async def api_revoke_consommateur( return consommateur +# --------------------------------------------------------------------------- +# Migration Phase 2 (admin seulement) : api_keys.json -> coffre d'instance. +# L'import ne supprime rien ; la purge exige une confirmation explicite +# et ne supprime que les noms listés. Aucune réponse ne contient jamais +# une valeur, seulement des noms et des comptes. +# Ces routes sont déclarées AVANT /api/coffres/{slug} pour ne pas être +# capturées par le paramètre slug. +# --------------------------------------------------------------------------- + +@router.get("/api/coffres/migration/api-keys") +async def api_migration_apercu(current_user=Depends(require_admin)): + _exiger_actives() + return {"coffre": COFFRE_INTERNE_SLUG, "candidats": lister_candidats()} + + +@router.post("/api/coffres/migration/api-keys/import") +async def api_migration_import(current_user=Depends(require_admin)): + _exiger_actives() + try: + resultat = importer_api_keys(cree_par=_username(current_user)) + except ValidationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except KEKAbsenteError: + raise HTTPException(status_code=503, detail="kek_absente") from None + except CryptoIndisponibleError: + raise HTTPException(status_code=503, detail="crypto_indisponible") from None + for nom in resultat["migres"]: + _audit( + _username(current_user), + "migration_api_keys", + coffre=COFFRE_INTERNE_SLUG, + secret_ref=f"coffre:{COFFRE_INTERNE_SLUG}/{nom}", + resultat="ok", + ) + return resultat + + +@router.post("/api/coffres/migration/api-keys/purger") +async def api_migration_purger( + body: dict = Body(...), current_user=Depends(require_admin) +): + _exiger_actives() + if body.get("confirmer") is not True: + raise HTTPException(status_code=400, detail="confirmation_requise") + try: + resultat = purger_api_keys( + noms=body.get("noms") or [], confirmer=True + ) + except ValidationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + for nom in resultat["supprimes"]: + _audit( + _username(current_user), + "migration_api_keys_purge", + coffre=COFFRE_INTERNE_SLUG, + secret_ref=f"coffre:{COFFRE_INTERNE_SLUG}/{nom}", + resultat="ok", + ) + return resultat + + # --------------------------------------------------------------------------- # Distribution (serveur-à-serveur, hors session utilisateur, hors LLM) # --------------------------------------------------------------------------- @@ -299,8 +412,20 @@ async def api_revoke_consommateur( async def api_distribuer(request: Request, body: dict = Body(...)): _exiger_actives() reference = (body.get("reference") or "").strip() - contexte_type = (body.get("contexte_type") or "").strip() - contexte_id = (body.get("contexte_id") or "").strip() + # Phase 3 (Spark) : le contexte peut arriver à plat + # (contexte_type / contexte_id) ou imbriqué (contexte: {type, id}, + # forme envoyée par le client Spark). Les deux formes sont acceptées. + contexte_type = str(body.get("contexte_type") or "").strip() + contexte_id_raw = body.get("contexte_id") + contexte_id = "" if contexte_id_raw is None else str(contexte_id_raw).strip() + if not contexte_type or not contexte_id: + contexte = body.get("contexte") + if isinstance(contexte, dict): + contexte_type = contexte_type or str(contexte.get("type") or "").strip() + contexte_id_raw = contexte.get("id") + contexte_id = contexte_id or ( + "" if contexte_id_raw is None else str(contexte_id_raw).strip() + ) if not contexte_type or not contexte_id: raise HTTPException(status_code=400, detail="contexte_manquant") if not reference.startswith("coffre:") or "/" not in reference[7:]: From 5492f99f062434467fcb5813278c4be35849d11f Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 27 Oct 2026 23:45:05 -0400 Subject: [PATCH 03/10] Phase 2 coffre: tests migration, restauration vierge, Spark, consommateurs --- tests/test_coffre_secrets.py | 223 +++++++++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) diff --git a/tests/test_coffre_secrets.py b/tests/test_coffre_secrets.py index 3061ed1..6d07fc6 100644 --- a/tests/test_coffre_secrets.py +++ b/tests/test_coffre_secrets.py @@ -19,6 +19,7 @@ from __future__ import annotations import base64 import json import os +import shutil from datetime import datetime, timedelta, timezone from pathlib import Path @@ -455,3 +456,225 @@ class TestDistribution: assert "secret_cree" in actions assert "distribution_ok" in actions assert "valeur" not in json.dumps(resp.json()) + + +# --------------------------------------------------------------------------- +# Phase 2 — Migration api_keys.json (issue #20) +# --------------------------------------------------------------------------- + +class TestMigrationApiKeys: + def _ecrire_api_keys(self, tmp_path): + (tmp_path / "api_keys.json").write_text( + json.dumps( + { + "DEEPSEEK_API_KEY": VALEUR_TEST, + "OBSIGATE_GITEA_TOKEN": VALEUR_TEST_2, + "OBSIGATE_GITEA_URL": "https://example.invalid", + "MISTRAL_API_KEY": "", + } + ), + encoding="utf-8", + ) + + def test_apercu_import_purge(self, client, env_coffre): + self._ecrire_api_keys(env_coffre) + resp = client.get("/api/coffres/migration/api-keys") + assert resp.status_code == 200, resp.text + # Aperçu = métadonnées seules, jamais une valeur. + assert VALEUR_TEST not in resp.text + assert VALEUR_TEST_2 not in resp.text + candidats = {c["nom"]: c for c in resp.json()["candidats"]} + assert candidats["deepseek_api_key"]["has_value"] is True + assert candidats["mistral_api_key"]["has_value"] is False + assert "obsigate_gitea_url" not in candidats # URL, pas un secret + # Import : ne supprime rien dans api_keys.json. + resp = client.post("/api/coffres/migration/api-keys/import", json={}) + assert resp.status_code == 200, resp.text + resultat = resp.json() + assert resultat["coffre"] == "obsigate-interne" + assert "deepseek_api_key" in resultat["migres"] + assert "obsigate_gitea_token" in resultat["migres"] + assert "mistral_api_key" in resultat["sans_valeur"] + cles = json.loads((env_coffre / "api_keys.json").read_text(encoding="utf-8")) + assert cles["DEEPSEEK_API_KEY"] == VALEUR_TEST + # Les secrets migrés sont visibles en métadonnées et distribuables. + resp = client.get("/api/coffres/obsigate-interne/secrets") + assert resp.status_code == 200 + assert VALEUR_TEST not in resp.text + assert any(s["nom"] == "deepseek_api_key" and s["has_value"] for s in resp.json()) + _, jeton, _ = _creer_consommateur_octroi( + client, slug="obsigate-interne", nom="deepseek_api_key" + ) + resp = _distribuer( + client, jeton, reference="coffre:obsigate-interne/deepseek_api_key" + ) + assert resp.status_code == 200, resp.text + assert resp.json()["valeur"] == VALEUR_TEST + # Purge sans confirmation : refusée, fichier intact. + resp = client.post( + "/api/coffres/migration/api-keys/purger", + json={"confirmer": False, "noms": ["deepseek_api_key"]}, + ) + assert resp.status_code == 400 + cles = json.loads((env_coffre / "api_keys.json").read_text(encoding="utf-8")) + assert "DEEPSEEK_API_KEY" in cles + # Purge confirmée : seules les clés listées disparaissent. + resp = client.post( + "/api/coffres/migration/api-keys/purger", + json={"confirmer": True, "noms": ["deepseek_api_key", "obsigate_gitea_token"]}, + ) + assert resp.status_code == 200, resp.text + assert set(resp.json()["supprimes"]) == { + "deepseek_api_key", + "obsigate_gitea_token", + } + cles = json.loads((env_coffre / "api_keys.json").read_text(encoding="utf-8")) + assert "DEEPSEEK_API_KEY" not in cles + assert "OBSIGATE_GITEA_TOKEN" not in cles + assert cles["OBSIGATE_GITEA_URL"] == "https://example.invalid" + # Audit sans valeur. + _fichiers_sans_valeur(env_coffre) + audit = (env_coffre / "audit.log").read_text(encoding="utf-8") + assert "migration_api_keys" in audit + assert "migration_api_keys_purge" in audit + + def test_import_ne_ecrase_pas_existant(self, client, env_coffre): + self._ecrire_api_keys(env_coffre) + assert client.post("/api/coffres/migration/api-keys/import", json={}).status_code == 200 + (env_coffre / "api_keys.json").write_text( + json.dumps({"DEEPSEEK_API_KEY": VALEUR_TEST_2}), encoding="utf-8" + ) + resp = client.post("/api/coffres/migration/api-keys/import", json={}) + assert resp.status_code == 200 + assert "deepseek_api_key" in resp.json()["deja_existants"] + _, jeton, _ = _creer_consommateur_octroi( + client, slug="obsigate-interne", nom="deepseek_api_key" + ) + resp = _distribuer( + client, jeton, reference="coffre:obsigate-interne/deepseek_api_key" + ) + assert resp.json()["valeur"] == VALEUR_TEST # valeur d'origine conservée + + def test_import_sans_kek_repond_503(self, client, env_coffre, monkeypatch): + # Une clé à migrer est nécessaire : sans valeur à chiffrer, + # l'import réussit trivialement même sans KEK (mode dégradé). + self._ecrire_api_keys(env_coffre) + monkeypatch.delenv("OBSIGATE_KEK", raising=False) + resp = client.post("/api/coffres/migration/api-keys/import", json={}) + assert resp.status_code == 503 + assert resp.json()["detail"] == "kek_absente" + + +# --------------------------------------------------------------------------- +# Phase 2 — Restauration sur instance vierge (issue #20) +# --------------------------------------------------------------------------- + +class TestRestaurationVierge: + def test_restauration_meme_kek_distribue(self, env_coffre, tmp_path): + coffre = create_coffre("personnel", "Personnel", "bruno") + secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST) + assert distribuer_valeur(coffre, secret) == VALEUR_TEST + # Simulation d'une instance vierge : seuls les deux fichiers sont + # copiés, la KEK (variable d'environnement) est inchangée. + restaure = tmp_path / "restaure" + restaure.mkdir() + for nom in ("coffres.json", "coffres_valeurs.json"): + shutil.copy(env_coffre / nom, restaure / nom) + coffre_r = get_coffre_or_404("personnel", restaure) + assert distribuer_valeur(coffre_r, secret, restaure) == VALEUR_TEST + for nom in ("coffres.json", "coffres_valeurs.json"): + assert VALEUR_TEST not in (restaure / nom).read_text(encoding="utf-8") + + def test_restauration_mauvaise_kek_refuse(self, env_coffre, tmp_path, monkeypatch): + coffre = create_coffre("personnel", "Personnel", "bruno") + secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST) + restaure = tmp_path / "restaure" + restaure.mkdir() + for nom in ("coffres.json", "coffres_valeurs.json"): + shutil.copy(env_coffre / nom, restaure / nom) + # Une autre KEK ne déchiffre rien : tag GCM invalide, pas de valeur. + monkeypatch.setenv("OBSIGATE_KEK", generate_kek_b64()) + coffre_r = get_coffre_or_404("personnel", restaure) + with pytest.raises(TagInvalideError): + distribuer_valeur(coffre_r, secret, restaure) + + +# --------------------------------------------------------------------------- +# Phase 3 — Contexte Spark imbriqué (issue #21) +# --------------------------------------------------------------------------- + +class TestDistributionSpark: + def test_contexte_nested_mission(self, client): + _creer_coffre_secret(client) + _, jeton, _ = _creer_consommateur_octroi(client) + resp = client.post( + "/api/coffres/distribuer", + json={ + "reference": "coffre:personnel/gitea-token", + "contexte": {"type": "mission", "id": "mission-test-2"}, + }, + headers={"Authorization": f"Bearer {jeton}"}, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["valeur"] == VALEUR_TEST + + def test_contexte_nested_sans_octroi_acces_requis(self, client): + _creer_coffre_secret(client) + resp = client.post("/api/coffres/consommateurs", json={"nom": "spark"}) + jeton = resp.json()["jeton"] + resp = client.post( + "/api/coffres/distribuer", + json={ + "reference": "coffre:personnel/gitea-token", + "contexte": {"type": "mission", "id": "mission-test-3"}, + }, + headers={"Authorization": f"Bearer {jeton}"}, + ) + # Côté Spark, ce code se traduit par l'état acces_requis. + assert resp.status_code == 403 + assert resp.json()["detail"] == "octroi_absent" + + +# --------------------------------------------------------------------------- +# Phase 4 — Consommateur + octrois en un appel (issue #22) +# --------------------------------------------------------------------------- + +class TestConsommateurAvecOctrois: + def test_avec_octrois_distribue(self, client, env_coffre): + _creer_coffre_secret(client) + resp = client.post( + "/api/coffres/consommateurs/avec-octrois", + json={ + "nom": "tiktak", + "octrois": [{"coffre_slug": "personnel", "secret_nom": "gitea-token"}], + }, + ) + assert resp.status_code == 200, resp.text + corps = resp.json() + assert corps["nom"] == "tiktak" + assert corps["jeton"].startswith("csk_") + assert len(corps["octrois"]) == 1 + assert corps["erreurs"] == [] + resp = _distribuer(client, corps["jeton"]) + assert resp.status_code == 200 + assert resp.json()["valeur"] == VALEUR_TEST + liste = client.get("/api/coffres/consommateurs").json() + assert corps["jeton"] not in json.dumps(liste) + assert "jeton_hash" not in json.dumps(liste) + + def test_avec_octrois_erreur_signalee_sans_annuler(self, client): + _creer_coffre_secret(client) + resp = client.post( + "/api/coffres/consommateurs/avec-octrois", + json={ + "nom": "flowdeck", + "octrois": [ + {"coffre_slug": "personnel", "secret_nom": "gitea-token"}, + {"coffre_slug": "inexistant", "secret_nom": "gitea-token"}, + ], + }, + ) + assert resp.status_code == 200, resp.text + assert len(resp.json()["octrois"]) == 1 + assert len(resp.json()["erreurs"]) == 1 + assert resp.json()["erreurs"][0]["coffre_slug"] == "inexistant" From 3acadf6ad7133f93cb839e86a1f452c346d4f178 Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 27 Oct 2026 23:45:10 -0400 Subject: [PATCH 04/10] Phase 2 coffre: bouton Importer l'existant (import puis purge confirmee) --- frontend/js/coffres.js | 88 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/frontend/js/coffres.js b/frontend/js/coffres.js index 8d57b89..1334d33 100644 --- a/frontend/js/coffres.js +++ b/frontend/js/coffres.js @@ -18,6 +18,11 @@ * _copieManuelle) passe par un consommateur temporaire créé puis révoqué * immédiatement, avec distribution auditée ; il n'existe pas encore * d'endpoint de distribution par session utilisateur côté backend. + * - Phase 2 (issue #20) : « Importer l'existant » migre les clés de + * data/api_keys.json vers le coffre d'instance « ObsiGate interne », + * en deux temps (import, puis purge confirmée). Aucune valeur ne + * transite jamais par cette vue : l'aperçu et les résultats ne + * contiennent que des noms et des comptes (has_value). * - La vue est rendue directement dans #content-area : elle ne passe ni * par TabManager.openNav (navigation de fichiers), ni par l'indexeur, * ni par la recherche, ni par le graphe. @@ -865,6 +870,83 @@ async function copieManuelle(s) { } } +/** Phase 2 — « Importer l'existant » (admin seulement, deux temps). + * 1. Aperçu (métadonnées seules, jamais de valeur) puis import vers le + * coffre d'instance. L'import ne supprime rien dans api_keys.json. + * 2. Purge du clair, uniquement après confirmation explicite, et + * uniquement pour les noms effectivement migrés. L'avertissement est + * affiché avant l'import : les lecteurs actuels (IA, outils) lisent + * encore api_keys.json, la purge les prive donc de la clé tant que + * leur lecteur n'est pas branché sur le coffre (voir la documentation + * de sauvegarde/restauration). */ +async function importerExistant() { + if (!isAdmin()) return; + let apercu; + try { + apercu = await api('/api/coffres/migration/api-keys'); + } catch (e) { + showToast(e.message || t('common.error'), 'error'); + return; + } + const candidats = apercu.candidats || []; + const lignes = candidats.map((c) => { + const etat = c.deja_existant + ? t('coffres.import_already_exists') + : (c.has_value ? t('coffres.import_has_value') : t('coffres.import_no_value')); + return `${c.nom} (${c.service}) — ${etat}`; + }); + const body = el('div', {}, [ + text('p', t('coffres.import_warning'), 'modal-hint'), + ...lignes.map((ligne) => text('p', ligne, '')), + ]); + const m = openModal(t('coffres.import_title'), body, [ + modalBtn(t('common.cancel'), '', () => m.close()), + modalBtn(t('coffres.import_existing'), 'primary', async () => { + let resultat; + try { + resultat = await api('/api/coffres/migration/api-keys/import', { method: 'POST', body: '{}' }); + } catch (e) { + showToast(e.message || t('common.error'), 'error'); + return; + } + m.close(); + const migres = resultat.migres || []; + const resume = [ + `${t('coffres.import_done')} — ${t('coffres.import_migrated')}: ${migres.length}, ${t('coffres.import_already_exists')}: ${(resultat.deja_existants || []).length}, ${t('coffres.import_no_value')}: ${(resultat.sans_valeur || []).length}`, + ...migres.map((nom) => `${nom} — ${t('coffres.import_migrated')}`), + ]; + const rb = el('div', {}, resume.map((ligne) => text('p', ligne, ''))); + const boutons = [modalBtn(t('common.close'), 'primary', async () => { + rm.close(); + await openCoffresView('obsigate-interne'); + })]; + if (migres.length) { + boutons.unshift(modalBtn(t('coffres.import_purge'), 'danger', async () => { + const ok = await showConfirm({ + title: t('coffres.import_purge'), + message: `${t('coffres.import_purge_confirm')} ${migres.join(', ')}`, + danger: true, + }); + if (!ok) return; + try { + const purge = await api('/api/coffres/migration/api-keys/purger', { + method: 'POST', + body: JSON.stringify({ confirmer: true, noms: migres }), + }); + showToast(`${t('coffres.import_purged')} (${(purge.supprimes || []).length})`, 'success'); + } catch (e) { + showToast(e.message || t('common.error'), 'error'); + } + rm.close(); + await openCoffresView('obsigate-interne'); + })); + } + const rm = openModal(t('coffres.import_title'), rb, boutons); + void rm; + }), + ]); +} + function openKekModal(mode) { if (!isAdmin()) return; if (mode === 'generer') { @@ -1002,6 +1084,12 @@ export async function openCoffresView(slug, options = {}) { }); toolbar.appendChild(search); toolbar.appendChild(modalBtn(t('coffres.new_secret'), 'primary', () => openCreateSecretModal(coffre.slug))); + // Phase 2 : migration api_keys.json -> coffre d'instance (admin seulement). + // Le bouton est visible quel que soit le coffre affiché : la cible est + // toujours le coffre d'instance « ObsiGate interne ». + if (isAdmin()) { + toolbar.appendChild(modalBtn(t('coffres.import_existing'), '', () => importerExistant())); + } toolbar.appendChild(modalBtn(t('coffres.dek_rotate'), '', async () => { const ok = await showConfirm({ title: t('coffres.dek_rotate'), message: t('coffres.confirm_dek_rotate'), danger: true }); if (!ok) return; From 7d48f65ff9a3367ec2991e5ac12b6b85326918c1 Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 27 Oct 2026 23:45:13 -0400 Subject: [PATCH 05/10] Phase 2 coffre: documentation sauvegarde/restauration KEK --- docs/COFFRE-sauvegarde-restauration.md | 155 +++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 docs/COFFRE-sauvegarde-restauration.md diff --git a/docs/COFFRE-sauvegarde-restauration.md b/docs/COFFRE-sauvegarde-restauration.md new file mode 100644 index 0000000..318519d --- /dev/null +++ b/docs/COFFRE-sauvegarde-restauration.md @@ -0,0 +1,155 @@ +# Coffre Secrets ObsiGate — Sauvegarde et restauration (Phase 2, issue #20) + +Ce document décrit la seule procédure supportée pour sauvegarder et +restaurer le coffre Secrets, ainsi que le cas de perte de la KEK. +Il complète la spécification du coffre (§13, §14) ; il ne décrit +aucune valeur réelle — les exemples utilisent des valeurs fictives. + +## 1. Ce qui constitue une sauvegarde + +Deux fichiers, et seulement ces deux fichiers, portent le coffre : + +| Fichier | Contenu | +| --- | --- | +| `data/coffres.json` | Coffres (métadonnées + DEK chiffrées), consommateurs (hashes de jetons seulement), octrois, métadonnées des secrets. | +| `data/coffres_valeurs.json` | Versions chiffrées des secrets (AES-256-GCM). | + +Ces deux fichiers forment une **sauvegarde ordinaire chiffrée** : +copiés seuls, ils ne donnent accès à aucune valeur — chaque DEK est +chiffrée par la KEK, chaque valeur est chiffrée par la DEK de son +coffre. Ils peuvent donc transiter par la sauvegarde habituelle du +serveur, avec les mêmes permissions restrictives que sur l'instance +(0600). Ils ne doivent jamais être déposés dans le dépôt Git, ni dans +une note ObsiGate, ni dans un journal. + +La **KEK ne fait pas partie de la sauvegarde** : elle est copiée à +part, hors serveur (voir §2). Sauvegarder les deux fichiers et la KEK +au même endroit annulerait la protection du chiffrement. + +## 2. Sauvegarder la KEK (copie externe, hors serveur) + +La KEK est une clé maître 256 bits (base64 de 32 octets), générée une +seule fois via `POST /api/coffres/admin/generer-kek` (admin, coffres +activés) et **jamais stockée par ObsiGate**. Au moment de sa +génération : + +1. Copiez-la immédiatement dans un gestionnaire de mots de passe ou + sur un support hors ligne, hors du serveur ObsiGate. +2. Ne la collez jamais dans le dépôt, jamais dans une note ObsiGate, + jamais dans le même dossier que `data/`, jamais dans un journal. +3. Vérifiez la copie (relisez-la depuis le gestionnaire, pas depuis + l'écran qui ne l'affichera plus jamais). + +Variables d'environnement (documentées aussi dans `.env.example`, +sans aucune valeur) : + +| Variable | Rôle | Défaut | +| --- | --- | --- | +| `OBSIGATE_SECRETS_ENABLED` | Activation du coffre. Toute route répond 503 tant qu'elle n'est pas activée. | désactivé | +| `OBSIGATE_KEK` | KEK, base64 de 32 octets (ou hex 64 caractères). Prioritaire sur le fichier. | absente | +| `OBSIGATE_KEK_FILE` | Chemin d'un fichier 0600, hors dépôt et hors arborescence des vaults, contenant la KEK. Un fichier trop permissif est refusé. | absent | +| `OBSIGATE_DATA_DIR` | Répertoire des données (`data/` par défaut), donc des deux fichiers du coffre et de `api_keys.json`. | `data` | + +**Aucune autre variable ne doit contenir une valeur de secret du +coffre.** En particulier, ne créez jamais de variable du type +`COFFRE_` avec une valeur : la seule valeur externe autorisée est +la KEK, et elle n'est pas une valeur de secret du coffre. + +## 3. Restauration sur une instance (y compris vierge) + +Prérequis : une instance ObsiGate avec le coffre activé +(`OBSIGATE_SECRETS_ENABLED`), et la **même KEK** que l'instance +d'origine. + +1. Arrêtez l'instance cible (ou, à défaut, assurez-vous qu'aucune + écriture n'a lieu pendant la copie). +2. Restaurez les deux fichiers `coffres.json` et + `coffres_valeurs.json` dans le répertoire de données cible, en + permissions 0600. +3. Re-présentez la même KEK, via `OBSIGATE_KEK` ou via + `OBSIGATE_KEK_FILE` (fichier 0600, hors dépôt). +4. Redémarrez l'instance. +5. Vérifiez, sans jamais afficher ni journaliser de valeur : + - la vue Secrets liste les coffres et les secrets avec + `has_value = true` ; + - une distribution de test via un consommateur existant (ou un + consommateur de test, révoqué ensuite) aboutit — côté serveur, + le succès seul est vérifié, la valeur n'est ni affichée ni + consignée ; + - le journal d'audit contient `distribution_ok` pour cette + référence. + +**Test de restauration obligatoire avant la mise en service réelle +(issue #20)** : la procédure ci-dessus a été automatisée dans la +suite de tests (`tests/test_coffre_secrets.py`, classe +`TestRestaurationVierge`) — copie des deux fichiers vers un +répertoire vierge, même KEK, distributions fonctionnelles — et doit +aussi être rejouée une fois sur une instance réelle par l'opérateur +avant de considérer la sauvegarde comme valide. + +## 4. Perte de la KEK : perte d'accès assumée, par construction + +Si la KEK est perdue, **il n'existe aucun moyen de récupérer les +valeurs** : les DEK ne sont stockées que chiffrées par la KEK, et les +valeurs ne sont stockées que chiffrées par les DEK (AES-256-GCM, +authentifié). Il n'y a ni clé de secours, ni dérivation depuis le +secret d'application, ni porte dérobée. + +Procédure en cas de perte : + +1. Considérez toutes les valeurs du coffre comme perdues. +2. Créez de nouveaux coffres avec une nouvelle KEK. +3. Pour chaque secret perdu, régénérez la valeur **côté fournisseur** + (nouvelle clé API, nouveau jeton) et créez un nouveau secret avec + cette nouvelle valeur ; révoquez les anciennes valeurs côté + fournisseur, car elles restent valides hors d'ObsiGate tant qu'elles + ne sont pas révoquées là-bas. +4. Recréez les consommateurs et les octrois (les anciens octrois + pointent vers des secrets qui n'existent plus dans les nouveaux + coffres). +5. Ne tentez pas de « réparer » les anciens fichiers avec une nouvelle + KEK : le déchiffrement échouera (tag GCM invalide), ce qui est le + comportement attendu, pas un bug. + +La rotation KEK (`POST /api/coffres/admin/rotation-kek`) n'est pas +une récupération : elle exige l'ancienne KEK encore valide pour +re-chiffrer les DEK, puis le remplacement de la KEK dans +l'environnement ou le fichier KEK. + +## 5. Migration `api_keys.json` (Phase 2) — avertissement important + +Le bouton « Importer l'existant » de la vue Secrets (admin seulement) +copie les clés IA et clés d'outils de `data/api_keys.json` vers le +coffre d'instance « ObsiGate interne » (slug `obsigate-interne`), +en deux temps : + +1. **Import** : les entrées avec valeur sont chiffrées dans le coffre + (un secret déjà présent n'est jamais écrasé) ; `api_keys.json` + n'est pas modifié. +2. **Purge** : après confirmation explicite, et uniquement pour les + noms migrés, les entrées sont supprimées du clair. Sans + confirmation, rien n'est supprimé — jamais automatiquement. + +**Avertissement** : les lecteurs actuels d'ObsiGate +(`backend/ai.py`, `backend/tools/secrets.py`) lisent encore +`api_keys.json` directement ; ils ne sont pas encore branchés sur le +coffre. Purger le clair prive donc ces lecteurs de la clé tant que +leur lecteur n'est pas branché sur une distribution depuis le coffre. +Ne purgez que si vous avez vérifié que le consommateur concerné +distribue depuis le coffre, ou si vous acceptez cette interruption. +Ce branchement des lecteurs internes est un reste à faire explicite +(voir §6). + +## 6. Restes à faire (après cette Phase 2) + +- Brancher les lecteurs internes (`ai.py`, `tools/secrets.py`) sur + une distribution depuis le coffre d'instance, pour que la purge du + clair ne coupe pas l'IA ni les outils. +- Rejouer le test de restauration sur une instance réelle (la suite + automatisée simule une instance vierge, elle ne remplace pas un + essai sur l'instance de production). +- Endpoint de distribution par session utilisateur (copie manuelle + sans consommateur temporaire) : non ajouté ici, car il contredirait + la règle Phase 1b (distribution réservée aux consommateurs) et + exigerait une ré-authentification qui n'existe pas encore — décision + à prendre avec l'opérateur avant toute implémentation. From 62aa6079be6a15704dd6e8a2934218f26177e7ca Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 27 Oct 2026 23:45:16 -0400 Subject: [PATCH 06/10] Phases 3-4 coffre: contrat de distribution Spark et consommateurs --- docs/COFFRE-contrat-distribution.md | 156 ++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 docs/COFFRE-contrat-distribution.md diff --git a/docs/COFFRE-contrat-distribution.md b/docs/COFFRE-contrat-distribution.md new file mode 100644 index 0000000..8a809f0 --- /dev/null +++ b/docs/COFFRE-contrat-distribution.md @@ -0,0 +1,156 @@ +# Coffre Secrets ObsiGate — Contrat de distribution (Phases 3 et 4) + +Référence du contrat pour les clients serveur-à-serveur : Spark +(issue #21), TikTak, Flowdeck, Kanbante (issue #22). Ce document ne +duplique aucune logique de chiffrement : le chiffrement reste +entièrement côté ObsiGate (enveloppe KEK → DEK → secret, AES-256-GCM). + +## 1. Endpoint + +``` +POST /api/coffres/distribuer +Authorization: Bearer +Content-Type: application/json +``` + +Corps (forme à plat, Phase 1b) : + +```json +{ + "reference": "coffre:/", + "contexte_type": "mission", + "contexte_id": "mission-123" +} +``` + +Forme imbriquée acceptée depuis la Phase 2 (client Spark) : + +```json +{ + "reference": "coffre:/", + "contexte": { "type": "mission", "id": "mission-123" } +} +``` + +La forme à plat reste supportée ; si les deux formes sont présentes, +la forme à plat est prioritaire. Le contexte est **obligatoire** : +sans `contexte_type`/`contexte_id` (ou sans `contexte.type`/`id`), +la requête est refusée avant toute authentification utile. + +Réponse en succès (200) : + +```json +{ "reference": "coffre:/", "valeur": "", "version": 1 } +``` + +La valeur n'existe qu'en mémoire chez l'appelant : elle ne doit +jamais être écrite dans un fichier, un journal, un prompt, une étape +d'activité ou un livrable. Le patron imposé à chaque app est +**distribuer → utiliser → oublier** ; à la fin de l'opération +(fin de mission, fin de synchronisation), l'environnement qui la +contenait est détruit et rien ne persiste. + +Prérequis côté ObsiGate : coffres activés +(`OBSIGATE_SECRETS_ENABLED`, sinon 503 `secrets_desactives`) et KEK +configurée (sinon 503 `kek_absente`). + +## 2. Authentification : consommateur nommé, jeton propre + +- Un consommateur est créé par un admin : + `POST /api/coffres/consommateurs` (`{"nom": "spark"}`), ou en une + seule opération avec ses octrois : + `POST /api/coffres/consommateurs/avec-octrois` + (`{"nom": "tiktak", "octrois": [{"coffre_slug": "...", + "secret_nom": "..."}]}` ; `secret_nom` absent = tout le coffre). +- Le jeton (`csk_…`) n'est rendu **qu'une seule fois**, à la création. + Il n'est jamais stocké en clair (seul son hash SHA-256 est persisté) + et jamais relisté (`GET /api/coffres/consommateurs` ne renvoie que + `id`, `nom`, `jeton_prefixe`, `statut`, `cree_par`, `cree_le`). +- L'`id` du consommateur sert de `jti` révocable : + `DELETE /api/coffres/consommateurs/{id}` révoque immédiatement + toutes ses distributions, sans toucher aux autres consommateurs. +- Ce jeton est **distinct** du jeton MCP de notes et de la session + SSO : une session utilisateur, même admin, ne distribue jamais + (401 `consommateur_invalide`). Il n'existe pas d'endpoint de + distribution par session (voir §6). +- Ajouter une app = enregistrer un consommateur + créer des octrois, + **sans modification du coffre** et sans toucher à ses secrets. + +## 3. Cas Spark (issue #21) + +- Consommateur nommé `spark` (convention, non imposée par le code : + l'autorisation porte sur le jeton, pas sur le nom). +- Contexte : `contexte_type = "mission"`, `contexte_id` = identifiant + de la mission. La forme imbriquée + `contexte: {type: "mission", id: }` est acceptée. +- Flux côté agent-commun (hors ObsiGate, rappelé ici pour la + correspondance des erreurs) : le LLM ne voit que la référence + `coffre:/` et un accès oui/non, jamais la valeur. Sans + octroi, la mission passe en `acces_requis` en nommant l'accès + manquant. Avec octroi et mission approuvée (enveloppe budgétaire), + le service Spark appelle `/distribuer` et injecte la valeur en + variable d'environnement de l'exécuteur, jamais dans le prompt. + Fin de mission = environnement détruit. +- Révocation dans ObsiGate (consommateur ou octroi) : la mission + suivante repasse en `acces_requis`. Limite assumée : une mission + déjà démarrée conserve son environnement jusqu'à sa fin. + +## 4. Erreurs (codes `detail`, stables depuis la Phase 1b) + +| HTTP | `detail` | Sens | Correspondance Spark | +| --- | --- | --- | --- | +| 400 | `contexte_manquant` | Contexte absent ou vide. | Erreur d'appel, pas un état d'accès. | +| 400 | `reference_invalide` | Référence mal formée ou nom invalide. | Erreur d'appel. | +| 401 | `consommateur_invalide` | Jeton inconnu (ni MCP, ni session). | Configurer le jeton consommateur. | +| 403 | `consommateur_revoque` | Consommateur révoqué. | `acces_requis` (ré-accorder). | +| 403 | `octroi_absent` | Aucun octroi (ou secret/coffre inexistant — anti-énumération, volontairement indistinct). | `acces_requis` (demander l'octroi). | +| 403 | `octroi_revoque` | Octroi révoqué. | `acces_requis` (ré-accorder). | +| 403 | `octroi_expire` | Octroi expiré (défaut 90 jours à la création). | `acces_requis` (renouveler). | +| 410 | `secret_expire` | Secret expiré, même avec un octroi actif. | Rotation du secret côté ObsiGate. | +| 503 | `secrets_desactives` / `kek_absente` / `crypto_indisponible` | Coffre désactivé, KEK absente, ou bibliothèque absente. | Panne ObsiGate, pas un refus d'accès. | + +Un octroi coffre-wide (`secret_id` nul) couvre tous les secrets du +coffre ; un octroi par secret ne couvre que ce secret. Un octroi +révoqué ne doit jamais être masqué par l'absence d'un autre octroi +(priorité : ok > révoqué > expiré > absent). + +## 5. Audit + +Chaque distribution est auditée (`distribution_ok` ou +`distribution_refusee`, avec référence, consommateur, contexte et +code de résultat). **Aucune entrée d'audit ne contient jamais une +valeur, un texte chiffré, un nonce ou un jeton.** La route journal +(`GET /api/coffres/{slug}/journal`) filtre en défense en profondeur +ces champs même s'ils apparaissaient un jour dans une entrée. +La copie manuelle à usage unique (admin, via consommateur temporaire +créé puis révoqué immédiatement, valeur copiée au presse-papiers +sans affichage) est auditée comme une distribution de contexte +`copie_manuelle`, avec la trace distincte de ce contexte. + +## 6. Limites et restes à faire (Phases 3–4, côté ObsiGate) + +- **Isolation workspace Flowdeck** : le type de cible `workspace` + existe pour les octrois, mais `/distribuer` n'autorise aujourd'hui + que les octrois de type `consommateur`. L'isolation « un workspace + ne peut pas utiliser l'octroi d'un autre » repose donc, pour + l'instant, sur le serveur Flowdeck lui-même (il ne demande que les + références de son workspace, avec son propre jeton consommateur). + Un contrôle serveur strict (contexte workspace vérifié côté + ObsiGate contre un octroi `workspace`) reste à spécifier et à + implémenter si Bruno l'exige — il changerait la sémantique + d'autorisation de `/distribuer` et doit être coordonné avec les + apps clientes avant tout changement. +- **Distribution par session** : non implémentée, volontairement. + Elle contredirait la règle Phase 1b (distribution réservée aux + consommateurs) et exigerait une ré-authentification qui n'existe + pas encore dans ObsiGate. La copie manuelle reste le patron + supporté (consommateur temporaire, admin seulement). +- **Clients par app** (TikTak, Flowdeck, Kanbante, agent-commun) : + à implémenter dans chaque dépôt, en branche + PR, jamais mergés + sans go. Patron commun : le serveur distribue, utilise, oublie ; + aucune valeur en cache ; si ObsiGate est indisponible, l'opération + échoue proprement, sans repli sur une ancienne valeur. +- **Lecteurs internes ObsiGate** (`ai.py`, `tools/secrets.py`) : + toujours branchés sur `api_keys.json` en clair (voir le document + de sauvegarde/restauration, §5) ; leur branchement sur le coffre + est un travail séparé de la présente Phase 2. From 53d98bfffeb5398457ab5afeb866bab488033800 Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 27 Oct 2026 23:45:20 -0400 Subject: [PATCH 07/10] Phase 2 coffre: variables OBSIGATE_SECRETS_ENABLED/KEK documentees (sans valeur) --- .env.example | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.env.example b/.env.example index 485b65f..2087871 100644 --- a/.env.example +++ b/.env.example @@ -132,3 +132,13 @@ DEEPSEEK_MODEL=deepseek-chat # DRACO_SESSION_SECRET_PREV= # DRACO_SESSION_COOKIE_DOMAIN=.dracodev.net # DRACO_SSO_MACHINE_KEY= + +# ── Coffre Secrets ObsiGate (Phases 1–2, voir docs/COFFRE-sauvegarde-restauration.md) ── +# Activation (défaut : désactivé — toutes les routes répondent 503 sinon) : +# OBSIGATE_SECRETS_ENABLED=true +# Clé maître 256 bits, base64 de 32 octets, rendue une seule fois par +# POST /api/coffres/admin/generer-kek ; à sauvegarder HORS dépôt, HORS note : +# OBSIGATE_KEK= +# … ou fichier 0600, hors dépôt et hors arborescence des vaults : +# OBSIGATE_KEK_FILE=/chemin/hors/depot/kek.txt +# Aucune autre variable ne doit contenir une valeur de secret du coffre. From effad9747255c48a1b97b74d12d455c6c29d44bd Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 27 Oct 2026 23:45:33 -0400 Subject: [PATCH 08/10] Phase 2 coffre: cles i18n import (fr) --- frontend/locales/fr.json | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 6a0a4dc..8968e75 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -2757,5 +2757,16 @@ "coffres.type": "Type", "coffres.value": "Valeur", "coffres.value_required": "Une valeur est requise.", - "coffres.version": "Version" + "coffres.version": "Version", + "coffres.import_existing": "Importer l'existant", + "coffres.import_title": "Importer les clés existantes", + "coffres.import_has_value": "Valeur présente", + "coffres.import_no_value": "Aucune valeur", + "coffres.import_already_exists": "Déjà dans le coffre", + "coffres.import_migrated": "Migré", + "coffres.import_warning": "Les clés IA et clés d'outils de data/api_keys.json seront copiées dans le coffre d'instance « ObsiGate interne ». L'import ne supprime rien. La purge du clair est une étape séparée, à confirmer explicitement — attention : les lecteurs actuels (IA, outils) lisent encore api_keys.json, la purge les prive donc de la clé tant que leur lecteur n'est pas branché sur le coffre.", + "coffres.import_done": "Import terminé", + "coffres.import_purge": "Supprimer le clair", + "coffres.import_purge_confirm": "Supprimer définitivement du clair les clés migrées suivantes ? Cette action est irréversible sans restauration du fichier :", + "coffres.import_purged": "Clair supprimé" } From 0fc7654f6ca21c6aed0039011baa5e852dd8b853 Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 27 Oct 2026 23:45:38 -0400 Subject: [PATCH 09/10] Phase 2 coffre: cles i18n import (en) --- frontend/locales/en.json | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 841d55f..e4958ff 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -2757,5 +2757,16 @@ "coffres.type": "Type", "coffres.value": "Value", "coffres.value_required": "A value is required.", - "coffres.version": "Version" + "coffres.version": "Version", + "coffres.import_existing": "Import existing", + "coffres.import_title": "Import existing keys", + "coffres.import_has_value": "Value present", + "coffres.import_no_value": "No value", + "coffres.import_already_exists": "Already in the vault", + "coffres.import_migrated": "Migrated", + "coffres.import_warning": "The AI and tool keys from data/api_keys.json will be copied into the instance vault “ObsiGate interne”. Import deletes nothing. Clearing the plaintext is a separate step, to confirm explicitly — note: the current readers (AI, tools) still read api_keys.json, so clearing deprives them of the key until their reader is wired to the vault.", + "coffres.import_done": "Import finished", + "coffres.import_purge": "Delete plaintext", + "coffres.import_purge_confirm": "Permanently delete the following migrated keys from plaintext? This cannot be undone without restoring the file:", + "coffres.import_purged": "Plaintext deleted" } From b42265b41e480b73ce17b74b58f2cd13a214ce8b Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 27 Oct 2026 23:45:44 -0400 Subject: [PATCH 10/10] Phase 2 coffre: correctif modale (max-height viewport + scroll interne) --- frontend/style.css | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/frontend/style.css b/frontend/style.css index 62bb864..3118e34 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -7700,6 +7700,8 @@ body.popup-mode .content-area { display: flex; align-items: center; justify-content: center; + padding: 16px; + box-sizing: border-box; opacity: 0; pointer-events: none; transition: opacity 200ms ease; @@ -7715,8 +7717,16 @@ body.popup-mode .content-area { border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5); - min-width: 400px; - max-width: 600px; + /* Correctif Phase 2 (modale de création de secret coupée en haut) : + sans max-height ni scroll interne, un formulaire long dépassait le + viewport. La modale est désormais bornée au viewport, en colonne + flex, avec seul le corps qui défile ; l'en-tête et le pied restent + toujours visibles. Sur petit écran, la largeur est bornée aussi. */ + min-width: min(400px, calc(100vw - 32px)); + max-width: min(600px, calc(100vw - 32px)); + max-height: calc(100dvh - 32px); + display: flex; + flex-direction: column; transform: scale(0.9); transition: transform 200ms ease; } @@ -7731,6 +7741,7 @@ body.popup-mode .content-area { display: flex; align-items: center; justify-content: space-between; + flex-shrink: 0; } .obsigate-modal-title { @@ -7742,6 +7753,8 @@ body.popup-mode .content-area { .obsigate-modal-body { padding: 20px; + overflow-y: auto; + min-height: 0; } .obsigate-modal-footer { @@ -7750,6 +7763,7 @@ body.popup-mode .content-area { display: flex; gap: 10px; justify-content: flex-end; + flex-shrink: 0; } .modal-input {