Secrets management — central source of truth

Objectif : une gestion propre et durable de TOUS les secrets de l’infrastructure GenAI / CoursIA, pour que le drift (un secret édité à un endroit mais pas ailleurs) soit mécaniquement impossible.

Architecture — .secrets/master.env + render

.secrets/master.env                    <- SOURCE UNIQUE (éditer ici)
   HF_TOKEN, OPENAI_API_KEY, COMFYUI_VIDEO_TOKEN, QDRANT_API_KEY, ... (19 clés partagées)

scripts/secrets/render_envs.py
   --bootstrap   one-shot : lit les .env éparpillés, écrit master.env
   (défaut)      sync     : propage master.env -> chaque .env (15 cibles)
   --check       gate     : exit 1 si un .env drift de master (audit local)

docker-configurations/services/*/.env  <- CONFIG service (ports, paths, GPU)
MyIA.AI.Notebooks/GenAI/.env           <- CONFIG notebooks + secrets synchronisés

Principe : master.env ne contient que les secrets partagés (un consommateur ≠, une valeur commune). La CONFIG service-spécifique (ports, chemins, GPU ids, noms de modèles, TZ) reste dans chaque .env et n’est jamais touchée par le render. Les mots de passe par instance (chaque ComfyUI / Forge a le sien) ne sont PAS centralisés — voir §“Secrets par instance”.

Rotation d’un secret (procédure canonique)

# 1. Éditer la source unique
$EDITOR .secrets/master.env          # ex: changer HF_TOKEN

# 2. Propager vers tous les .env consommateurs
python scripts/secrets/render_envs.py

# 3. Redémarrer les containers impactés (OBLIGATOIRE pour ComfyUI-Login,
#    voir §"Règle du restart ComfyUI" ci-dessous)
docker compose -f docker-configurations/services/<svc>/docker-compose.yml restart

Le render est idempotent : re-lancer ne change rien si déjà synchronisé.

Audit / détection de drift

python scripts/secrets/render_envs.py --check
# exit 0 = tous les .env synchronisés avec master
# exit 1 = drift détéré (un .env a été édité à la main), affiche chaque écart

--check compare chaque clé secrète de chaque .env à master.env. À lancer après toute édition manuelle d’un .env, ou périodiquement. Note : c’est un outil local (les .env sont gitignored, donc absents d’un checkout CI frais) — le scanner d’inline-literals côté code committé reste gitleaks CI (cf secrets-hygiene.md).

Inventaire des secrets centralisés (master.env)

Catégorie Clés Consommateurs Sensible rotation
Hugging Face (aliasé) HF_TOKEN = HUGGINGFACE_TOKEN 5 services + notebooks Oui (gated/quotas)
LLM APIs payantes OPENAI_API_KEY, ANTHROPIC_API_KEY, OPENROUTER_API_KEY notebooks Oui (facturation)
Hubs / git CIVITAI_TOKEN, GITHUB_TOKEN = GITHUB_ACCESS_TOKEN services + notebooks Oui
Client API keys (server↔︎client) WHISPER_API_KEY, VLLM_API_KEY, TTS_API_KEY, QWEN_ASR_API_KEY, MUSICGEN_API_KEY, DEMUCS_API_KEY, FUNASR_API_KEY 1 service + notebooks Moyen
Qdrant vector DB (client) QDRANT_API_KEY notebooks RAG / SemanticKernel / Argument_Analysis Moyen (flip serveur = op inter-repo roo-extensions)
Tokens client ComfyUI COMFYUI_VIDEO_TOKEN, COMFYUI_API_TOKEN = COMFYUI_AUTH_TOKEN notebooks → services ComfyUI Moyen
Session SECRET_KEY 1 service Oui

Alias : HF_TOKEN/HUGGINGFACE_TOKEN, GITHUB_TOKEN/GITHUB_ACCESS_TOKEN et COMFYUI_API_TOKEN/COMFYUI_AUTH_TOKEN désignent le MÊME secret sous deux noms (services vs notebooks). Le bootstrap vérifie leur cohérence (abort si divergence).

L’alias ne fabrique PAS le nom manquant — les deux doivent être écrits dans master.env. ALIASES (render_envs.py) déclare la paire ; il ne dérive pas HF_TOKEN d’un HUGGINGFACE_TOKEN présent seul. Un master.env qui ne porte qu’un des deux noms laisse l’autre dans la liste declared SECRET keys are absent from master.env (left untouched in services) de --check, et aucun consommateur ne le reçoit — alors que le token existe et est valide. Mesuré le 2026-08-15 : HUGGINGFACE_TOKEN présent + HF_TOKEN absent, pour 655 sites de code lisant HF_TOKEN contre 144 lisant HUGGINGFACE_TOKEN. Remède : écrire les deux lignes dans master.env, puis render_envs.py.

sync() rafraîchit, il n’insère pas. Une clé absente d’un .env cible n’y est pas ajoutée par le render : seules les clés déjà déclarées y sont réécrites depuis master (le gap-fill n’existe que pour un service sans .env, à partir des ${KEY} de sa compose). Pour un .env côté notebooks (pas de compose), déclarer la ligne vide CLE= puis lancer le render.

Un 403 sur un repo gated n’est pas un défaut de token. Discriminer avant d’escalader — repo non-gated avec token → 206 (le token lit) ; repo gated sans token → 401 ; repo gated avec token → 403 = authentifié mais non autorisé, c’est-à-dire licence non acceptée par le compte porteur ou scope « read gated repos » absent du token fine-grained. Les deux se règlent sur huggingface.co, pas dans master.env.

ComfyUI-Login (comfyui-qwen) — source unique du credential API (#14382)

Le middleware ComfyUI-Login valide le bearer littéralement contre le fichier bind-mounté .secrets/qwen-api-user.token (60 caractères, forme $2b$ bcrypt — le hash est le token partagé). Avant #14382, le compose interpolait des noms que render_envs.py ne gérait pas (COMFYUI_BEARER_TOKEN, COMFYUI_RAW_TOKEN) : sur un .env rendu par le seul render, ils résolvent vide (mesure #14382) ; là où auth_manager.py les avait écrits, ce sont des copies non gérées qui dérivent à la prochaine rotation (pattern incident #6901). Le sidecar idle-monitor recevait en outre COMFYUI_RAW_TOKEN (le mot de passe formulaire, pas le bearer) → ses polls d’activité prenaient 401.

Le câblage depuis #14382 ne fait plus dériver — et ne passe jamais par l’interpolation d’env du compose : docker compose interprète les $ des valeurs (le bcrypt $2b$12$Iv… devient un token tronqué 60c → 57c, mesuré en conteneur). Le credential passe par le fichier (les fichiers bind-mountés ne sont pas interpolés) :

Consommateur Voie
middleware ComfyUI-Login (entrypoint.sh) fichier bind-mounté .secrets/qwen-api-user.token (déjà le chemin privilégié)
workspace/install_comfyui.sh → ComfyUI-Login/PASSWORD cp du fichier monté (l’echo de l’env compose écrivait un token tronqué)
sidecar idle-monitor (poll /system_stats) fichier bind-mounté /secrets/qwen-api-user.token, lu en fallback par comfyui_idle_monitor.py
notebooks GenAI (os.getenv("COMFYUI_AUTH_TOKEN") or os.getenv("COMFYUI_API_TOKEN"), flip #16) GenAI/.env, les deux noms gérés master (alias COMFYUI_AUTH_TOKEN = COMFYUI_API_TOKEN)

Rotation du credential comfyui-qwen : éditer COMFYUI_API_TOKEN et COMFYUI_AUTH_TOKEN dans master.env (même valeur), régénérer .secrets/qwen-api-user.token avec cette valeur, render_envs.py, puis recréer le container (docker compose up -d — un restart ne relit ni les montages ni l’env d’interpolation ; cf règle du restart ci-dessous pour COMFYUI_PASSWORD).

COMFYUI_RAW_TOKEN (le mot de passe en clair avant hash, utile seulement au login formulaire COMFYUI_USERNAME/COMFYUI_PASSWORD) ne transite plus par aucun .env géré — la forme brute ne doit pas vivre dans les .env (seule la forme hashée y circule). auth_manager.py n’écrit plus COMFYUI_BEARER_TOKEN/COMFYUI_RAW_TOKEN dans les .env.

COMFYUI_BEARER_TOKEN est un nom retiré (pré-#14382). Il ne vit plus que dans les archives (docs/archive/, scripts/genai-stack/_archive/), les tests de tolérance legacy (test_genai_stack_pure.py préserve sans réécrire ; verify_running_containers.py tolère les services déployés avant le recâblage fichier) et les commentaires historiques. Toute occurrence vivante qui le prescrit comme nom requis est un bug — le canon est COMFYUI_API_TOKEN (alias notebooks COMFYUI_AUTH_TOKEN), sweep #16647.

Qdrant — convention client vs serveur (cross-repo)

Qdrant expose la même clé API sous deux noms selon le côté :

  • Serveur Qdrant — QDRANT__SERVICE__API_KEY (double underscore, convention config.yaml Qdrant). Vit dans la compose roo-extensions (autre repo). Non centralisé ici : le flip de valeur est une op inter-repo manuelle.
  • Client (notebooks CoursIA) — QDRANT_API_KEY (simple underscore, convention des clients qdrant-client). Vit dans MyIA.AI.Notebooks/GenAI/.env. Centralisé dans SECRET_KEYS (render_envs.py).

Les deux noms doivent porter la même valeur (sinon 401 côté client). Centraliser le côté client CoursIA permet aux notebooks de suivre la rotation sans action manuelle : edit master.env → render_envs.py propage vers GenAI/.env. Le flip de la valeur côté serveur (rotation effective, Qdrant redémarre avec la nouvelle clé) reste une opération manuelle inter-repo sur roo-extensions — tracker séparément, cf SECURITY.md.

WHISPER_API_KEY — convention client ↔︎ service + propagation cross-repos

WHISPER_API_KEY est un bearer token partagé entre le service whisper-api (po-2023, port 8190) et ses consommateurs — notebooks CoursIA ET plusieurs workspaces cross-repo. Comme Qdrant, sa rotation a deux volets : CoursIA (automatisé) et cross-repo (manuel).

Côté CoursIA (automatisé via render_envs.py) : WHISPER_API_KEY est dans SECRET_KEYS et whisper-api/.env est un TARGET_ENV (glob services/*/.env). La rotation suit le runbook canonique ci-dessus — edit master.env → render_envs.py → docker compose restart whisper-api. Le compose injecte API_KEY=${WHISPER_API_KEY:-} ; l’auth est active (vérifié 2026-07-05 : POST /v1/audio/transcriptions sans bearer → 401).

Côté cross-repo (propagation manuelle) : le même token est consommé par des workspaces hors CoursIA. Après rotation CoursIA, propager la nouvelle valeur à chaque consommateur (RooSync privé ou édition du .env/config local du workspace) :

Workspace Usage Méthode de propagation
roo-extensions sk-agent MCP speech-to-text config MCP / .env local
myia-open-webui Web UI STT .env / config
hermes-agent (repo public) pipeline ASR ⚠️ JAMAIS dans le repo public — env var runtime ou RooSync privé (cf incident 2026-05-16 ci-dessous)
nanoclaw pipeline ASR config locale (cf nanoclaw-asr-dependency)

Cette liste reflète les consommateurs connus à la dernière rotation (2026-05). Vérifier le déploiement courant avant de rotater — un nouveau consommateur aurait pu être ajouté.

Vérification post-rotation (sur po-2023, après restart du container) :

# Token manquant → 401 (auth active, requête refusée)
curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:8190/v1/audio/transcriptions

# Token valide → 500 (auth passe, mais pas de fichier audio = attendu)
curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer <nouveau_token>" -X POST http://localhost:8190/v1/audio/transcriptions

Incident fondateur (hermes-agent, 2026-05-16) : un WHISPER_API_KEY a fuité via un .env.example commité dans le repo public jsboige/hermes-agent. Résolution : token rotaté, container restarté, fuite corrigée côté hermes-agent. Règle dure : dans tout repo public, utiliser des placeholders (YOUR_API_KEY_HERE), jamais la valeur réelle — même dans un .env.example. Cet incident motive le ⚠️ sur la ligne hermes-agent ci-dessus.

Secrets par instance (NON centralisés — config service)

Ces secrets sont légitimement différents par instance (un mot de passe par container). Ils restent dans le .env de leur service et ne sont jamais collapsés :

Secret Instances Pourquoi par-instance
COMFYUI_PASSWORD, COMFYUI_USERNAME comfyui-qwen Chaque ComfyUI a son propre login
FORGE_PASSWORD, FORGE_USER forge-turbo, sd-forge-main Chaque Forge a son propre login
WHISPER_PASSWORD/WHISPER_USER, WHISPER_WEBUI_PASSWORD/WHISPER_WEBUI_USER whisper-api, whisper-webui Logins par instance

Leur prévention du drift = la règle du restart (ci-dessous) + le self-check entrypoint, PAS la centralisation.

Exception #10985 (décision user 2026-08-20) — comfyui-video : le mot de passe de comfyui-video est centralisé dans master.env sous le nom instance-scopé COMFYUI_VIDEO_PASSWORD (propagé par render vers comfyui-video/.env et le .env client GenAI). Le compose mappe cette clé vers l’env conteneur COMFYUI_PASSWORD (nom lu par entrypoint.sh et ComfyUI-Login, inchangé). Le nom homonyme nu COMFYUI_PASSWORD reste interdit dans SECRET_KEYS : comfyui-qwen porte une valeur différente légitime (conflit dur au bootstrap). Motivation : rotation trousseau partagé po-2023 ↔︎ ai-01, aucun mot de passe en clair dans ce qui remonte sur github.com.

Règle du restart ComfyUI-Login (le piège du drift)

ComfyUI-Login stocke un hash bcrypt dans workspace/login/PASSWORD (bind-mount persistant). L’entrypoint.sh régénère ce hash depuis COMFYUI_PASSWORD (env) à chaque démarrage du container — mais pas pendant qu’il tourne.

Conséquence : si tu changes COMFYUI_PASSWORD dans le .env sans restart, le container garde un hash périmé jusqu’au prochain restart. Un client qui s’authentifie avec le nouveau mot de passe obtient un 401 — exactement le symptôme qui a fait croire (à tort) à une “clé perdue”.

Règle : après tout changement de COMFYUI_PASSWORD (ou COMFYUI_*_TOKEN), docker compose restart le container concerné. Le render affiche un rappel.

Self-check entrypoint : après écriture du hash, l’entrypoint vérifie bcrypt.checkpw(COMFYUI_PASSWORD, hash) et logge la confirmation — donc les logs de démarrage prouvent que le hash du container courant matche le .env au startup.

Incident fondateur (juin 2026)

Diagnostic erroné “drift bcrypt / plaintext perdu” sur comfyui-video → demande user de reset. Vérification re-faite : bcrypt.checkpw(.env_password, hash) == True — la clé n’était JAMAIS perdue. Le container avait simplement été vérifié avant son restart (hash périmé en RAM), puis restarté (hash régénéré depuis le .env courant). Leçon G.1/G.9 : un verdict “clé perdue” doit être vérifié après restart, pas sur un container au hash potentiellement périmé. Cet épisode a motivé la centralisation ci-dessus.

À faire (phase 2, optionnel)

  • Brancher render_envs.py --check en pre-commit local (hook pre-commit, pas CI — .env absents du checkout CI).
  • Étendre master.env aux tokens cross-repos (roo-extensions, hermes-agent) via un master partagé si besoin.

Voir aussi

Retour au sommet