Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9192ef355 | ||
|
|
4c9bfd576c | ||
|
|
20898cade7 | ||
|
|
2c12639499 | ||
|
|
7d2b2067ef | ||
|
|
0b52e6ee86 | ||
|
|
cafac69008 | ||
|
|
871875b8e5 | ||
|
|
e5ae3e48d6 | ||
|
|
76de3dd75e | ||
|
|
687427381d | ||
|
|
e418d87802 | ||
|
|
a1f3690085 | ||
|
|
0a7bed10bf | ||
|
|
32996ce9aa | ||
|
|
b42265b41e | ||
|
|
0fc7654f6c | ||
|
|
effad97472 | ||
|
|
53d98bfffe | ||
|
|
62aa6079be | ||
|
|
7d48f65ff9 | ||
|
|
3acadf6ad7 | ||
|
|
5492f99f06 | ||
|
|
71221e055b | ||
|
|
86330e8f65 | ||
|
|
f3b507b8d9 | ||
|
|
c01c7e862d | ||
|
|
99cd765997 | ||
|
|
5266506b56 | ||
|
|
682a5030d3 | ||
|
|
fc3d847d85 | ||
|
|
82756030c6 | ||
|
|
0352451ead | ||
|
|
593cf492c3 | ||
|
|
0b8ad8b0f3 | ||
|
|
111d927ece | ||
|
|
2dae023ee3 | ||
|
|
b12122f78a | ||
|
|
0c7b3162c8 | ||
|
|
d4d050297b | ||
|
|
8d768bf9d5 | ||
|
|
47ac8bf6c0 | ||
|
|
070add4e7b | ||
|
|
847b3225b2 | ||
|
|
3ae5c7efac | ||
|
|
4966c27548 |
@@ -132,3 +132,13 @@ DEEPSEEK_MODEL=deepseek-chat
|
|||||||
# DRACO_SESSION_SECRET_PREV=<ancien secret, fenêtre de rotation>
|
# DRACO_SESSION_SECRET_PREV=<ancien secret, fenêtre de rotation>
|
||||||
# DRACO_SESSION_COOKIE_DOMAIN=.dracodev.net
|
# DRACO_SESSION_COOKIE_DOMAIN=.dracodev.net
|
||||||
# DRACO_SSO_MACHINE_KEY=<clé machine des révocations, jamais commitée>
|
# DRACO_SSO_MACHINE_KEY=<clé machine des révocations, jamais commitée>
|
||||||
|
|
||||||
|
# ── 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=<kek-base64-32-octets>
|
||||||
|
# … 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.
|
||||||
|
|||||||
+16
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
|||||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||||
> [Unreleased](#unreleased). La dernière version livrée est **2.80.1**.
|
> [Unreleased](#unreleased). La dernière version livrée est **2.81.0**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,6 +14,21 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [2.81.0] — 2026-10-28
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.80.2] — 2026-10-27
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
|
||||||
|
- **CI lint (mypy)** — 3 erreurs dans le Coffre Secrets : annotation explicite `type[Any] | None` pour `AESGCM` dans `coffre_crypto.py` (le repli sans `cryptography` reste `None`, et `_require_aesgcm()` retourne désormais la classe pour un narrowing propre) ; annotation `dict[str, Any]` du coffre dans `create_coffre` (`coffre_store.py`), qui inférait ses valeurs en `str | None` et refusait le dict de `wrap_dek` ainsi que `coffre["id"]` en argument. Aucun `type: ignore` aveugle ajouté, aucun changement de comportement.
|
||||||
|
|
||||||
|
- **Bouton Nouveau secret inoperant (console NotFoundError)** — `valeurField` appelait `wrap.insertBefore(input, eyeBtn)` alors que le bouton oeil n'avait jamais ete rattache a `wrap` : l'oeil est desormais ajoute a `wrap` avant le premier `build`, la modale de creation (et celle de rotation) s'ouvre.
|
||||||
|
- **CI security (pip-audit)** — plancher `msgpack>=1.2.3` : la version transitive 1.2.1 etait signalee pour CVE-2026-114613 (correctif 1.2.3).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2.80.1] — 2026-10-27
|
## [2.80.1] — 2026-10-27
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|||||||
+3
-3
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 Changelog
|
||||||
|
|
||||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.80.1).
|
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.81.0).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Projet : ObsiGate | Version : 2.80.1 | Dernière mise à jour : Septembre 2026*
|
*Projet : ObsiGate | Version : 2.81.0 | Dernière mise à jour : Septembre 2026*
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 Changelog
|
||||||
|
|
||||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.80.1).
|
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.81.0).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Project: ObsiGate | Version: 2.80.1 | Last updated: September 2026*
|
*Project: ObsiGate | Version: 2.81.0 | Last updated: September 2026*
|
||||||
|
|||||||
@@ -24,14 +24,19 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import stat
|
import stat
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
logger = logging.getLogger("obsigate.coffre_crypto")
|
logger = logging.getLogger("obsigate.coffre_crypto")
|
||||||
|
|
||||||
|
# Annotation explicite : sans elle, mypy refuse l'assignation de deux types
|
||||||
|
# (la classe AESGCM importée, puis None dans le repli sans cryptography)
|
||||||
|
# et le commentaire `type: ignore[assignment]` ne couvre pas ce code [misc].
|
||||||
|
AESGCM: type[Any] | None
|
||||||
try: # cryptography est une dépendance de la Phase 1a (requirements.txt)
|
try: # cryptography est une dépendance de la Phase 1a (requirements.txt)
|
||||||
from cryptography.exceptions import InvalidTag
|
from cryptography.exceptions import InvalidTag
|
||||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
except Exception: # pragma: no cover - environnement sans cryptography
|
except Exception: # pragma: no cover - environnement sans cryptography
|
||||||
AESGCM = None # type: ignore[assignment]
|
AESGCM = None
|
||||||
InvalidTag = Exception # type: ignore[assignment,misc]
|
InvalidTag = Exception # type: ignore[assignment,misc]
|
||||||
|
|
||||||
|
|
||||||
@@ -55,11 +60,17 @@ class CryptoIndisponibleError(CoffreCryptoError):
|
|||||||
"""La bibliothèque ``cryptography`` n'est pas installée."""
|
"""La bibliothèque ``cryptography`` n'est pas installée."""
|
||||||
|
|
||||||
|
|
||||||
def _require_aesgcm() -> None:
|
def _require_aesgcm() -> type[Any]:
|
||||||
|
"""Retourne la classe AESGCM, ou lève si cryptography est absent.
|
||||||
|
|
||||||
|
Retourner la classe (plutôt qu'un simple garde) permet à mypy de
|
||||||
|
savoir que l'appelant détient bien un constructible, et non None.
|
||||||
|
"""
|
||||||
if AESGCM is None:
|
if AESGCM is None:
|
||||||
raise CryptoIndisponibleError(
|
raise CryptoIndisponibleError(
|
||||||
"cryptography n'est pas installé (dépendance Phase 1a)"
|
"cryptography n'est pas installé (dépendance Phase 1a)"
|
||||||
)
|
)
|
||||||
|
return AESGCM
|
||||||
|
|
||||||
|
|
||||||
def parse_kek(raw: str) -> bytes:
|
def parse_kek(raw: str) -> bytes:
|
||||||
@@ -146,17 +157,17 @@ def _unb64(value: str) -> bytes:
|
|||||||
|
|
||||||
def encrypt_with_key(key: bytes, plaintext: bytes, aad: bytes) -> dict:
|
def encrypt_with_key(key: bytes, plaintext: bytes, aad: bytes) -> dict:
|
||||||
"""Chiffre avec AES-256-GCM. Retourne {nonce, ciphertext} en base64."""
|
"""Chiffre avec AES-256-GCM. Retourne {nonce, ciphertext} en base64."""
|
||||||
_require_aesgcm()
|
aesgcm = _require_aesgcm()
|
||||||
nonce = os.urandom(12)
|
nonce = os.urandom(12)
|
||||||
ciphertext = AESGCM(key).encrypt(nonce, plaintext, aad)
|
ciphertext = aesgcm(key).encrypt(nonce, plaintext, aad)
|
||||||
return {"nonce": _b64(nonce), "ciphertext": _b64(ciphertext)}
|
return {"nonce": _b64(nonce), "ciphertext": _b64(ciphertext)}
|
||||||
|
|
||||||
|
|
||||||
def decrypt_with_key(key: bytes, nonce_b64: str, ciphertext_b64: str, aad: bytes) -> bytes:
|
def decrypt_with_key(key: bytes, nonce_b64: str, ciphertext_b64: str, aad: bytes) -> bytes:
|
||||||
"""Déchiffre avec AES-256-GCM. Tag invalide -> TagInvalideError."""
|
"""Déchiffre avec AES-256-GCM. Tag invalide -> TagInvalideError."""
|
||||||
_require_aesgcm()
|
aesgcm = _require_aesgcm()
|
||||||
try:
|
try:
|
||||||
return AESGCM(key).decrypt(_unb64(nonce_b64), _unb64(ciphertext_b64), aad)
|
return aesgcm(key).decrypt(_unb64(nonce_b64), _unb64(ciphertext_b64), aad)
|
||||||
except InvalidTag as exc:
|
except InvalidTag as exc:
|
||||||
raise TagInvalideError("Tag GCM invalide : données altérées ou mauvaise clé") from exc
|
raise TagInvalideError("Tag GCM invalide : données altérées ou mauvaise clé") from exc
|
||||||
except (binascii.Error, ValueError) as exc:
|
except (binascii.Error, ValueError) as exc:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -42,6 +42,7 @@ import threading
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from backend.coffre_crypto import (
|
from backend.coffre_crypto import (
|
||||||
decrypt_secret_value,
|
decrypt_secret_value,
|
||||||
@@ -306,7 +307,7 @@ def create_coffre(
|
|||||||
data = _load_coffres(base)
|
data = _load_coffres(base)
|
||||||
if any(c["slug"] == slug for c in data["coffres"]):
|
if any(c["slug"] == slug for c in data["coffres"]):
|
||||||
raise CoffreExisteError(f"Coffre déjà existant : {slug}")
|
raise CoffreExisteError(f"Coffre déjà existant : {slug}")
|
||||||
coffre = {
|
coffre: dict[str, Any] = {
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
"slug": slug,
|
"slug": slug,
|
||||||
"nom": nom,
|
"nom": nom,
|
||||||
|
|||||||
@@ -75,3 +75,7 @@ filelock>=4.2.1
|
|||||||
opentelemetry-api>=1.45.1
|
opentelemetry-api>=1.45.1
|
||||||
# Coffre Secrets (Phase 1a) : chiffrement par enveloppe AES-256-GCM.
|
# Coffre Secrets (Phase 1a) : chiffrement par enveloppe AES-256-GCM.
|
||||||
cryptography>=44.0
|
cryptography>=44.0
|
||||||
|
|
||||||
|
# Plancher de securite : msgpack est transitif, version 1.2.1 relevee par le job
|
||||||
|
# security (pip-audit, bloquant) — CVE-2026-114613, corrigee en 1.2.3.
|
||||||
|
msgpack>=1.2.3
|
||||||
|
|||||||
+127
-2
@@ -19,6 +19,14 @@ Règles d'or, appliquées côté serveur à chaque requête :
|
|||||||
utilisé ici, volontairement).
|
utilisé ici, volontairement).
|
||||||
- Feature flag ``OBSIGATE_SECRETS_ENABLED`` (défaut désactivé) : tant
|
- Feature flag ``OBSIGATE_SECRETS_ENABLED`` (défaut désactivé) : tant
|
||||||
qu'il n'est pas activé, toutes les routes répondent 503.
|
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
|
from __future__ import annotations
|
||||||
@@ -35,6 +43,13 @@ from backend.coffre_crypto import (
|
|||||||
KEKAbsenteError,
|
KEKAbsenteError,
|
||||||
generate_kek_b64,
|
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 (
|
from backend.coffre_store import (
|
||||||
CoffreExisteError,
|
CoffreExisteError,
|
||||||
IntrouvableError,
|
IntrouvableError,
|
||||||
@@ -273,6 +288,43 @@ async def api_create_consommateur(
|
|||||||
return {**consommateur, "jeton": jeton}
|
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}")
|
@router.delete("/api/coffres/consommateurs/{consommateur_id}")
|
||||||
async def api_revoke_consommateur(
|
async def api_revoke_consommateur(
|
||||||
consommateur_id: str, current_user=Depends(require_admin)
|
consommateur_id: str, current_user=Depends(require_admin)
|
||||||
@@ -291,6 +343,67 @@ async def api_revoke_consommateur(
|
|||||||
return 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)
|
# 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(...)):
|
async def api_distribuer(request: Request, body: dict = Body(...)):
|
||||||
_exiger_actives()
|
_exiger_actives()
|
||||||
reference = (body.get("reference") or "").strip()
|
reference = (body.get("reference") or "").strip()
|
||||||
contexte_type = (body.get("contexte_type") or "").strip()
|
# Phase 3 (Spark) : le contexte peut arriver à plat
|
||||||
contexte_id = (body.get("contexte_id") or "").strip()
|
# (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:
|
if not contexte_type or not contexte_id:
|
||||||
raise HTTPException(status_code=400, detail="contexte_manquant")
|
raise HTTPException(status_code=400, detail="contexte_manquant")
|
||||||
if not reference.startswith("coffre:") or "/" not in reference[7:]:
|
if not reference.startswith("coffre:") or "/" not in reference[7:]:
|
||||||
|
|||||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.80.1"
|
version = "2.81.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.80.1"
|
version = "2.81.0"
|
||||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||||
authors = ["Bruno Charest"]
|
authors = ["Bruno Charest"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||||
"productName": "ObsiGate",
|
"productName": "ObsiGate",
|
||||||
"version": "2.80.1",
|
"version": "2.81.0",
|
||||||
"identifier": "com.obsigate.desktop",
|
"identifier": "com.obsigate.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend",
|
"frontendDist": "../frontend",
|
||||||
|
|||||||
@@ -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 <jeton-consommateur>
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Corps (forme à plat, Phase 1b) :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"reference": "coffre:<slug>/<nom>",
|
||||||
|
"contexte_type": "mission",
|
||||||
|
"contexte_id": "mission-123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Forme imbriquée acceptée depuis la Phase 2 (client Spark) :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"reference": "coffre:<slug>/<nom>",
|
||||||
|
"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:<slug>/<nom>", "valeur": "<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: <id-mission>}` 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:<slug>/<nom>` 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.
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
# Coffre Secrets ObsiGate — Déploiement et mise en service (Phase 5, issue #23)
|
||||||
|
|
||||||
|
Document de déploiement du coffre Secrets, issu des Phases 1a/1b
|
||||||
|
(issues #17, #18), 2 (issue #20), 3 (issue #21, côté agent-commun)
|
||||||
|
et 4 (issue #22, côté TikTak, Flowdeck, Kanbante).
|
||||||
|
|
||||||
|
**Règles de gouvernance (non négociables) :** travail en branche +
|
||||||
|
pull request, jamais sur `main`, jamais de merge, de release, de tag,
|
||||||
|
de fermeture d'issue ni de changement de label sans le go explicite
|
||||||
|
de Bruno. Ce document ne décrit aucune valeur réelle : les exemples
|
||||||
|
utilisent des valeurs fictives (`TEST-VALUE-XXXX`) et aucune KEK
|
||||||
|
n'est jamais écrite ici, ni dans un journal, ni dans une note, ni
|
||||||
|
dans le dépôt.
|
||||||
|
|
||||||
|
Documents compagnons (même branche de référence, Phase 2) :
|
||||||
|
|
||||||
|
- `docs/COFFRE-contrat-distribution.md` — contrat
|
||||||
|
`POST /api/coffres/distribuer` pour Spark, TikTak, Flowdeck,
|
||||||
|
Kanbante (authentification par consommateur, contextes, codes
|
||||||
|
d'erreur stables).
|
||||||
|
- `docs/COFFRE-sauvegarde-restauration.md` — sauvegarde
|
||||||
|
(`data/coffres.json` + `data/coffres_valeurs.json`, 0600) et
|
||||||
|
restauration sur instance vierge, perte de KEK.
|
||||||
|
|
||||||
|
## 1. Prérequis et variables d'environnement
|
||||||
|
|
||||||
|
### 1.1 Dépendance
|
||||||
|
|
||||||
|
- `cryptography>=44.0` dans `backend/requirements.txt` (dépendance
|
||||||
|
Phase 1a). Sans elle, les opérations de chiffrement répondent
|
||||||
|
503 `crypto_indisponible` : c'est un mode dégradé explicite, pas
|
||||||
|
un repli en clair. Il n'y a jamais de stockage ni de distribution
|
||||||
|
de valeur en clair.
|
||||||
|
- Chiffrement par enveloppe AES-256-GCM : KEK (256 bits) → DEK par
|
||||||
|
coffre → valeur du secret. Un tag GCM invalide refuse le
|
||||||
|
déchiffrement (testé : `tests/test_coffre_secrets.py`,
|
||||||
|
`test_tag_invalide_refuse`).
|
||||||
|
|
||||||
|
### 1.2 Variables
|
||||||
|
|
||||||
|
| Variable | Rôle | Défaut |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `OBSIGATE_SECRETS_ENABLED` | Activation du coffre. Tant qu'elle n'est pas à `true`, **toutes** les routes coffre répondent 503 `secrets_desactives`. | désactivé |
|
||||||
|
| `OBSIGATE_KEK` | KEK, base64 de 32 octets (ou hex 64 caractères). **Prioritaire** sur le fichier si les deux sont définis. | absente |
|
||||||
|
| `OBSIGATE_KEK_FILE` | Chemin d'un fichier contenant la KEK, **en 0600, hors dépôt et hors arborescence des vaults**. Un fichier trop permissif (droits groupe/autre) est refusé explicitement (`KEKInvalideError`). | absent |
|
||||||
|
| `OBSIGATE_DATA_DIR` | Répertoire des données (`data/` par défaut) : `coffres.json`, `coffres_valeurs.json` et l'historique `api_keys.json`. | `data` |
|
||||||
|
|
||||||
|
La KEK est générée une seule fois (admin, coffres activés) via
|
||||||
|
`POST /api/coffres/admin/generer-kek` ; ObsiGate **ne la stocke
|
||||||
|
jamais**. Au moment de sa génération : la copier immédiatement
|
||||||
|
dans un gestionnaire de mots de passe ou sur un support hors ligne,
|
||||||
|
hors du serveur ; vérifier la copie en la relisant depuis le
|
||||||
|
gestionnaire, jamais depuis l'écran qui ne l'affichera plus.
|
||||||
|
Ne jamais la coller dans le dépôt, dans une note ObsiGate, dans le
|
||||||
|
même dossier que `data/`, ni dans un journal.
|
||||||
|
|
||||||
|
**Recommandation de déploiement : préférer `OBSIGATE_KEK_FILE`**
|
||||||
|
(fichier 0600) à `OBSIGATE_KEK` en variable d'environnement, pour
|
||||||
|
éviter que la KEK apparaisse dans un historique de processus ou un
|
||||||
|
dump d'environnement. Si les deux sont définis, `OBSIGATE_KEK`
|
||||||
|
l'emporte — vérifier qu'aucune ancienne valeur ne traîne dans
|
||||||
|
l'environnement au moment de basculer sur le fichier.
|
||||||
|
|
||||||
|
### 1.3 Ce que la sauvegarde contient (rappel)
|
||||||
|
|
||||||
|
Deux fichiers seulement portent le coffre, tous deux en 0600 :
|
||||||
|
|
||||||
|
- `data/coffres.json` : métadonnées des coffres, DEK chiffrées,
|
||||||
|
consommateurs (hashes SHA-256 des jetons seulement, jamais le
|
||||||
|
jeton en clair), octrois, métadonnées des secrets ;
|
||||||
|
- `data/coffres_valeurs.json` : versions chiffrées des secrets.
|
||||||
|
|
||||||
|
Copiés seuls, ils ne donnent accès à aucune valeur. La KEK ne fait
|
||||||
|
pas partie de la sauvegarde : elle est copiée à part, hors serveur.
|
||||||
|
Sauvegarder les deux fichiers et la KEK au même endroit annulerait
|
||||||
|
la protection du chiffrement.
|
||||||
|
|
||||||
|
## 2. Décisions ouvertes de la spec — tranchées par défaut
|
||||||
|
|
||||||
|
Les décisions ouvertes de la spec (§15, §16), rappelées dans
|
||||||
|
l'issue #23, sont confirmées comme tranchées selon les défauts
|
||||||
|
recommandés, en attendant une décision contraire explicite de
|
||||||
|
Bruno. Aucune d'elles ne nécessite de changement de code pour la
|
||||||
|
mise en service décrite ci-dessous :
|
||||||
|
|
||||||
|
| Décision | Choix confirmé par défaut |
|
||||||
|
| --- | --- |
|
||||||
|
| Glyphe du nœud Secrets | Cadenas U+1F510 (🔐), distinct de la maison du Home et du diamant du Partage. Le nœud est un nœud virtuel sous le Home de l'utilisateur courant seulement (voir §4). |
|
||||||
|
| Stockage de la KEK | Par fichier 0600, hors dépôt (`OBSIGATE_KEK_FILE`) ; `OBSIGATE_KEK` reste supportée comme solution de secours. |
|
||||||
|
| Expiration des octrois | 90 jours par défaut à la création, renouvelable (création d'un nouvel octroi). Un octroi expiré refuse la distribution (`octroi_expire`, 403) ; un octroi révoqué refuse dès l'appel suivant (`octroi_revoque`, 403). |
|
||||||
|
| Copie manuelle | Presse-papiers seul, sans affichage de la valeur. Patron supporté : consommateur temporaire créé par un admin puis révoqué immédiatement, auditée comme distribution de contexte `copie_manuelle`. **Aucun endpoint dédié de copie par session n'est ajouté** : il contredirait la règle « distribution réservée aux consommateurs » et exigerait une ré-authentification qui n'existe pas encore — décision volontaire, à trancher avec Bruno avant toute implémentation. |
|
||||||
|
| Coffres partagés | Phase 2 ultérieure. En mise en service : coffres personnels d'abord, plus le coffre d'instance « ObsiGate interne » (slug `obsigate-interne`, admin seulement) issu de l'import. |
|
||||||
|
|
||||||
|
## 3. Mise en service pas à pas
|
||||||
|
|
||||||
|
**Ordre impératif.** Chaque étape est validée avant de passer à la
|
||||||
|
suivante. En particulier : **les lecteurs internes sont branchés
|
||||||
|
sur le coffre AVANT toute purge du clair** (étape 4 avant étape 7),
|
||||||
|
et la purge n'a lieu qu'après validation complète (étape 6).
|
||||||
|
|
||||||
|
### Étape 0 — Préparer, coffre encore désactivé
|
||||||
|
|
||||||
|
1. Déployer le code coffre (Phases 1–2) sur l'instance, **sans
|
||||||
|
activer le flag** : `OBSIGATE_SECRETS_ENABLED` reste absent ou à
|
||||||
|
`false`. Vérifier que toutes les routes `/api/coffres/*`
|
||||||
|
répondent 503.
|
||||||
|
2. Sauvegarder l'état actuel : copie de `data/` (dont
|
||||||
|
`api_keys.json`) et de la configuration, hors serveur.
|
||||||
|
3. Générer la KEK (voir §1.2) et la copier hors serveur. Ne pas
|
||||||
|
encore la référencer dans la configuration de l'instance si
|
||||||
|
l'étape 1 n'est pas terminée — une KEK présente sans flag actif
|
||||||
|
ne distribue rien, mais sa présence doit rester traçable.
|
||||||
|
|
||||||
|
### Étape 1 — Créer les consommateurs et les octrois
|
||||||
|
|
||||||
|
Avant d'activer le flag, préparer les consommateurs nommés, un par
|
||||||
|
application cliente (convention de nommage, non imposée par le
|
||||||
|
code — l'autorisation porte sur le jeton, pas sur le nom) :
|
||||||
|
|
||||||
|
| Consommateur | Usage | Contexte de distribution |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `spark` | Agent Spark (agent-commun, Phase 3) | `contexte_type = "mission"`, `contexte_id` = id de mission (forme imbriquée `contexte: {type, id}` acceptée) |
|
||||||
|
| `tiktak` | Synchronisation TikTak (Phase 4) | `contexte_type = "tiktak_sync"`, `contexte_id` = id utilisateur |
|
||||||
|
| `flowdeck` | Connecteurs Flowdeck (Phase 4) | Contexte workspace, vérifié côté Flowdeck (voir §5) |
|
||||||
|
| `kanbante` | Webhooks Kanbante (Phase 4) | Contexte webhook, distribution à chaque appel puis oubli |
|
||||||
|
|
||||||
|
Création en une seule opération avec les octrois :
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/coffres/consommateurs/avec-octrois
|
||||||
|
{"nom": "tiktak", "octrois": [{"coffre_slug": "...", "secret_nom": "..."}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `secret_nom` absent = tout le coffre (octroi coffre-wide).
|
||||||
|
- Le jeton (`csk_…`) n'est rendu **qu'une seule fois**, à la
|
||||||
|
création. Le noter immédiatement dans le coffre de
|
||||||
|
configuration de l'application cliente concernée, jamais dans
|
||||||
|
une note ObsiGate ni dans le dépôt. Il n'est jamais relisté :
|
||||||
|
`GET /api/coffres/consommateurs` ne renvoie que `id`, `nom`,
|
||||||
|
`jeton_prefixe`, `statut`, `cree_par`, `cree_le`.
|
||||||
|
- La révocation est immédiate : `DELETE
|
||||||
|
/api/coffres/consommateurs/{id}` révoque toutes les
|
||||||
|
distributions de ce consommateur, sans toucher aux autres.
|
||||||
|
Une mission Spark déjà démarrée conserve son environnement
|
||||||
|
jusqu'à sa fin ; la mission suivante repasse en `acces_requis`.
|
||||||
|
|
||||||
|
À ce stade, les consommateurs et octrois peuvent être créés alors
|
||||||
|
que les secrets n'existent pas encore : les octrois pointent vers
|
||||||
|
des coffres/secrets à créer aux étapes suivantes. Vérifier la
|
||||||
|
liste des consommateurs et des octrois avant d'activer le flag.
|
||||||
|
|
||||||
|
### Étape 2 — Activer le flag
|
||||||
|
|
||||||
|
1. Définir `OBSIGATE_KEK_FILE` (fichier 0600, hors dépôt) puis
|
||||||
|
`OBSIGATE_SECRETS_ENABLED=true`.
|
||||||
|
2. Redémarrer l'instance.
|
||||||
|
3. Vérifier : la vue Secrets s'ouvre ; `GET /api/coffres` répond
|
||||||
|
200 (liste vide ou coffres existants) ; sans KEK valide, les
|
||||||
|
routes répondent 503 `kek_absente` — dans ce cas, corriger la
|
||||||
|
KEK avant toute création de secret.
|
||||||
|
|
||||||
|
### Étape 3 — Importer l'existant (admin seulement)
|
||||||
|
|
||||||
|
Bouton « Importer l'existant » de la vue Secrets (admin
|
||||||
|
seulement), en deux temps séparés :
|
||||||
|
|
||||||
|
1. **Aperçu** : `GET /api/coffres/migration/api-keys` liste les
|
||||||
|
candidats de `data/api_keys.json` (clés IA et clés d'outils).
|
||||||
|
2. **Import** : `POST /api/coffres/migration/api-keys/import`
|
||||||
|
copie les entrées avec valeur vers le coffre d'instance
|
||||||
|
« ObsiGate interne » (slug `obsigate-interne`), chiffrées.
|
||||||
|
Un secret déjà présent n'est **jamais écrasé**.
|
||||||
|
`api_keys.json` n'est **pas modifié** par l'import.
|
||||||
|
|
||||||
|
Vérifier après import : les secrets importés apparaissent dans la
|
||||||
|
vue Secrets avec `has_value = true`, et aucune valeur n'est
|
||||||
|
visible (métadonnées seules : nom, type, version, dates, octrois
|
||||||
|
actifs — jamais de valeur, jamais de texte chiffré, jamais de
|
||||||
|
nonce).
|
||||||
|
|
||||||
|
### Étape 4 — Brancher les lecteurs internes AVANT toute purge
|
||||||
|
|
||||||
|
**Point de vigilance principal de la mise en service.** Les
|
||||||
|
lecteurs actuels d'ObsiGate (`backend/ai.py`,
|
||||||
|
`backend/tools/secrets.py`) lisent encore `data/api_keys.json`
|
||||||
|
directement ; ils ne sont pas encore branchés sur le coffre
|
||||||
|
(reste à faire explicite des Phases 2–5). Tant que ce branchement
|
||||||
|
n'est pas livré et validé, **purger le clair priverait l'IA et les
|
||||||
|
outils de leurs clés**.
|
||||||
|
|
||||||
|
Ordre à respecter :
|
||||||
|
|
||||||
|
1. Ne pas purger.
|
||||||
|
2. Livrer (en branche + PR, jamais mergé sans go) le branchement
|
||||||
|
des lecteurs internes sur une distribution depuis le coffre
|
||||||
|
d'instance, puis le valider sur l'instance : l'IA et les outils
|
||||||
|
fonctionnent avec `api_keys.json` rendu temporairement
|
||||||
|
inaccessible en lecture (test de coupure contrôlée), ou à
|
||||||
|
défaut avec un secret de test distribué depuis le coffre.
|
||||||
|
3. Seulement après cette validation, passer à l'étape 7.
|
||||||
|
|
||||||
|
Si Bruno accepte une interruption temporaire de l'IA/outils, le
|
||||||
|
documenter explicitement avant la purge ; sinon, la purge attend
|
||||||
|
le branchement.
|
||||||
|
|
||||||
|
### Étape 5 — Brancher les applications clientes
|
||||||
|
|
||||||
|
Pour chaque application (branches dédiées, jamais mergées sans
|
||||||
|
go), appliquer le patron commun **distribuer → utiliser →
|
||||||
|
oublier** : le serveur distribue à chaque opération, utilise la
|
||||||
|
valeur en mémoire, puis l'oublie ; aucune valeur en cache, aucune
|
||||||
|
persistance. Si ObsiGate est indisponible, l'opération échoue
|
||||||
|
proprement, sans repli sur une ancienne valeur.
|
||||||
|
|
||||||
|
- **Spark (agent-commun, Phase 3)** : identités référencées
|
||||||
|
`coffre:<slug>/<nom>` (slug 3–64, nom 3–128, minuscules /
|
||||||
|
chiffres / `-` / `_`), sans colonne valeur ; le LLM ne voit que
|
||||||
|
la référence et un accès oui/non. Sans octroi, la mission passe
|
||||||
|
en `acces_requis` en nommant l'accès manquant ; avec octroi et
|
||||||
|
mission approuvée (enveloppe budgétaire), distribution puis
|
||||||
|
injection en variable d'environnement de l'exécuteur, jamais
|
||||||
|
dans le prompt, jamais dans les étapes d'activité. Fin de
|
||||||
|
mission = environnement détruit.
|
||||||
|
- **TikTak (Phase 4)** : référence seule stockée ; distribution
|
||||||
|
serveur à chaque synchro ; masquage côté Android (ni référence
|
||||||
|
complète ni valeur dans les réponses vues par une clé `tck_`).
|
||||||
|
Les configurations existantes sont marquées `a_resaisir = 1`
|
||||||
|
(migration 019 : colonne en clair supprimée, référence vide) :
|
||||||
|
il n'y a **pas de conversion automatique** — chaque
|
||||||
|
configuration doit être re-saisie par référence.
|
||||||
|
- **Flowdeck (Phase 4)** : `workspace_id` + référence sur les
|
||||||
|
connecteurs ; distribution en mémoire ; isolation A vs B
|
||||||
|
testée (un workspace ne distribue pas avec l'octroi d'un autre).
|
||||||
|
Les connecteurs natifs Gitea/GitHub et les clés LLM ne sont pas
|
||||||
|
basculés ; pas de sélecteur UI (API seulement).
|
||||||
|
- **Kanbante (Phase 4)** : secret webhook migré en référence,
|
||||||
|
distribuée à chaque webhook puis oubliée ; client générique
|
||||||
|
réutilisable. Les jetons forge chiffrés Fernet ne sont **pas**
|
||||||
|
migrés (distribution par appel requise, incompatible avec le
|
||||||
|
cache `ForgeRegistry`) ; les secrets webhook historiques en
|
||||||
|
clair restent acceptés par compatibilité, sans migration
|
||||||
|
automatique.
|
||||||
|
|
||||||
|
### Étape 6 — Valider (avant purge)
|
||||||
|
|
||||||
|
Exécuter la checklist de validation de l'instance réelle (§4).
|
||||||
|
Tant qu'un point échoue, ne pas purger et ne pas considérer la
|
||||||
|
mise en service comme terminée.
|
||||||
|
|
||||||
|
### Étape 7 — Purger, seulement après validation
|
||||||
|
|
||||||
|
`POST /api/coffres/migration/api-keys/purger`, **après
|
||||||
|
confirmation explicite** (`confirmer = true`), et uniquement pour
|
||||||
|
les noms migrés. Sans confirmation, rien n'est supprimé — jamais
|
||||||
|
automatiquement.
|
||||||
|
|
||||||
|
Après purge, vérifier :
|
||||||
|
|
||||||
|
- `api_keys.json` ne contient plus les noms purgés ;
|
||||||
|
- l'IA et les outils fonctionnent toujours (lecteurs branchés,
|
||||||
|
étape 4) ;
|
||||||
|
- une distribution de test via un consommateur existant aboutit
|
||||||
|
(succès seul vérifié, valeur ni affichée ni consignée).
|
||||||
|
|
||||||
|
### Étape 8 — Sauvegarder et tester la restauration
|
||||||
|
|
||||||
|
1. Sauvegarder `data/coffres.json` + `data/coffres_valeurs.json`
|
||||||
|
(0600) par la sauvegarde habituelle du serveur, et la KEK à
|
||||||
|
part, hors serveur (§1.3).
|
||||||
|
2. Rejouer la restauration sur une instance vierge (procédure
|
||||||
|
`docs/COFFRE-sauvegarde-restauration.md` §3 : arrêt de la
|
||||||
|
cible, copie des deux fichiers en 0600, même KEK, redémarrage,
|
||||||
|
vérification `has_value = true` + distribution de test +
|
||||||
|
`distribution_ok` dans le journal). La suite automatisée
|
||||||
|
(`tests/test_coffre_secrets.py`, classe
|
||||||
|
`TestRestaurationVierge`) simule cette restauration ; elle ne
|
||||||
|
remplace pas un essai sur instance réelle.
|
||||||
|
3. En cas de perte de KEK : perte d'accès assumée, par
|
||||||
|
construction — aucun moyen de récupérer les valeurs. Recréer
|
||||||
|
les coffres avec une nouvelle KEK, régénérer les valeurs côté
|
||||||
|
fournisseur, révoquer les anciennes côté fournisseur, recréer
|
||||||
|
consommateurs et octrois. Ne jamais tenter de « réparer » les
|
||||||
|
anciens fichiers avec une nouvelle KEK (le déchiffrement
|
||||||
|
échouera sur tag GCM invalide, ce qui est attendu).
|
||||||
|
|
||||||
|
## 4. Checklist de validation par Bruno sur instance réelle
|
||||||
|
|
||||||
|
Validation exigée par l'issue #23 avant tout usage réel. Chaque
|
||||||
|
point est validé sur capture, comme pour les autres livraisons :
|
||||||
|
|
||||||
|
- [ ] **Sidebar** : le nœud « Secrets » (cadenas U+1F510)
|
||||||
|
apparaît sous le Home de l'utilisateur courant seulement, et
|
||||||
|
pas sous les autres vaults ; cliquer dessus ouvre la vue
|
||||||
|
Secrets sans naviguer dans les fichiers.
|
||||||
|
- [ ] **Secret de test** : création d'un secret de test
|
||||||
|
(valeur fictive, ex. `TEST-VALUE-XXXX`) dans un coffre
|
||||||
|
personnel, puis lecture : métadonnées seules, aucune valeur
|
||||||
|
affichée, aucun texte chiffré, aucun nonce.
|
||||||
|
- [ ] **Journal d'audit** : après une distribution de test, le
|
||||||
|
journal contient `distribution_ok` pour la référence, et un
|
||||||
|
`grep` du journal sur la valeur de test ne renvoie **rien**
|
||||||
|
(ni valeur, ni texte chiffré, ni nonce, ni jeton).
|
||||||
|
- [ ] **Autorisations** : un admin ne voit pas le coffre
|
||||||
|
personnel d'autrui ; une ACL `*` sur un vault ne donne aucun
|
||||||
|
droit sur un coffre ; une session utilisateur, même admin, ne
|
||||||
|
distribue pas (401 `consommateur_invalide`).
|
||||||
|
- [ ] **Distribution** : contexte manquant refusé (400
|
||||||
|
`contexte_manquant`) ; octroi révoqué refusé dès l'appel
|
||||||
|
suivant (403 `octroi_revoque`) ; octroi expiré refusé (403
|
||||||
|
`octroi_expire`) ; secret expiré refusé (410 `secret_expire`).
|
||||||
|
- [ ] **Import / purge** : l'import ne modifie pas
|
||||||
|
`api_keys.json` et n'écrase pas un secret existant ; la purge
|
||||||
|
sans confirmation explicite ne supprime rien.
|
||||||
|
- [ ] **Restauration** : sauvegarde + KEK externe restaurées sur
|
||||||
|
une instance vierge, distributions fonctionnelles.
|
||||||
|
- [ ] **Intégrations** : mission Spark en `acces_requis` sans
|
||||||
|
octroi, puis injection exécuteur sans trace dans les étapes ;
|
||||||
|
synchro TikTak sans valeur côté Android ; isolation workspace
|
||||||
|
Flowdeck (A ne distribue pas avec l'octroi de B).
|
||||||
|
- [ ] **Modale** : la modale de création de secret n'est plus
|
||||||
|
coupée en haut (`.obsigate-modal` bornée au viewport, corps
|
||||||
|
seul défilant, en-tête et pied toujours visibles).
|
||||||
|
|
||||||
|
## 5. Limites connues à la mise en service
|
||||||
|
|
||||||
|
À lire avant de déclarer la mise en service terminée :
|
||||||
|
|
||||||
|
- **Lecteurs internes non branchés** (`ai.py`,
|
||||||
|
`tools/secrets.py` lisent encore `api_keys.json` en clair) :
|
||||||
|
purger avant de les brancher priverait l'IA/outils de leurs
|
||||||
|
clés (§3, étape 4).
|
||||||
|
- **Endpoint dédié de copie manuelle par session non ajouté**
|
||||||
|
(décision volontaire, §2) : la copie manuelle passe par un
|
||||||
|
consommateur temporaire admin, presse-papiers seul.
|
||||||
|
- **Isolation workspace Flowdeck non contrôlée côté ObsiGate** :
|
||||||
|
`/distribuer` n'autorise aujourd'hui que les octrois de type
|
||||||
|
`consommateur` ; l'isolation workspace repose sur le serveur
|
||||||
|
Flowdeck lui-même (il ne demande que les références de son
|
||||||
|
workspace, avec son propre jeton). Un contrôle serveur strict
|
||||||
|
reste à spécifier si Bruno l'exige.
|
||||||
|
- **Identités agent-commun** : détenteur libre hors coffre
|
||||||
|
conservé (historique) ; révocation Identités non propagée vers
|
||||||
|
ObsiGate (pas d'endpoint) ; exécuteurs CLI synchrones
|
||||||
|
uniquement.
|
||||||
|
- **TikTak** : « sélecteur » = saisie validée, pas de liste
|
||||||
|
déroulante (aucune API de listage côté ObsiGate) ;
|
||||||
|
configurations existantes à re-saisir, pas de conversion
|
||||||
|
automatique.
|
||||||
|
- **Flowdeck** : natifs Gitea/GitHub et clés LLM non basculés ;
|
||||||
|
pas de sélecteur UI (API seulement) ; `workspace_id` pas encore
|
||||||
|
branché depuis AgentEngine.
|
||||||
|
- **Kanbante** : jetons forge chiffrés Fernet non migrés ;
|
||||||
|
pas de migration automatique des secrets webhook historiques.
|
||||||
|
- **Recherche / graphe / index** : par construction, un coffre
|
||||||
|
n'est pas un vault de notes — il ne passe ni par l'indexeur, ni
|
||||||
|
par la recherche, ni par le graphe, et aucun outil MCP de secret
|
||||||
|
n'existe. Aucune métadonnée de secret ne doit apparaître dans
|
||||||
|
ces index (vérification par revue de code, pas de suite
|
||||||
|
automatisée dédiée — voir le bilan Phase 5).
|
||||||
|
|
||||||
|
## 6. Repli (rollback)
|
||||||
|
|
||||||
|
- Désactiver le flag (`OBSIGATE_SECRETS_ENABLED` absent ou
|
||||||
|
`false`) : toutes les routes coffre répondent 503, les fichiers
|
||||||
|
`coffres.json` / `coffres_valeurs.json` restent intacts et
|
||||||
|
re-distribueront dès réactivation avec la même KEK.
|
||||||
|
- Révoquer un consommateur (`DELETE
|
||||||
|
/api/coffres/consommateurs/{id}`) coupe immédiatement ses
|
||||||
|
distributions, sans toucher aux autres consommateurs ni aux
|
||||||
|
secrets.
|
||||||
|
- Ne jamais « réparer » des fichiers chiffrés avec une nouvelle
|
||||||
|
KEK, ne jamais restaurer `coffres.json` sans
|
||||||
|
`coffres_valeurs.json` (et inversement), ne jamais copier la
|
||||||
|
KEK au même endroit que la sauvegarde.
|
||||||
|
|
||||||
|
## 7. Références
|
||||||
|
|
||||||
|
- Spec « Coffre de secrets ObsiGate » §6 (autorisations), §7/§10/§11
|
||||||
|
(API), §13/§14 (sauvegarde/restauration), §15/§16 (tests,
|
||||||
|
déploiement).
|
||||||
|
- Issues ObsiGate : #17 (Phase 1a), #18 (Phase 1b), #19 (Phase 1c
|
||||||
|
UI), #20 (Phase 2), #21 (Phase 3, Spark), #22 (Phase 4, TikTak /
|
||||||
|
Flowdeck / Kanbante), #23 (Phase 5, ce document).
|
||||||
|
- Suites de tests : `tests/test_coffre_secrets.py` (ObsiGate, 35
|
||||||
|
tests), `tests/test_identites_coffre_phase3.py` (agent-commun,
|
||||||
|
16 tests), `tests/test_coffre_reference.py` (TikTak, 17 fonctions
|
||||||
|
/ 26 cas), `tests/test_coffre_reference_flowdeck.py` (Flowdeck,
|
||||||
|
16 tests), `tests/test_coffre_client.py` (Kanbante, 11 fonctions
|
||||||
|
/ 25 cas). Voir le bilan Phase 5 pour la matrice de couverture
|
||||||
|
complète et les manques.
|
||||||
@@ -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_<nom>` 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.
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# ObsiGate — Roadmap
|
||||||
|
|
||||||
> **Version :** 2.80.1 | **Dernière mise à jour :** 2026-10-27
|
> **Version :** 2.81.0 | **Dernière mise à jour :** 2026-10-28
|
||||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||||
|
|||||||
+4
-4
@@ -19,8 +19,8 @@
|
|||||||
var mode = localStorage.getItem("obsigate-theme-mode") || "dark";
|
var mode = localStorage.getItem("obsigate-theme-mode") || "dark";
|
||||||
root.setAttribute("data-theme", mode);
|
root.setAttribute("data-theme", mode);
|
||||||
var light = mode === "light" || mode === "sepia";
|
var light = mode === "light" || mode === "sepia";
|
||||||
root.style.setProperty("--bg-primary", light ? "#ffffff" : "#0f1117");
|
root.style.setProperty("--bg-primary", light ? "#fbfbfa" : "#17181a");
|
||||||
root.style.setProperty("--text-primary", light ? "#1a1a1a" : "#e6edf3");
|
root.style.setProperty("--text-primary", light ? "#242624" : "#eceeeb");
|
||||||
} catch (e) { root.setAttribute("data-theme", "dark"); }
|
} catch (e) { root.setAttribute("data-theme", "dark"); }
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
@@ -41,8 +41,8 @@
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
overflow: auto; /* override SPA style.css: body{overflow:hidden} */
|
overflow: auto; /* override SPA style.css: body{overflow:hidden} */
|
||||||
height: auto; /* override SPA style.css: body{height:100vh} */
|
height: auto; /* override SPA style.css: body{height:100vh} */
|
||||||
background: var(--bg-primary, #0f1117);
|
background: var(--bg-primary, #17181a);
|
||||||
color: var(--text-primary, #e6edf3);
|
color: var(--text-primary, #eceeeb);
|
||||||
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -10,7 +10,7 @@
|
|||||||
name="description"
|
name="description"
|
||||||
content="Porte d'entrée web pour vos vaults Obsidian - Accédez, naviguez et recherchez dans toutes vos notes"
|
content="Porte d'entrée web pour vos vaults Obsidian - Accédez, naviguez et recherchez dans toutes vos notes"
|
||||||
/>
|
/>
|
||||||
<meta name="theme-color" content="#2563eb" />
|
<meta name="theme-color" content="#7c5cd6" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta
|
<meta
|
||||||
@@ -54,14 +54,14 @@
|
|||||||
#boot-splash {
|
#boot-splash {
|
||||||
position: fixed; inset: 0; z-index: 2147483647;
|
position: fixed; inset: 0; z-index: 2147483647;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
background: radial-gradient(1200px 600px at 50% -10%, #1b2030 0%, #0e1117 60%);
|
background: radial-gradient(1200px 600px at 50% -10%, #26272b 0%, #17181a 60%);
|
||||||
color: #e6e8ee; font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
color: #eceeeb; font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||||
}
|
}
|
||||||
body.app-booted #boot-splash { display: none; }
|
body.app-booted #boot-splash { display: none; }
|
||||||
.bs-card { display: flex; flex-direction: column; align-items: center; gap: 14px; }
|
.bs-card { display: flex; flex-direction: column; align-items: center; gap: 14px; }
|
||||||
.bs-logo {
|
.bs-logo {
|
||||||
width: 72px; height: 72px; display: flex; align-items: center; justify-content: center;
|
width: 72px; height: 72px; display: flex; align-items: center; justify-content: center;
|
||||||
font-size: 38px; background: #171c26; border: 1px solid #2a3242; border-radius: 18px;
|
font-size: 38px; background: #1f2023; border: 1px solid #33353a; border-radius: 18px;
|
||||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
|
||||||
}
|
}
|
||||||
.bs-title { font-size: 22px; font-weight: 600; letter-spacing: 0.3px; }
|
.bs-title { font-size: 22px; font-weight: 600; letter-spacing: 0.3px; }
|
||||||
|
|||||||
@@ -18,6 +18,11 @@
|
|||||||
* _copieManuelle) passe par un consommateur temporaire créé puis révoqué
|
* _copieManuelle) passe par un consommateur temporaire créé puis révoqué
|
||||||
* immédiatement, avec distribution auditée ; il n'existe pas encore
|
* immédiatement, avec distribution auditée ; il n'existe pas encore
|
||||||
* d'endpoint de distribution par session utilisateur côté backend.
|
* 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
|
* - La vue est rendue directement dans #content-area : elle ne passe ni
|
||||||
* par TabManager.openNav (navigation de fichiers), ni par l'indexeur,
|
* par TabManager.openNav (navigation de fichiers), ni par l'indexeur,
|
||||||
* ni par la recherche, ni par le graphe.
|
* ni par la recherche, ni par le graphe.
|
||||||
@@ -292,6 +297,9 @@ function valeurField(initialType) {
|
|||||||
else if (input) input.type = 'password';
|
else if (input) input.type = 'password';
|
||||||
});
|
});
|
||||||
eyeBtn.title = t('coffres.show_before_save');
|
eyeBtn.title = t('coffres.show_before_save');
|
||||||
|
// L'oeil doit etre enfant de wrap AVANT le premier build : insertBefore(input, eyeBtn)
|
||||||
|
// leve sinon NotFoundError et la modale ne s'ouvre jamais (bug bouton Nouveau secret).
|
||||||
|
wrap.appendChild(eyeBtn);
|
||||||
build(initialType);
|
build(initialType);
|
||||||
return {
|
return {
|
||||||
wrap,
|
wrap,
|
||||||
@@ -862,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) {
|
function openKekModal(mode) {
|
||||||
if (!isAdmin()) return;
|
if (!isAdmin()) return;
|
||||||
if (mode === 'generer') {
|
if (mode === 'generer') {
|
||||||
@@ -999,6 +1084,12 @@ export async function openCoffresView(slug, options = {}) {
|
|||||||
});
|
});
|
||||||
toolbar.appendChild(search);
|
toolbar.appendChild(search);
|
||||||
toolbar.appendChild(modalBtn(t('coffres.new_secret'), 'primary', () => openCreateSecretModal(coffre.slug)));
|
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 () => {
|
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 });
|
const ok = await showConfirm({ title: t('coffres.dek_rotate'), message: t('coffres.confirm_dek_rotate'), danger: true });
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|||||||
+67
-2
@@ -1,5 +1,5 @@
|
|||||||
/* ObsiGate — Theme System
|
/* ObsiGate — Theme System
|
||||||
* 15 themes x 2 modes (dark/light)
|
* 16 themes x 2 modes (dark/light)
|
||||||
* Persisted in localStorage as 'obsigate-theme'
|
* Persisted in localStorage as 'obsigate-theme'
|
||||||
* Usage: import { initThemes, applyTheme, getCurrentTheme, THEMES } from './themes.js'
|
* Usage: import { initThemes, applyTheme, getCurrentTheme, THEMES } from './themes.js'
|
||||||
*/
|
*/
|
||||||
@@ -45,6 +45,71 @@ var THEMES = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Thème Spark ──────────────────────────────────────────────────────
|
||||||
|
// Palette source : console Spark (agent.dracodev.net), app/console/console.css
|
||||||
|
// (variables --p-*). Mapping Spark -> ObsiGate, identique en structure pour
|
||||||
|
// dark et light ; seules les valeurs changent (sauf accent/ok/warn/err).
|
||||||
|
// Fidèle (valeur Spark directe) :
|
||||||
|
// fond -> --bg-primary, --surface2 ; surface -> --surface ;
|
||||||
|
// chip -> --bg-secondary, --tag-bg ; chip-survol -> --bg-hover ;
|
||||||
|
// bord -> --border ; chip-bord -> --border-md, --scrollbar, --resize-handle ;
|
||||||
|
// texte -> --text-primary, --text ; doux -> --text-secondary, --text-2 ;
|
||||||
|
// faint -> --text-muted, --text-3 ; champ -> --search-bg ;
|
||||||
|
// code -> --code-bg ; accent -> --accent, --accent-card, --purple, --ai ;
|
||||||
|
// lien -> --accent-text (variante lisible de l'accent sur fond teinté) ;
|
||||||
|
// ok -> --accent-green, --success, --green ;
|
||||||
|
// danger -> --danger ; danger-fond -> --danger-bg ;
|
||||||
|
// pastilles ok (fond) -> --success-bg, --green-bg ;
|
||||||
|
// pastilles ok (texte) -> --ok ; ambre -> --warn ; rouge -> --err.
|
||||||
|
// Approximations documentées (ObsiGate a plus de variables que Spark,
|
||||||
|
// ce ne sont PAS des valeurs Spark) :
|
||||||
|
// --bg-sidebar <- fond (Spark n'a pas de token sidebar dédié) ;
|
||||||
|
// --accent-bg <- agent (surface teintée de Spark, pas de teinte lavande) ;
|
||||||
|
// --surface3 <- agent ; --purple-bg <- appro-fond (fond teinté le plus
|
||||||
|
// proche, la palette Spark n'a pas de teinte lavande) ;
|
||||||
|
// --overlay-bg <- convention ObsiGate (rgba noir 0.3 clair / 0.5 sombre,
|
||||||
|
// aucun token voile dans la palette Spark).
|
||||||
|
// Non reportés (aucune variable ObsiGate correspondante dans les 39) :
|
||||||
|
// bulle utilisateur/envoi #2f6fed, titres approbation #9a6a1c / #e8bc7a.
|
||||||
|
// Une seule entrée 'spark' : dark et light sont ses deux modes, jamais
|
||||||
|
// deux thèmes séparés. Modes high-contrast/sepia générés comme pour les
|
||||||
|
// autres thèmes (à partir de dark/light).
|
||||||
|
'spark': {
|
||||||
|
name: 'Spark', desc: 'Palette de la console Spark',
|
||||||
|
modes: {
|
||||||
|
dark: {
|
||||||
|
'--bg-primary': '#17181a', '--bg-secondary': '#26272b', '--bg-sidebar': '#17181a',
|
||||||
|
'--bg-hover': '#2e3036', '--border': '#33353a', '--text-primary': '#eceeeb',
|
||||||
|
'--text-secondary': '#a6aba0', '--text-muted': '#83887e', '--accent': '#7c5cd6',
|
||||||
|
'--accent-green': '#2ea36b', '--tag-bg': '#26272b', '--tag-text': '#7c5cd6',
|
||||||
|
'--code-bg': '#2c2e33', '--search-bg': '#1f2023', '--scrollbar': '#3a3c41',
|
||||||
|
'--resize-handle': '#3a3c41', '--overlay-bg': 'rgba(0,0,0,0.5)', '--danger': '#e8a08d',
|
||||||
|
'--danger-bg': '#40241f', '--success': '#2ea36b', '--success-bg': '#173d29',
|
||||||
|
'--accent-card': '#7c5cd6', '--accent-bg': '#26272b', '--accent-text': '#b39df5',
|
||||||
|
'--green': '#2ea36b', '--green-bg': '#173d29', '--purple': '#7c5cd6',
|
||||||
|
'--purple-bg': '#2a2118', '--surface': '#1f2023', '--surface2': '#17181a',
|
||||||
|
'--surface3': '#26272b', '--border-md': '#3a3c41', '--text': '#eceeeb',
|
||||||
|
'--text-2': '#a6aba0', '--text-3': '#83887e', '--ok': '#7fd6a8',
|
||||||
|
'--warn': '#e8a13c', '--ai': '#7c5cd6', '--err': '#cf4b32'
|
||||||
|
},
|
||||||
|
light: {
|
||||||
|
'--bg-primary': '#fbfbfa', '--bg-secondary': '#f4f5f1', '--bg-sidebar': '#fbfbfa',
|
||||||
|
'--bg-hover': '#e9ebe4', '--border': '#e4e5e0', '--text-primary': '#242624',
|
||||||
|
'--text-secondary': '#6c7268', '--text-muted': '#8a8f84', '--accent': '#7c5cd6',
|
||||||
|
'--accent-green': '#2ea36b', '--tag-bg': '#f4f5f1', '--tag-text': '#7c5cd6',
|
||||||
|
'--code-bg': '#e8eae3', '--search-bg': '#ffffff', '--scrollbar': '#d6d8d1',
|
||||||
|
'--resize-handle': '#d6d8d1', '--overlay-bg': 'rgba(0,0,0,0.3)', '--danger': '#a03a28',
|
||||||
|
'--danger-bg': '#f6e3de', '--success': '#2ea36b', '--success-bg': '#dff2e7',
|
||||||
|
'--accent-card': '#7c5cd6', '--accent-bg': '#eef0ea', '--accent-text': '#5b3df0',
|
||||||
|
'--green': '#2ea36b', '--green-bg': '#dff2e7', '--purple': '#7c5cd6',
|
||||||
|
'--purple-bg': '#fdf6ec', '--surface': '#ffffff', '--surface2': '#fbfbfa',
|
||||||
|
'--surface3': '#eef0ea', '--border-md': '#d6d8d1', '--text': '#242624',
|
||||||
|
'--text-2': '#6c7268', '--text-3': '#8a8f84', '--ok': '#1d7a4c',
|
||||||
|
'--warn': '#e8a13c', '--ai': '#7c5cd6', '--err': '#cf4b32'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
'github': {
|
'github': {
|
||||||
name: 'GitHub', desc: 'Contraste doux',
|
name: 'GitHub', desc: 'Contraste doux',
|
||||||
modes: {
|
modes: {
|
||||||
@@ -523,7 +588,7 @@ var THEMES = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Theme Engine ─────────────────────────────────────────────────────
|
// ── Theme Engine ─────────────────────────────────────────────────────
|
||||||
var _currentTheme = 'defaut-obsigate';
|
var _currentTheme = 'spark';
|
||||||
var _currentMode = 'dark';
|
var _currentMode = 'dark';
|
||||||
|
|
||||||
var AVAILABLE_MODES = ['dark', 'light', 'high-contrast', 'sepia'];
|
var AVAILABLE_MODES = ['dark', 'light', 'high-contrast', 'sepia'];
|
||||||
|
|||||||
@@ -2757,5 +2757,16 @@
|
|||||||
"coffres.type": "Type",
|
"coffres.type": "Type",
|
||||||
"coffres.value": "Value",
|
"coffres.value": "Value",
|
||||||
"coffres.value_required": "A value is required.",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2757,5 +2757,16 @@
|
|||||||
"coffres.type": "Type",
|
"coffres.type": "Type",
|
||||||
"coffres.value": "Valeur",
|
"coffres.value": "Valeur",
|
||||||
"coffres.value_required": "Une valeur est requise.",
|
"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é"
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-44
@@ -11,58 +11,58 @@
|
|||||||
|
|
||||||
/* ── Editor save dot state colors ── */
|
/* ── Editor save dot state colors ── */
|
||||||
:root {
|
:root {
|
||||||
--ok: #34d399;
|
--ok: #7fd6a8;
|
||||||
--warn: #fbbf24;
|
--warn: #e8a13c;
|
||||||
--ai: #8b5cf6;
|
--ai: #7c5cd6;
|
||||||
--err: #f87171;
|
--err: #cf4b32;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== THEME — DARK (fallback default, no data-theme) ===== */
|
/* ===== THEME — DARK (fallback default, no data-theme) ===== */
|
||||||
:root {
|
:root {
|
||||||
--bg-primary: #0f1117;
|
--bg-primary: #17181a;
|
||||||
--bg-secondary: #161b22;
|
--bg-secondary: #26272b;
|
||||||
--bg-sidebar: #13161d;
|
--bg-sidebar: #17181a;
|
||||||
--bg-hover: #1f2430;
|
--bg-hover: #2e3036;
|
||||||
--border: #21262d;
|
--border: #33353a;
|
||||||
--text-primary: #e6edf3;
|
--text-primary: #eceeeb;
|
||||||
--text-secondary: #8b949e;
|
--text-secondary: #a6aba0;
|
||||||
--text-muted: #484f58;
|
--text-muted: #83887e;
|
||||||
--accent: #58a6ff;
|
--accent: #7c5cd6;
|
||||||
--accent-green: #3fb950;
|
--accent-green: #2ea36b;
|
||||||
--tag-bg: #1f2a3a;
|
--tag-bg: #26272b;
|
||||||
--tag-text: #79c0ff;
|
--tag-text: #7c5cd6;
|
||||||
--code-bg: #161b22;
|
--code-bg: #2c2e33;
|
||||||
--search-bg: #21262d;
|
--search-bg: #1f2023;
|
||||||
--scrollbar: #30363d;
|
--scrollbar: #3a3c41;
|
||||||
--resize-handle: #30363d;
|
--resize-handle: #3a3c41;
|
||||||
--overlay-bg: rgba(0, 0, 0, 0.5);
|
--overlay-bg: rgba(0, 0, 0, 0.5);
|
||||||
--danger: #ff7b72;
|
--danger: #e8a08d;
|
||||||
--danger-bg: #3d1a18;
|
--danger-bg: #40241f;
|
||||||
--success: #3fb950;
|
--success: #2ea36b;
|
||||||
--success-bg: #1a3d1f;
|
--success-bg: #173d29;
|
||||||
--accent-card: #58a6ff;
|
--accent-card: #7c5cd6;
|
||||||
--accent-bg: #1f2a3a;
|
--accent-bg: #26272b;
|
||||||
--accent-text: #79c0ff;
|
--accent-text: #b39df5;
|
||||||
--green: #3fb950;
|
--green: #2ea36b;
|
||||||
--green-bg: #1a3d1f;
|
--green-bg: #173d29;
|
||||||
--purple: #a371f7;
|
--purple: #7c5cd6;
|
||||||
--purple-bg: #2d1b4e;
|
--purple-bg: #2a2118;
|
||||||
--surface: #161b22;
|
--surface: #1f2023;
|
||||||
--surface2: #0f1117;
|
--surface2: #17181a;
|
||||||
--surface3: #0d0f14;
|
--surface3: #26272b;
|
||||||
--border-md: #30363d;
|
--border-md: #3a3c41;
|
||||||
--text: #e6edf3;
|
--text: #eceeeb;
|
||||||
--text-2: #8b949e;
|
--text-2: #a6aba0;
|
||||||
--text-3: #484f58;
|
--text-3: #83887e;
|
||||||
--mono: "JetBrains Mono", monospace;
|
--mono: "JetBrains Mono", monospace;
|
||||||
--radius: 6px;
|
--radius: 6px;
|
||||||
--radius-lg: 10px;
|
--radius-lg: 10px;
|
||||||
/* #154-A1 — data-grid tokens: the sheet reads slightly lighter than the
|
/* #154-A1 — data-grid tokens: the sheet reads slightly lighter than the
|
||||||
chrome (toolbar/panels), headers are clearly distinct from cells. */
|
chrome (toolbar/panels), headers are clearly distinct from cells. */
|
||||||
--grid-bg: #161b22;
|
--grid-bg: #1f2023;
|
||||||
--grid-header-bg: #1f2430;
|
--grid-header-bg: #2e3036;
|
||||||
--grid-header-text: #e6edf3;
|
--grid-header-text: #eceeeb;
|
||||||
--grid-border: #3d444d;
|
--grid-border: #33353a;
|
||||||
--grid-zebra: rgba(255, 255, 255, 0.025);
|
--grid-zebra: rgba(255, 255, 255, 0.025);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7700,6 +7700,8 @@ body.popup-mode .content-area {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: opacity 200ms ease;
|
transition: opacity 200ms ease;
|
||||||
@@ -7715,8 +7717,16 @@ body.popup-mode .content-area {
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
|
||||||
min-width: 400px;
|
/* Correctif Phase 2 (modale de création de secret coupée en haut) :
|
||||||
max-width: 600px;
|
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);
|
transform: scale(0.9);
|
||||||
transition: transform 200ms ease;
|
transition: transform 200ms ease;
|
||||||
}
|
}
|
||||||
@@ -7731,6 +7741,7 @@ body.popup-mode .content-area {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obsigate-modal-title {
|
.obsigate-modal-title {
|
||||||
@@ -7742,6 +7753,8 @@ body.popup-mode .content-area {
|
|||||||
|
|
||||||
.obsigate-modal-body {
|
.obsigate-modal-body {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obsigate-modal-footer {
|
.obsigate-modal-footer {
|
||||||
@@ -7750,6 +7763,7 @@ body.popup-mode .content-area {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-input {
|
.modal-input {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsigate",
|
"name": "obsigate",
|
||||||
"version": "2.80.1",
|
"version": "2.81.0",
|
||||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||||
"main": "patch.js",
|
"main": "patch.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -455,3 +456,225 @@ class TestDistribution:
|
|||||||
assert "secret_cree" in actions
|
assert "secret_cree" in actions
|
||||||
assert "distribution_ok" in actions
|
assert "distribution_ok" in actions
|
||||||
assert "valeur" not in json.dumps(resp.json())
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user