From ff1ccca3cd83bc4a5d8f258f6c351bfc2aa1e2a5 Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 27 Oct 2026 21:45:32 -0400 Subject: [PATCH 01/12] [Coffre Secrets] Phase 1a : chiffrement par enveloppe AES-256-GCM (KEK/DEK) --- backend/coffre_crypto.py | 204 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 backend/coffre_crypto.py diff --git a/backend/coffre_crypto.py b/backend/coffre_crypto.py new file mode 100644 index 0000000..438773b --- /dev/null +++ b/backend/coffre_crypto.py @@ -0,0 +1,204 @@ +"""Chiffrement par enveloppe du coffre Secrets ObsiGate (Phase 1a). + +Principe (spec §5) : deux niveaux de clés, jamais de valeur en clair au repos. + +- KEK : clé maître 256 bits, fournie par l'opérateur via ``OBSIGATE_KEK`` + (base64, 32 octets) ou ``OBSIGATE_KEK_FILE`` (fichier 0600, hors dépôt, + hors arborescence des vaults). Elle n'est JAMAIS dérivée du secret + d'application, jamais journalisée, jamais renvoyée par API. +- DEK : une clé aléatoire par coffre, stockée uniquement chiffrée par la KEK. +- Version de secret : ciphertext AES-256-GCM + nonce unique + tag GCM + (le tag est inclus dans le ciphertext renvoyé par ``AESGCM.encrypt``). + Un tag invalide ou un fichier altéré = refus, jamais de valeur fausse. + +Le module reste importable sans ``cryptography`` installé : les opérations +échouent alors avec ``CryptoIndisponibleError`` au lieu de casser le +démarrage de l'application (le routeur répond 503 dans ce cas). +""" + +from __future__ import annotations + +import base64 +import binascii +import logging +import os +import stat +from pathlib import Path + +logger = logging.getLogger("obsigate.coffre_crypto") + +try: # cryptography est une dépendance de la Phase 1a (requirements.txt) + from cryptography.exceptions import InvalidTag + from cryptography.hazmat.primitives.ciphers.aead import AESGCM +except Exception: # pragma: no cover - environnement sans cryptography + AESGCM = None # type: ignore[assignment] + InvalidTag = Exception # type: ignore[assignment,misc] + + +class CoffreCryptoError(Exception): + """Erreur générique du chiffrement du coffre.""" + + +class KEKAbsenteError(CoffreCryptoError): + """Aucune KEK configurée (ni OBSIGATE_KEK, ni OBSIGATE_KEK_FILE).""" + + +class KEKInvalideError(CoffreCryptoError): + """KEK mal formée (ni base64 32 octets, ni hex 64 caractères).""" + + +class TagInvalideError(CoffreCryptoError): + """Authentification GCM échouée : données altérées ou mauvaise clé.""" + + +class CryptoIndisponibleError(CoffreCryptoError): + """La bibliothèque ``cryptography`` n'est pas installée.""" + + +def _require_aesgcm() -> None: + if AESGCM is None: + raise CryptoIndisponibleError( + "cryptography n'est pas installé (dépendance Phase 1a)" + ) + + +def parse_kek(raw: str) -> bytes: + """Parse une KEK : base64 (32 octets) ou hex (64 caractères). + + Accepte le base64 standard et URL-safe, avec ou sans padding. + """ + value = (raw or "").strip() + if not value: + raise KEKInvalideError("KEK vide") + # Hex d'abord si la forme correspond (64 caractères hex). + if len(value) == 64: + try: + decoded = bytes.fromhex(value) + except ValueError: + decoded = b"" + if len(decoded) == 32: + return decoded + padded = value + "=" * (-len(value) % 4) + for decoder in (base64.urlsafe_b64decode, base64.b64decode): + try: + decoded = decoder(padded) + except (binascii.Error, ValueError): + continue + if len(decoded) == 32: + return decoded + raise KEKInvalideError("KEK invalide : attendu base64 de 32 octets") + + +def generate_kek_b64() -> str: + """Génère une KEK 256 bits, encodée base64 (à sauvegarder hors dépôt).""" + return base64.b64encode(os.urandom(32)).decode("ascii") + + +def load_kek() -> bytes: + """Charge la KEK depuis l'environnement ou le fichier dédié. + + Priorité : ``OBSIGATE_KEK`` puis ``OBSIGATE_KEK_FILE``. Le fichier doit + être en 0600 (aucun droit groupe/autre) ; sinon, refus explicite. + """ + env_kek = os.environ.get("OBSIGATE_KEK", "").strip() + if env_kek: + return parse_kek(env_kek) + kek_file = os.environ.get("OBSIGATE_KEK_FILE", "").strip() + if kek_file: + path = Path(kek_file) + try: + mode = path.stat().st_mode + except OSError as exc: + raise KEKAbsenteError(f"Fichier KEK illisible : {kek_file}") from exc + if mode & (stat.S_IRWXG | stat.S_IRWXO): + raise KEKInvalideError( + "Fichier KEK trop permissif : attendu 0600, " + f"droits groupe/autre détectés sur {kek_file}" + ) + try: + return parse_kek(path.read_text(encoding="utf-8")) + except OSError as exc: + raise KEKAbsenteError(f"Fichier KEK illisible : {kek_file}") from exc + raise KEKAbsenteError("Aucune KEK configurée (OBSIGATE_KEK / OBSIGATE_KEK_FILE)") + + +def kek_presente() -> bool: + """True si une KEK valide est chargeable, False sinon (jamais d'exception).""" + try: + load_kek() + except CoffreCryptoError: + return False + return True + + +def generate_dek() -> bytes: + """Génère une DEK aléatoire 256 bits pour un coffre.""" + return os.urandom(32) + + +def _b64(data: bytes) -> str: + return base64.b64encode(data).decode("ascii") + + +def _unb64(value: str) -> bytes: + return base64.b64decode(value) + + +def encrypt_with_key(key: bytes, plaintext: bytes, aad: bytes) -> dict: + """Chiffre avec AES-256-GCM. Retourne {nonce, ciphertext} en base64.""" + _require_aesgcm() + nonce = os.urandom(12) + ciphertext = AESGCM(key).encrypt(nonce, plaintext, aad) + return {"nonce": _b64(nonce), "ciphertext": _b64(ciphertext)} + + +def decrypt_with_key(key: bytes, nonce_b64: str, ciphertext_b64: str, aad: bytes) -> bytes: + """Déchiffre avec AES-256-GCM. Tag invalide -> TagInvalideError.""" + _require_aesgcm() + try: + return AESGCM(key).decrypt(_unb64(nonce_b64), _unb64(ciphertext_b64), aad) + except InvalidTag as exc: + raise TagInvalideError("Tag GCM invalide : données altérées ou mauvaise clé") from exc + except (binascii.Error, ValueError) as exc: + raise TagInvalideError("Données chiffrées mal formées") from exc + + +def wrap_dek(dek: bytes, kek: bytes, coffre_id: str) -> dict: + """Chiffre une DEK avec la KEK (AAD = identifiant du coffre).""" + return encrypt_with_key(kek, dek, coffre_id.encode("utf-8")) + + +def unwrap_dek(wrapped: dict, kek: bytes, coffre_id: str) -> bytes: + """Déchiffre une DEK avec la KEK.""" + return decrypt_with_key( + kek, wrapped["nonce"], wrapped["ciphertext"], coffre_id.encode("utf-8") + ) + + +def encrypt_secret_value(dek: bytes, secret_id: str, version: int, valeur: str) -> dict: + """Chiffre une valeur de secret (AAD = secret_id:version).""" + aad = f"{secret_id}:{version}".encode("utf-8") + return encrypt_with_key(dek, valeur.encode("utf-8"), aad) + + +def decrypt_secret_value( + dek: bytes, secret_id: str, version: int, nonce_b64: str, ciphertext_b64: str +) -> str: + """Déchiffre une valeur de secret.""" + aad = f"{secret_id}:{version}".encode("utf-8") + return decrypt_with_key(dek, nonce_b64, ciphertext_b64, aad).decode("utf-8") + + +def reencrypt_secret_value( + ancienne_dek: bytes, + nouvelle_dek: bytes, + secret_id: str, + version: int, + nonce_b64: str, + ciphertext_b64: str, +) -> dict: + """Re-chiffre une version avec une nouvelle DEK (rotation DEK).""" + valeur = decrypt_secret_value( + ancienne_dek, secret_id, version, nonce_b64, ciphertext_b64 + ) + return encrypt_secret_value(nouvelle_dek, secret_id, version, valeur) -- 2.54.0 From 02394d32789f1db18aa783276a5938f0817dbee4 Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 27 Oct 2026 21:45:35 -0400 Subject: [PATCH 02/12] [Coffre Secrets] Phase 1a : store coffres/valeurs/octrois/consommateurs (0600, atomique, RLock) --- backend/coffre_store.py | 860 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 860 insertions(+) create mode 100644 backend/coffre_store.py diff --git a/backend/coffre_store.py b/backend/coffre_store.py new file mode 100644 index 0000000..e731dca --- /dev/null +++ b/backend/coffre_store.py @@ -0,0 +1,860 @@ +"""Store du coffre Secrets ObsiGate (Phase 1a). + +Patron repris de ``backend/tools/secrets.py`` : JSON dans ``data/``, +écriture atomique (tmp + rename), verrou ``RLock`` sur les cycles +lecture-modification-écriture, répertoire honorant ``OBSIGATE_DATA_DIR``. + +Fichiers (permissions 0600) : +- ``data/coffres.json`` : coffres (métadonnées + DEK chiffrées), + consommateurs (jetons hachés SHA-256 seulement, jamais en clair), + octrois (références, statuts, expirations). +- ``data/coffres_valeurs.json`` : versions chiffrées des secrets. + +Aucune valeur en clair n'est jamais persistée ici, et aucune fonction de +ce module ne journalise de valeur ni de texte chiffré. + +Modèle : +- Coffre : id, slug, nom, description, proprietaire, perimetre + (personnel | partage | instance), dek_chiffree (ou None en mode + dégradé sans KEK), statut (actif | archive), dates. +- Secret : id, coffre_id, nom, type, service, note, expiration, + version_courante, cree_le, modifie_le, derniere_utilisation. +- Version : numero, nonce, ciphertext, cree_le, remplacee_le. + Seule la version courante est distribuable. +- Consommateur : id (sert de jti), nom, jeton_hash, jeton_prefixe, + statut (actif | revoque), cree_par, cree_le. +- Octroi : id, coffre_id, secret_id (None = tout le coffre), + cible_type (consommateur | utilisateur | executeur | workspace), + cible_id, expiration, statut (actif | revoque | expire), + cree_par, cree_le, revoque_le. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +import os +import re +import secrets as _secrets +import threading +import uuid +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from backend.coffre_crypto import ( + KEKAbsenteError, + generate_dek, + load_kek, + parse_kek, + reencrypt_secret_value, + unwrap_dek, + wrap_dek, + encrypt_secret_value, + decrypt_secret_value, +) + +logger = logging.getLogger("obsigate.coffre_store") + +_lock = threading.RLock() + +SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{2,63}$") +NOM_SECRET_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{2,127}$") +TYPES_SECRET = {"api_key", "token", "password", "certificate", "autre"} +PERIMETRES = {"personnel", "partage", "instance"} +CIBLES_OCTROI = {"consommateur", "utilisateur", "executeur", "workspace"} + +MAX_VALEUR = 65536 +MAX_TEXTE = 2000 +DUREE_OCTROI_DEFAUT_JOURS = 90 + +# Motifs refusés dans les champs de métadonnées (description, note) : +# une valeur ressemblant à un secret ne doit jamais transiter par un champ +# qui, lui, est renvoyé en clair dans les métadonnées. +_SECRET_LIKE_PATTERNS = ( + re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"), + re.compile(r"\bsk-[A-Za-z0-9]{8,}\b"), + re.compile(r"\bgh[opsu]_[A-Za-z0-9]{8,}\b"), + re.compile(r"\bAKIA[0-9A-Z]{16}\b"), + re.compile( + r"(?i)\b(token|secret|mot_de_passe|password|api[-_]?key)\s*[:=]\s*\S+" + ), +) + + +class StoreError(Exception): + """Erreur générique du store.""" + + +class CoffreExisteError(StoreError): + pass + + +class SecretExisteError(StoreError): + pass + + +class IntrouvableError(StoreError): + pass + + +class ValidationError(StoreError): + pass + + +class SecretExpireError(StoreError): + pass + + +def resolve_data_dir(data_dir: str | Path | None = None) -> Path: + """Répertoire de persistance (OBSIGATE_DATA_DIR, défaut ``data``).""" + if data_dir is not None: + return Path(data_dir) + return Path(os.environ.get("OBSIGATE_DATA_DIR", "data")) + + +def _coffres_path(base: Path) -> Path: + return base / "coffres.json" + + +def _valeurs_path(base: Path) -> Path: + return base / "coffres_valeurs.json" + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _parse_date(value: str | None) -> datetime | None: + """Parse une date ISO (datetime) ou une date seule (fin de journée UTC).""" + if value is None: + return None + text = str(value).strip() + if not text: + return None + if len(text) == 10: + text = text + "T23:59:59+00:00" + try: + parsed = datetime.fromisoformat(text) + except ValueError as exc: + raise ValidationError(f"Date invalide : {value!r}") from exc + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed + + +def est_expire(expiration: str | None, maintenant: datetime | None = None) -> bool: + """True si la date d'expiration est passée (None = jamais).""" + parsed = _parse_date(expiration) + if parsed is None: + return False + ref = maintenant or datetime.now(timezone.utc) + return parsed <= ref + + +def _read_json(path: Path, default: dict) -> dict: + if not path.exists(): + return json.loads(json.dumps(default)) + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + logger.warning("Store coffre illisible (%s) : %s", path, exc) + return json.loads(json.dumps(default)) + return data if isinstance(data, dict) else json.loads(json.dumps(default)) + + +def _write_json(path: Path, data: dict) -> None: + """Écriture atomique tmp+rename, fichier final en 0600.""" + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.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 _load_coffres(base: Path) -> dict: + return _read_json( + _coffres_path(base), + {"coffres": [], "consommateurs": [], "octrois": []}, + ) + + +def _load_valeurs(base: Path) -> dict: + return _read_json(_valeurs_path(base), {"versions": {}}) + + +def _save_coffres(base: Path, data: dict) -> None: + _write_json(_coffres_path(base), data) + + +def _save_valeurs(base: Path, data: dict) -> None: + _write_json(_valeurs_path(base), data) + + +def valider_slug(slug: str) -> str: + value = (slug or "").strip().lower() + if not SLUG_RE.match(value): + raise ValidationError( + "Slug invalide : 3 à 64 caractères, minuscules, chiffres, - ou _" + ) + return value + + +def valider_nom_secret(nom: str) -> str: + value = (nom or "").strip().lower() + if not NOM_SECRET_RE.match(value): + raise ValidationError( + "Nom de secret invalide : 3 à 128 caractères, minuscules, chiffres, - ou _" + ) + return value + + +def refuser_valeur_dans_metadonnees(texte: str | None, champ: str) -> None: + """Refuse une valeur ressemblant à un secret dans un champ métadonnée.""" + if not texte: + return + for motif in _SECRET_LIKE_PATTERNS: + if motif.search(texte): + raise ValidationError( + f"Le champ {champ} ressemble à une valeur de secret ; " + "utilisez le champ valeur du secret, jamais une métadonnée" + ) + + +def _valider_texte(texte: str | None, champ: str) -> str: + value = (texte or "").strip() + if len(value) > MAX_TEXTE: + raise ValidationError(f"Le champ {champ} dépasse {MAX_TEXTE} caractères") + refuser_valeur_dans_metadonnees(value, champ) + return value + + +# --------------------------------------------------------------------------- +# Coffres +# --------------------------------------------------------------------------- + +def list_coffres(data_dir: str | Path | None = None) -> list: + base = resolve_data_dir(data_dir) + with _lock: + return list(_load_coffres(base)["coffres"]) + + +def get_coffre(slug: str, data_dir: str | Path | None = None) -> dict | None: + base = resolve_data_dir(data_dir) + slug = valider_slug(slug) + with _lock: + for coffre in _load_coffres(base)["coffres"]: + if coffre["slug"] == slug: + return coffre + return None + + +def get_coffre_or_404(slug: str, data_dir: str | Path | None = None) -> dict: + coffre = get_coffre(slug, data_dir) + if coffre is None: + raise IntrouvableError(f"Coffre introuvable : {slug}") + return coffre + + +def get_coffre_by_id(coffre_id: str, data_dir: str | Path | None = None) -> dict | None: + base = resolve_data_dir(data_dir) + with _lock: + for coffre in _load_coffres(base)["coffres"]: + if coffre["id"] == coffre_id: + return coffre + return None + + +def get_octroi(octroi_id: str, data_dir: str | Path | None = None) -> dict | None: + base = resolve_data_dir(data_dir) + with _lock: + for octroi in _load_coffres(base)["octrois"]: + if octroi["id"] == octroi_id: + return octroi + return None + + +def create_coffre( + slug: str, + nom: str, + proprietaire: str, + perimetre: str = "personnel", + description: str = "", + data_dir: str | Path | None = None, +) -> dict: + """Crée un coffre. Sans KEK, la DEK reste None (mode dégradé) : les + métadonnées sont créées, mais aucun secret ne pourra être ajouté tant + que la KEK n'est pas configurée.""" + base = resolve_data_dir(data_dir) + slug = valider_slug(slug) + if perimetre not in PERIMETRES: + raise ValidationError(f"Périmètre invalide : {perimetre}") + if not (proprietaire or "").strip(): + raise ValidationError("Propriétaire requis") + description = _valider_texte(description, "description") + nom = (nom or "").strip() + if not nom or len(nom) > 128: + raise ValidationError("Nom de coffre requis (128 caractères max)") + with _lock: + data = _load_coffres(base) + if any(c["slug"] == slug for c in data["coffres"]): + raise CoffreExisteError(f"Coffre déjà existant : {slug}") + coffre = { + "id": str(uuid.uuid4()), + "slug": slug, + "nom": nom, + "description": description, + "proprietaire": proprietaire.strip(), + "perimetre": perimetre, + "dek_chiffree": None, + "statut": "actif", + "cree_le": _now_iso(), + } + try: + kek = load_kek() + except Exception: + kek = None + if kek is not None: + coffre["dek_chiffree"] = wrap_dek(generate_dek(), kek, coffre["id"]) + data["coffres"].append(coffre) + _save_coffres(base, data) + return coffre + + +def ensure_dek(coffre: dict, data_dir: str | Path | None = None) -> bytes: + """Retourne la DEK en clair (mémoire seulement). Génère et persiste la + DEK si le coffre a été créé en mode dégradé et que la KEK est là.""" + base = resolve_data_dir(data_dir) + kek = load_kek() # lève KEKAbsenteError si aucune KEK + if coffre.get("dek_chiffree"): + return unwrap_dek(coffre["dek_chiffree"], kek, coffre["id"]) + with _lock: + data = _load_coffres(base) + for item in data["coffres"]: + if item["id"] == coffre["id"]: + if item.get("dek_chiffree"): + return unwrap_dek(item["dek_chiffree"], kek, item["id"]) + dek = generate_dek() + item["dek_chiffree"] = wrap_dek(dek, kek, item["id"]) + _save_coffres(base, data) + return dek + raise IntrouvableError("Coffre introuvable pour génération de DEK") + + +# Les secrets vivent dans coffres.json (métadonnées), leurs versions dans +# coffres_valeurs.json (chiffrées). Les deux chargements sont faits ici +# pour garder une seule source de vérité dans ce module. +def _load_tout(base: Path) -> tuple[dict, dict]: + data = _load_coffres(base) + if "secrets" not in data: + data["secrets"] = [] + return data, _load_valeurs(base) + + +def compter_secrets(coffre_id: str, data_dir: str | Path | None = None) -> int: + base = resolve_data_dir(data_dir) + with _lock: + data, _ = _load_tout(base) + return sum(1 for s in data["secrets"] if s["coffre_id"] == coffre_id) + + +def list_secrets(coffre_id: str, data_dir: str | Path | None = None) -> list: + base = resolve_data_dir(data_dir) + with _lock: + data, _ = _load_tout(base) + return [s for s in data["secrets"] if s["coffre_id"] == coffre_id] + + +def get_secret( + coffre_id: str, nom: str, data_dir: str | Path | None = None +) -> dict | None: + base = resolve_data_dir(data_dir) + nom = valider_nom_secret(nom) + with _lock: + data, _ = _load_tout(base) + for secret in data["secrets"]: + if secret["coffre_id"] == coffre_id and secret["nom"] == nom: + return secret + return None + + +def secret_metadata(secret: dict, octrois_actifs: int = 0) -> dict: + """Vue métadonnées seules d'un secret : jamais de valeur, jamais de + texte chiffré, jamais de nonce.""" + return { + "id": secret["id"], + "nom": secret["nom"], + "type": secret["type"], + "service": secret.get("service", ""), + "note": secret.get("note", ""), + "expiration": secret.get("expiration"), + "expire": est_expire(secret.get("expiration")), + "version": secret["version_courante"], + "has_value": True, + "cree_le": secret["cree_le"], + "modifie_le": secret["modifie_le"], + "derniere_utilisation": secret.get("derniere_utilisation"), + "octrois_actifs": octrois_actifs, + "reference": f"coffre:{secret['coffre_slug']}/{secret['nom']}", + } + + +def create_secret( + coffre: dict, + nom: str, + type_secret: str, + valeur: str, + service: str = "", + expiration: str | None = None, + note: str = "", + data_dir: str | Path | None = None, +) -> dict: + """Chiffre et stocke un secret (nouvelle version 1).""" + base = resolve_data_dir(data_dir) + nom = valider_nom_secret(nom) + if type_secret not in TYPES_SECRET: + raise ValidationError(f"Type de secret invalide : {type_secret}") + if not valeur or len(valeur) > MAX_VALEUR: + raise ValidationError("Valeur requise (64 KiB max)") + service = (service or "").strip() + if len(service) > 128: + raise ValidationError("Service trop long (128 caractères max)") + note = _valider_texte(note, "note") + if expiration is not None: + _parse_date(expiration) + dek = ensure_dek(coffre, base) # KEKAbsenteError si pas de KEK + with _lock: + data, valeurs = _load_tout(base) + if any( + s["coffre_id"] == coffre["id"] and s["nom"] == nom + for s in data["secrets"] + ): + raise SecretExisteError(f"Secret déjà existant : {nom}") + secret_id = str(uuid.uuid4()) + chiffre = encrypt_secret_value(dek, secret_id, 1, valeur) + secret = { + "id": secret_id, + "coffre_id": coffre["id"], + "coffre_slug": coffre["slug"], + "nom": nom, + "type": type_secret, + "service": service, + "note": note, + "expiration": expiration, + "version_courante": 1, + "cree_le": _now_iso(), + "modifie_le": _now_iso(), + "derniere_utilisation": None, + } + data["secrets"].append(secret) + valeurs["versions"][secret_id] = [ + { + "numero": 1, + "nonce": chiffre["nonce"], + "ciphertext": chiffre["ciphertext"], + "cree_le": secret["cree_le"], + "remplacee_le": None, + } + ] + _save_coffres(base, data) + _save_valeurs(base, valeurs) + return secret + + +def rotate_secret( + coffre: dict, nom: str, valeur: str, data_dir: str | Path | None = None +) -> dict: + """Rotation : nouvelle version active immédiatement, l'ancienne n'est + plus distribuable (seule la version courante est lue à la distribution).""" + base = resolve_data_dir(data_dir) + nom = valider_nom_secret(nom) + if not valeur or len(valeur) > MAX_VALEUR: + raise ValidationError("Valeur requise (64 KiB max)") + dek = ensure_dek(coffre, base) + with _lock: + data, valeurs = _load_tout(base) + secret = None + for item in data["secrets"]: + if item["coffre_id"] == coffre["id"] and item["nom"] == nom: + secret = item + break + if secret is None: + raise IntrouvableError(f"Secret introuvable : {nom}") + nouvelle_version = secret["version_courante"] + 1 + chiffre = encrypt_secret_value(dek, secret["id"], nouvelle_version, valeur) + versions = valeurs["versions"].setdefault(secret["id"], []) + for version in versions: + if version["remplacee_le"] is None: + version["remplacee_le"] = _now_iso() + versions.append( + { + "numero": nouvelle_version, + "nonce": chiffre["nonce"], + "ciphertext": chiffre["ciphertext"], + "cree_le": _now_iso(), + "remplacee_le": None, + } + ) + secret["version_courante"] = nouvelle_version + secret["modifie_le"] = _now_iso() + _save_coffres(base, data) + _save_valeurs(base, valeurs) + return secret + + +def delete_secret( + coffre: dict, nom: str, data_dir: str | Path | None = None +) -> tuple[dict, list]: + """Supprime un secret, ses versions et ses octrois (cascade).""" + base = resolve_data_dir(data_dir) + nom = valider_nom_secret(nom) + with _lock: + data, valeurs = _load_tout(base) + secret = None + for item in data["secrets"]: + if item["coffre_id"] == coffre["id"] and item["nom"] == nom: + secret = item + break + if secret is None: + raise IntrouvableError(f"Secret introuvable : {nom}") + data["secrets"] = [s for s in data["secrets"] if s["id"] != secret["id"]] + valeurs["versions"].pop(secret["id"], None) + octrois_supprimes = [ + o for o in data["octrois"] if o.get("secret_id") == secret["id"] + ] + data["octrois"] = [ + o for o in data["octrois"] if o.get("secret_id") != secret["id"] + ] + _save_coffres(base, data) + _save_valeurs(base, valeurs) + return secret, octrois_supprimes + + +def distribuer_valeur( + coffre: dict, secret: dict, data_dir: str | Path | None = None +) -> str: + """Déchiffre la version courante d'un secret (après contrôles d'octroi + faits par le routeur). Refuse un secret expiré (410 côté routeur).""" + base = resolve_data_dir(data_dir) + if est_expire(secret.get("expiration")): + raise SecretExpireError("Secret expiré") + dek = ensure_dek(coffre, base) + with _lock: + _, valeurs = _load_tout(base) + versions = valeurs["versions"].get(secret["id"], []) + courante = None + for version in versions: + if version["numero"] == secret["version_courante"]: + courante = version + break + if courante is None: + raise IntrouvableError("Version courante introuvable") + return decrypt_secret_value( + dek, + secret["id"], + secret["version_courante"], + courante["nonce"], + courante["ciphertext"], + ) + + +def toucher_utilisation(secret_id: str, data_dir: str | Path | None = None) -> None: + base = resolve_data_dir(data_dir) + with _lock: + data, _ = _load_tout(base) + for secret in data["secrets"]: + if secret["id"] == secret_id: + secret["derniere_utilisation"] = _now_iso() + _save_coffres(base, data) + return + + +def rotate_dek(coffre_slug: str, data_dir: str | Path | None = None) -> dict: + """Rotation DEK : re-chiffre toutes les versions du coffre avec une + nouvelle DEK, puis remplace la DEK chiffrée du coffre.""" + base = resolve_data_dir(data_dir) + coffre = get_coffre_or_404(coffre_slug, base) + kek = load_kek() + ancienne_dek = ensure_dek(coffre, base) + nouvelle_dek = generate_dek() + with _lock: + data, valeurs = _load_tout(base) + secrets_coffre = [s for s in data["secrets"] if s["coffre_id"] == coffre["id"]] + for secret in secrets_coffre: + nouvelles_versions = [] + for version in valeurs["versions"].get(secret["id"], []): + nouvelles_versions.append( + { + **reencrypt_secret_value( + ancienne_dek, + nouvelle_dek, + secret["id"], + version["numero"], + version["nonce"], + version["ciphertext"], + ), + "numero": version["numero"], + "cree_le": version["cree_le"], + "remplacee_le": version["remplacee_le"], + } + ) + valeurs["versions"][secret["id"]] = nouvelles_versions + for item in data["coffres"]: + if item["id"] == coffre["id"]: + item["dek_chiffree"] = wrap_dek(nouvelle_dek, kek, item["id"]) + coffre = item + break + _save_coffres(base, data) + _save_valeurs(base, valeurs) + return coffre + + +def rotate_kek(nouvelle_kek_b64: str, data_dir: str | Path | None = None) -> int: + """Rotation KEK : re-chiffre uniquement les DEK avec la nouvelle KEK. + L'opérateur doit ensuite remplacer OBSIGATE_KEK / OBSIGATE_KEK_FILE + par la nouvelle valeur (cette fonction ne touche pas à l'environnement). + Retourne le nombre de coffres re-chiffrés.""" + base = resolve_data_dir(data_dir) + ancienne_kek = load_kek() + nouvelle_kek = parse_kek(nouvelle_kek_b64) + count = 0 + with _lock: + data = _load_coffres(base) + for coffre in data["coffres"]: + if not coffre.get("dek_chiffree"): + continue + dek = unwrap_dek(coffre["dek_chiffree"], ancienne_kek, coffre["id"]) + coffre["dek_chiffree"] = wrap_dek(dek, nouvelle_kek, coffre["id"]) + count += 1 + _save_coffres(base, data) + return count + + +# --------------------------------------------------------------------------- +# Consommateurs (jetons serveur-à-serveur, hachés, révocables par jti) +# --------------------------------------------------------------------------- + +def _hash_jeton(jeton: str) -> str: + return hashlib.sha256(jeton.encode("utf-8")).hexdigest() + + +def list_consommateurs(data_dir: str | Path | None = None) -> list: + base = resolve_data_dir(data_dir) + with _lock: + return [ + {k: v for k, v in c.items() if k != "jeton_hash"} + for c in _load_coffres(base)["consommateurs"] + ] + + +def create_consommateur( + nom: str, cree_par: str, data_dir: str | Path | None = None +) -> tuple[dict, str]: + """Crée un consommateur et retourne (métadonnées, jeton à usage unique). + Le jeton n'est JAMAIS persisté : seul son hash SHA-256 est stocké.""" + base = resolve_data_dir(data_dir) + nom = (nom or "").strip() + if not nom or len(nom) > 128: + raise ValidationError("Nom de consommateur requis (128 caractères max)") + jeton = "csk_" + _secrets.token_urlsafe(32) + with _lock: + data = _load_coffres(base) + consommateur = { + "id": str(uuid.uuid4()), + "nom": nom, + "jeton_hash": _hash_jeton(jeton), + "jeton_prefixe": jeton[:12], + "statut": "actif", + "cree_par": cree_par, + "cree_le": _now_iso(), + } + data["consommateurs"].append(consommateur) + _save_coffres(base, data) + public = {k: v for k, v in consommateur.items() if k != "jeton_hash"} + return public, jeton + + +def verifier_jeton_consommateur( + jeton: str, data_dir: str | Path | None = None +) -> dict | None: + """Retourne le consommateur (sans hash) si le jeton correspond, sinon None.""" + if not jeton: + return None + base = resolve_data_dir(data_dir) + cible = _hash_jeton(jeton) + with _lock: + for consommateur in _load_coffres(base)["consommateurs"]: + if hmac.compare_digest(consommateur.get("jeton_hash", ""), cible): + return { + k: v for k, v in consommateur.items() if k != "jeton_hash" + } + return None + + +def revoquer_consommateur( + consommateur_id: str, data_dir: str | Path | None = None +) -> dict: + base = resolve_data_dir(data_dir) + with _lock: + data = _load_coffres(base) + for consommateur in data["consommateurs"]: + if consommateur["id"] == consommateur_id: + consommateur["statut"] = "revoque" + _save_coffres(base, data) + return { + k: v + for k, v in consommateur.items() + if k != "jeton_hash" + } + raise IntrouvableError("Consommateur introuvable") + + +# --------------------------------------------------------------------------- +# Octrois +# --------------------------------------------------------------------------- + +def list_octrois(coffre_id: str, data_dir: str | Path | None = None) -> list: + base = resolve_data_dir(data_dir) + with _lock: + return [ + o for o in _load_coffres(base)["octrois"] if o["coffre_id"] == coffre_id + ] + + +def compter_octrois_actifs( + secret_id: str, data_dir: str | Path | None = None +) -> int: + base = resolve_data_dir(data_dir) + with _lock: + return sum( + 1 + for o in _load_coffres(base)["octrois"] + if o.get("secret_id") == secret_id + and o["statut"] == "actif" + and not est_expire(o.get("expiration")) + ) + + +def create_octroi( + coffre: dict, + cible_type: str, + cible_id: str, + secret_id: str | None, + cree_par: str, + expiration: str | None = None, + data_dir: str | Path | None = None, +) -> dict: + base = resolve_data_dir(data_dir) + if cible_type not in CIBLES_OCTROI: + raise ValidationError(f"Type de cible invalide : {cible_type}") + if not (cible_id or "").strip(): + raise ValidationError("Cible d'octroi requise") + if secret_id is not None: + with _lock: + data = _load_coffres(base) + if not any( + s["id"] == secret_id and s["coffre_id"] == coffre["id"] + for s in data["secrets"] + ): + raise IntrouvableError("Secret introuvable dans ce coffre") + if expiration is None: + expiration = (datetime.now(timezone.utc) + timedelta(days=DUREE_OCTROI_DEFAUT_JOURS)).isoformat() + else: + _parse_date(expiration) + with _lock: + data = _load_coffres(base) + octroi = { + "id": str(uuid.uuid4()), + "coffre_id": coffre["id"], + "secret_id": secret_id, + "cible_type": cible_type, + "cible_id": cible_id.strip(), + "expiration": expiration, + "statut": "actif", + "cree_par": cree_par, + "cree_le": _now_iso(), + "revoque_le": None, + } + data["octrois"].append(octroi) + _save_coffres(base, data) + return octroi + + +def revoquer_octroi(octroi_id: str, data_dir: str | Path | None = None) -> dict: + """Révocation : statut -> revoque (conservé pour distinguer + octroi_revoque de octroi_absent à la distribution).""" + base = resolve_data_dir(data_dir) + with _lock: + data = _load_coffres(base) + for octroi in data["octrois"]: + if octroi["id"] == octroi_id: + octroi["statut"] = "revoque" + octroi["revoque_le"] = _now_iso() + _save_coffres(base, data) + return octroi + raise IntrouvableError("Octroi introuvable") + + +def statut_octroi_consommateur( + consommateur_id: str, + coffre_id: str, + secret_id: str, + data_dir: str | Path | None = None, +) -> tuple[str, dict | None]: + """Statut d'accès d'un consommateur à un secret. + + Retourne ("ok" | "revoque" | "expire" | "absent", octroi). Un octroi + coffre-wide (secret_id None) couvre tous les secrets du coffre. + Priorité : ok > revoque > expire > absent (un octroi révoqué ne doit + jamais être masqué par l'absence d'un autre octroi).""" + base = resolve_data_dir(data_dir) + with _lock: + candidats = [ + o + for o in _load_coffres(base)["octrois"] + if o["cible_type"] == "consommateur" + and o["cible_id"] == consommateur_id + and o["coffre_id"] == coffre_id + and (o.get("secret_id") is None or o["secret_id"] == secret_id) + ] + for octroi in candidats: + if octroi["statut"] == "actif" and not est_expire(octroi.get("expiration")): + return "ok", octroi + for octroi in candidats: + if octroi["statut"] == "revoque": + return "revoque", octroi + for octroi in candidats: + if octroi["statut"] == "actif" and est_expire(octroi.get("expiration")): + return "expire", octroi + return "absent", None + + +def secrets_accessibles_utilisateur( + username: str, coffre: dict, data_dir: str | Path | None = None +) -> set | None: + """Secrets d'un coffre visibles par un utilisateur via octroi. + + Retourne None si un octroi coffre-wide actif existe (tout le coffre), + sinon l'ensemble des ids de secrets octroyés, sinon l'ensemble vide.""" + base = resolve_data_dir(data_dir) + with _lock: + octrois = [ + o + for o in _load_coffres(base)["octrois"] + if o["cible_type"] == "utilisateur" + and o["cible_id"] == username + and o["coffre_id"] == coffre["id"] + and o["statut"] == "actif" + and not est_expire(o.get("expiration")) + ] + if any(o.get("secret_id") is None for o in octrois): + return None + return {o["secret_id"] for o in octrois if o.get("secret_id")} -- 2.54.0 From 2ffc219f4793af524c48a94e869645c3a4450de6 Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 27 Oct 2026 21:45:38 -0400 Subject: [PATCH 03/12] [Coffre Secrets] Phase 1b : log_coffre_event dans audit.log (sans valeur) --- backend/audit.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/backend/audit.py b/backend/audit.py index dfe9879..cfb8a44 100644 --- a/backend/audit.py +++ b/backend/audit.py @@ -119,6 +119,41 @@ def log_vault_remove(username: str, vault_name: str, ip: str | None = None): }) +def log_coffre_event( + username: str, + action: str, + coffre: str | None = None, + secret_ref: str | None = None, + consommateur: str | None = None, + resultat: str | None = None, + contexte_id: str | None = None, + ip: str | None = None, +): + """Journal d'un événement du coffre Secrets (Phase 1b). + + Règle absolue : aucun champ ne contient jamais une valeur de secret, + un texte chiffré, un nonce ou un jeton. Seules des références + (coffre:/), des identifiants et des codes de résultat. + """ + entry = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "action": action, + "username": username, + "ip": ip or "unknown", + } + if coffre is not None: + entry["coffre"] = coffre + if secret_ref is not None: + entry["secret_ref"] = secret_ref + if consommateur is not None: + entry["consommateur"] = consommateur + if resultat is not None: + entry["resultat"] = resultat + if contexte_id is not None: + entry["contexte_id"] = contexte_id + _write_entry(entry) + + def get_recent_entries(limit: int = 100, action: str | None = None) -> list: """Read the most recent audit log entries. -- 2.54.0 From 40b3e9c5dd9b78d6235357846abe7cd5014208c8 Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 27 Oct 2026 21:45:40 -0400 Subject: [PATCH 04/12] [Coffre Secrets] Phase 1b : routeur /api/coffres, autorisations, octrois, distribution --- backend/routers/coffres.py | 606 +++++++++++++++++++++++++++++++++++++ 1 file changed, 606 insertions(+) create mode 100644 backend/routers/coffres.py diff --git a/backend/routers/coffres.py b/backend/routers/coffres.py new file mode 100644 index 0000000..b3aa91c --- /dev/null +++ b/backend/routers/coffres.py @@ -0,0 +1,606 @@ +"""API du coffre Secrets ObsiGate (Phase 1b, spec §7, §10, §11). + +Monté dans ``backend/main.py`` à côté de ``config_router`` — JAMAIS dans +``backend/routers/vaults.py`` : 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. + +Règles d'or, appliquées côté serveur à chaque requête : +- Toutes les lectures renvoient des métadonnées seules (``has_value``, + version, dates, octrois actifs) : jamais de valeur, jamais de texte + chiffré, jamais de nonce. +- Seul ``POST /api/coffres/distribuer`` rend une valeur, et uniquement à + un consommateur authentifié par jeton (hash + jti, jamais la session + utilisateur), avec un octroi actif et un contexte obligatoire. +- Autorisations (spec §6) : le propriétaire seul sur son coffre + personnel ; l'admin gère les coffres d'instance, les consommateurs et + la KEK, jamais le coffre personnel d'autrui ; l'ACL « * » des vaults + ne donne AUCUN droit sur un coffre (``check_vault_access`` n'est pas + utilisé ici, volontairement). +- Feature flag ``OBSIGATE_SECRETS_ENABLED`` (défaut désactivé) : tant + qu'il n'est pas activé, toutes les routes répondent 503. +""" + +from __future__ import annotations + +import logging +import os + +from fastapi import APIRouter, Body, Depends, HTTPException, Request + +from backend.audit import get_recent_entries, log_coffre_event +from backend.auth.middleware import require_admin, require_auth +from backend.coffre_crypto import ( + CryptoIndisponibleError, + KEKAbsenteError, + generate_kek_b64, +) +from backend.coffre_store import ( + CoffreExisteError, + IntrouvableError, + SecretExisteError, + SecretExpireError, + ValidationError, + compter_octrois_actifs, + compter_secrets, + create_coffre, + create_consommateur, + create_octroi, + create_secret, + delete_secret, + distribuer_valeur, + get_coffre, + get_coffre_by_id, + get_coffre_or_404, + get_octroi, + get_secret, + list_coffres, + list_consommateurs, + list_octrois, + list_secrets, + revoquer_consommateur, + revoquer_octroi, + rotate_dek, + rotate_kek, + rotate_secret, + secret_metadata, + secrets_accessibles_utilisateur, + statut_octroi_consommateur, + toucher_utilisation, + verifier_jeton_consommateur, +) + +logger = logging.getLogger("obsigate.coffres") + +router = APIRouter(tags=["Coffres Secrets"]) + +CODES_ERREUR = { + "octroi_absent", + "octroi_revoque", + "octroi_expire", + "secret_expire", + "kek_absente", + "contexte_manquant", + "consommateur_invalide", + "consommateur_revoque", + "crypto_indisponible", + "secrets_desactives", +} + + +def secrets_actives() -> bool: + """Feature flag : défaut désactivé, activation explicite par l'opérateur.""" + return os.environ.get("OBSIGATE_SECRETS_ENABLED", "false").lower() in ( + "1", + "true", + "yes", + "on", + ) + + +def _exiger_actives() -> None: + if not secrets_actives(): + raise HTTPException(status_code=503, detail="secrets_desactives") + + +def _username(user: dict) -> str: + return str(user.get("username", "")) + + +def _est_admin(user: dict) -> bool: + return user.get("role") == "admin" + + +def _peut_voir_coffre(user: dict, coffre: dict) -> bool: + """Voir les métadonnées d'un coffre.""" + if coffre["proprietaire"] == _username(user): + return True + if _est_admin(user) and coffre["perimetre"] == "instance": + return True + # Octroi utilisateur (coffre-wide ou par secret) : accès en lecture. + accessibles = secrets_accessibles_utilisateur(_username(user), coffre) + if accessibles is None: + return True + return len(accessibles) > 0 + + +def _peut_gerer_coffre(user: dict, coffre: dict) -> bool: + """Créer / roter / supprimer des secrets et gérer les octrois.""" + if coffre["proprietaire"] == _username(user): + return True + return _est_admin(user) and coffre["perimetre"] == "instance" + + +def _exiger_voir(user: dict, coffre: dict) -> None: + if not _peut_voir_coffre(user, coffre): + raise HTTPException(status_code=403, detail="Accès refusé à ce coffre") + + +def _exiger_gerer(user: dict, coffre: dict) -> None: + if not _peut_gerer_coffre(user, coffre): + raise HTTPException(status_code=403, detail="Accès refusé à ce coffre") + + +def _coffre_or_404(slug: str) -> dict: + try: + return get_coffre_or_404(slug) + except IntrouvableError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValidationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +def _secret_or_404(coffre: dict, nom: str) -> dict: + try: + secret = get_secret(coffre["id"], nom) + except ValidationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if secret is None: + raise HTTPException(status_code=404, detail=f"Secret introuvable : {nom}") + return secret + + +def _metadata_coffre(coffre: dict) -> dict: + return { + "slug": coffre["slug"], + "nom": coffre["nom"], + "description": coffre.get("description", ""), + "proprietaire": coffre["proprietaire"], + "perimetre": coffre["perimetre"], + "statut": coffre["statut"], + "kek_configuree": coffre.get("dek_chiffree") is not None, + "nb_secrets": compter_secrets(coffre["id"]), + "cree_le": coffre["cree_le"], + } + + +def _metadata_secret(coffre: dict, secret: dict) -> dict: + return secret_metadata(secret, compter_octrois_actifs(secret["id"])) + + +def _secrets_visibles(user: dict, coffre: dict) -> list: + """Secrets visibles par l'utilisateur (métadonnées seules).""" + if coffre["proprietaire"] == _username(user) or ( + _est_admin(user) and coffre["perimetre"] == "instance" + ): + return [_metadata_secret(coffre, s) for s in list_secrets(coffre["id"])] + accessibles = secrets_accessibles_utilisateur(_username(user), coffre) + if accessibles is None: + return [_metadata_secret(coffre, s) for s in list_secrets(coffre["id"])] + return [ + _metadata_secret(coffre, s) + for s in list_secrets(coffre["id"]) + if s["id"] in accessibles + ] + + +def _audit(user: str, action: str, **kwargs) -> None: + try: + log_coffre_event(user, action, **kwargs) + except Exception as exc: # l'audit ne doit jamais casser l'opération + logger.warning("Échec audit coffre (%s) : %s", action, exc) + + +# --------------------------------------------------------------------------- +# Coffres +# --------------------------------------------------------------------------- + +@router.get("/api/coffres") +async def api_list_coffres(current_user=Depends(require_auth)): + _exiger_actives() + visibles = [c for c in list_coffres() if _peut_voir_coffre(current_user, c)] + return [_metadata_coffre(c) for c in visibles] + + +@router.post("/api/coffres") +async def api_create_coffre(body: dict = Body(...), current_user=Depends(require_auth)): + _exiger_actives() + slug = body.get("slug", "") + perimetre = body.get("perimetre", "personnel") + if perimetre == "instance" and not _est_admin(current_user): + raise HTTPException( + status_code=403, detail="Seul un admin peut créer un coffre d'instance" + ) + try: + coffre = create_coffre( + slug=slug, + nom=body.get("nom", ""), + proprietaire=_username(current_user), + perimetre=perimetre, + description=body.get("description", ""), + ) + except CoffreExisteError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except ValidationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + _audit( + _username(current_user), + "coffre_cree", + coffre=coffre["slug"], + resultat="ok", + ) + return _metadata_coffre(coffre) + + +# --------------------------------------------------------------------------- +# Consommateurs (admin seulement, jeton rendu une seule fois) +# --------------------------------------------------------------------------- + +@router.get("/api/coffres/consommateurs") +async def api_list_consommateurs(current_user=Depends(require_admin)): + _exiger_actives() + return list_consommateurs() + + +@router.post("/api/coffres/consommateurs") +async def api_create_consommateur( + body: dict = Body(...), current_user=Depends(require_admin) +): + _exiger_actives() + try: + consommateur, jeton = create_consommateur( + nom=body.get("nom", ""), 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", + ) + # Le jeton n'est rendu qu'ici, jamais stocké en clair, jamais relisté. + return {**consommateur, "jeton": jeton} + + +@router.delete("/api/coffres/consommateurs/{consommateur_id}") +async def api_revoke_consommateur( + consommateur_id: str, current_user=Depends(require_admin) +): + _exiger_actives() + try: + consommateur = revoquer_consommateur(consommateur_id) + except IntrouvableError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + _audit( + _username(current_user), + "consommateur_revoque", + consommateur=consommateur["nom"], + resultat="ok", + ) + return consommateur + + +# --------------------------------------------------------------------------- +# Distribution (serveur-à-serveur, hors session utilisateur, hors LLM) +# --------------------------------------------------------------------------- + +@router.post("/api/coffres/distribuer") +async def api_distribuer(request: Request, body: dict = Body(...)): + _exiger_actives() + reference = (body.get("reference") or "").strip() + contexte_type = (body.get("contexte_type") or "").strip() + contexte_id = (body.get("contexte_id") or "").strip() + if not contexte_type or not contexte_id: + raise HTTPException(status_code=400, detail="contexte_manquant") + if not reference.startswith("coffre:") or "/" not in reference[7:]: + raise HTTPException(status_code=400, detail="reference_invalide") + slug, nom = reference[7:].split("/", 1) + # Authentification consommateur : Bearer, hash + statut, jamais un JWT + # de session utilisateur. + auth = request.headers.get("Authorization", "") + jeton = auth[7:] if auth.startswith("Bearer ") else "" + consommateur = verifier_jeton_consommateur(jeton) + if consommateur is None: + _audit("consommateur", "distribution_refusee", secret_ref=reference, + resultat="consommateur_invalide", contexte_id=contexte_id) + raise HTTPException(status_code=401, detail="consommateur_invalide") + if consommateur["statut"] != "actif": + _audit("consommateur", "distribution_refusee", secret_ref=reference, + consommateur=consommateur["nom"], + resultat="consommateur_revoque", contexte_id=contexte_id) + raise HTTPException(status_code=403, detail="consommateur_revoque") + try: + coffre = get_coffre(slug) + secret = get_secret(coffre["id"], nom) if coffre else None + except ValidationError: + raise HTTPException(status_code=400, detail="reference_invalide") from None + if coffre is None or secret is None or coffre["statut"] != "actif": + # Anti-énumération : un secret inexistant se comporte comme un + # octroi absent, il ne révèle pas l'existence du coffre/secret. + _audit("consommateur", "distribution_refusee", secret_ref=reference, + consommateur=consommateur["nom"], + resultat="octroi_absent", contexte_id=contexte_id) + raise HTTPException(status_code=403, detail="octroi_absent") + statut, _octroi = statut_octroi_consommateur( + consommateur["id"], coffre["id"], secret["id"] + ) + if statut != "ok": + code = { + "revoque": "octroi_revoque", + "expire": "octroi_expire", + "absent": "octroi_absent", + }[statut] + _audit("consommateur", "distribution_refusee", coffre=coffre["slug"], + secret_ref=reference, consommateur=consommateur["nom"], + resultat=code, contexte_id=contexte_id) + raise HTTPException(status_code=403, detail=code) + try: + valeur = distribuer_valeur(coffre, secret) + except SecretExpireError: + _audit("consommateur", "distribution_refusee", coffre=coffre["slug"], + secret_ref=reference, consommateur=consommateur["nom"], + resultat="secret_expire", contexte_id=contexte_id) + raise HTTPException(status_code=410, detail="secret_expire") from None + except KEKAbsenteError: + _audit("consommateur", "distribution_refusee", coffre=coffre["slug"], + secret_ref=reference, consommateur=consommateur["nom"], + resultat="kek_absente", contexte_id=contexte_id) + raise HTTPException(status_code=503, detail="kek_absente") from None + except CryptoIndisponibleError: + raise HTTPException(status_code=503, detail="crypto_indisponible") from None + toucher_utilisation(secret["id"]) + _audit("consommateur", "distribution_ok", coffre=coffre["slug"], + secret_ref=reference, consommateur=consommateur["nom"], + resultat="ok", contexte_id=contexte_id) + return { + "reference": reference, + "valeur": valeur, + "version": secret["version_courante"], + } + + +# --------------------------------------------------------------------------- +# Un coffre, ses secrets, ses octrois, son journal +# --------------------------------------------------------------------------- + +@router.get("/api/coffres/{slug}") +async def api_get_coffre(slug: str, current_user=Depends(require_auth)): + _exiger_actives() + coffre = _coffre_or_404(slug) + _exiger_voir(current_user, coffre) + return _metadata_coffre(coffre) + + +@router.get("/api/coffres/{slug}/secrets") +async def api_list_secrets(slug: str, current_user=Depends(require_auth)): + _exiger_actives() + coffre = _coffre_or_404(slug) + _exiger_voir(current_user, coffre) + return _secrets_visibles(current_user, coffre) + + +@router.post("/api/coffres/{slug}/secrets") +async def api_create_secret( + slug: str, body: dict = Body(...), current_user=Depends(require_auth) +): + _exiger_actives() + coffre = _coffre_or_404(slug) + _exiger_gerer(current_user, coffre) + try: + secret = create_secret( + coffre=coffre, + nom=body.get("nom", ""), + type_secret=body.get("type", "autre"), + valeur=body.get("valeur", ""), + service=body.get("service", ""), + expiration=body.get("expiration"), + note=body.get("note", ""), + ) + except SecretExisteError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + 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 + _audit( + _username(current_user), + "secret_cree", + coffre=coffre["slug"], + secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}", + resultat="ok", + ) + return _metadata_secret(coffre, secret) + + +@router.post("/api/coffres/{slug}/secrets/{nom}/rotation") +async def api_rotate_secret( + slug: str, nom: str, body: dict = Body(...), current_user=Depends(require_auth) +): + _exiger_actives() + coffre = _coffre_or_404(slug) + _exiger_gerer(current_user, coffre) + try: + secret = rotate_secret(coffre, nom, body.get("valeur", "")) + except IntrouvableError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + 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 + _audit( + _username(current_user), + "secret_rotation", + coffre=coffre["slug"], + secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}", + resultat="ok", + ) + return _metadata_secret(coffre, secret) + + +@router.delete("/api/coffres/{slug}/secrets/{nom}") +async def api_delete_secret( + slug: str, nom: str, current_user=Depends(require_auth) +): + _exiger_actives() + coffre = _coffre_or_404(slug) + _exiger_gerer(current_user, coffre) + try: + secret, _octrois = delete_secret(coffre, nom) + except IntrouvableError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + _audit( + _username(current_user), + "secret_supprime", + coffre=coffre["slug"], + secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}", + resultat="ok", + ) + return {"status": "deleted", "nom": secret["nom"]} + + +@router.get("/api/coffres/{slug}/octrois") +async def api_list_octrois(slug: str, current_user=Depends(require_auth)): + _exiger_actives() + coffre = _coffre_or_404(slug) + _exiger_voir(current_user, coffre) + return list_octrois(coffre["id"]) + + +@router.post("/api/coffres/{slug}/octrois") +async def api_create_octroi( + slug: str, body: dict = Body(...), current_user=Depends(require_auth) +): + _exiger_actives() + coffre = _coffre_or_404(slug) + _exiger_gerer(current_user, coffre) + secret_id = None + if body.get("secret_nom"): + secret = _secret_or_404(coffre, body["secret_nom"]) + secret_id = secret["id"] + try: + octroi = create_octroi( + coffre=coffre, + cible_type=body.get("cible_type", ""), + cible_id=body.get("cible_id", ""), + secret_id=secret_id, + cree_par=_username(current_user), + expiration=body.get("expiration"), + ) + except IntrouvableError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValidationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + _audit( + _username(current_user), + "octroi_cree", + coffre=coffre["slug"], + secret_ref=( + f"coffre:{coffre['slug']}/{body.get('secret_nom', '*')}" + ), + resultat="ok", + ) + return octroi + + +@router.delete("/api/coffres/octrois/{octroi_id}") +async def api_revoke_octroi(octroi_id: str, current_user=Depends(require_auth)): + _exiger_actives() + # Le gestionnaire du coffre concerné (ou l'admin d'instance) seul peut + # révoquer : on résout le coffre depuis l'octroi avant toute action. + octroi = get_octroi(octroi_id) + if octroi is None: + raise HTTPException(status_code=404, detail="Octroi introuvable") + coffre = get_coffre_by_id(octroi["coffre_id"]) + if coffre is None: + raise HTTPException(status_code=404, detail="Coffre introuvable") + _exiger_gerer(current_user, coffre) + try: + octroi = revoquer_octroi(octroi_id) + except IntrouvableError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + _audit( + _username(current_user), + "octroi_revoque", + coffre=coffre["slug"], + resultat="ok", + ) + return octroi + + +@router.get("/api/coffres/{slug}/journal") +async def api_journal(slug: str, current_user=Depends(require_auth)): + _exiger_actives() + coffre = _coffre_or_404(slug) + _exiger_voir(current_user, coffre) + # Défense en profondeur : même si une entrée contenait un jour un champ + # sensible, il ne sort jamais par cette route. + nettoyees = [] + for entry in get_recent_entries(limit=100): + if entry.get("coffre") != slug: + continue + nettoyees.append( + { + k: v + for k, v in entry.items() + if k not in ("valeur", "ciphertext", "nonce", "jeton", "dek_chiffree") + } + ) + return nettoyees + + +# --------------------------------------------------------------------------- +# Administration KEK / rotations (admin seulement) +# --------------------------------------------------------------------------- + +@router.post("/api/coffres/admin/generer-kek") +async def api_generer_kek(current_user=Depends(require_admin)): + _exiger_actives() + # La KEK générée n'est rendue qu'une fois, ici, et jamais stockée par + # ObsiGate : l'opérateur doit la sauvegarder hors dépôt immédiatement. + return {"kek": generate_kek_b64()} + + +@router.post("/api/coffres/{slug}/rotation-dek") +async def api_rotate_dek(slug: str, current_user=Depends(require_auth)): + _exiger_actives() + coffre = _coffre_or_404(slug) + _exiger_gerer(current_user, coffre) + try: + coffre = rotate_dek(slug) + except KEKAbsenteError: + raise HTTPException(status_code=503, detail="kek_absente") from None + except CryptoIndisponibleError: + raise HTTPException(status_code=503, detail="crypto_indisponible") from None + _audit(_username(current_user), "dek_rotation", coffre=coffre["slug"], + resultat="ok") + return _metadata_coffre(coffre) + + +@router.post("/api/coffres/admin/rotation-kek") +async def api_rotate_kek( + body: dict = Body(...), current_user=Depends(require_admin) +): + _exiger_actives() + try: + count = rotate_kek(body.get("nouvelle_kek", "")) + except KEKAbsenteError: + raise HTTPException(status_code=503, detail="kek_absente") from None + except Exception as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + _audit(_username(current_user), "kek_rotation", resultat="ok") + return {"status": "ok", "coffres_rechiffres": count} -- 2.54.0 From 5f8f93ba6c79a5fb4243d2f8313eb91f555cb8e4 Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 27 Oct 2026 21:45:53 -0400 Subject: [PATCH 05/12] [Coffre Secrets] Phase 1b : montage du routeur coffres a cote de config_router --- backend/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/main.py b/backend/main.py index 3813f81..bb8f117 100644 --- a/backend/main.py +++ b/backend/main.py @@ -530,6 +530,7 @@ from backend.auth.middleware import ( from backend.auth.router import router as auth_router from backend.bookslm_routes import router as bookslm_router from backend.routers.backups import router as backups_router +from backend.routers.coffres import router as coffres_router from backend.routers.config import _load_config from backend.routers.config import router as config_router from backend.routers.conflicts import router as conflicts_router @@ -564,6 +565,7 @@ app.include_router(search_router) # ROADMAP #85 T5 — Search app.include_router(backups_router) # ROADMAP #85 T4 — Backups app.include_router(conflicts_router) # ROADMAP #85 T8 — Conflicts app.include_router(config_router) # ROADMAP #85 T7 — Config +app.include_router(coffres_router) # Coffre Secrets Phase 1b — JAMAIS dans vaults.py app.include_router(file_chat_router) # ROADMAP #169 — Chat par fichier app.include_router(files_read_router) # ROADMAP #85 T6a — Files read app.include_router(files_media_router) # ROADMAP #85 T6c — Media/export -- 2.54.0 From 581a31161cfabbfdd04812f5e8f5d6c03a97a70e Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 27 Oct 2026 21:45:56 -0400 Subject: [PATCH 06/12] [Coffre Secrets] Phase 1a : dependance cryptography pour AES-GCM --- backend/requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/requirements.txt b/backend/requirements.txt index 51a84be..3b500cf 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -73,3 +73,5 @@ filelock>=4.2.1 # aucune dépendance directe d'ObsiGate) — CVE-2026-114603, corrigée # en 1.45.1. opentelemetry-api>=1.45.1 +# Coffre Secrets (Phase 1a) : chiffrement par enveloppe AES-256-GCM. +cryptography>=44.0 -- 2.54.0 From bd6b79fb418c9acc853c5941d1acac9d85e430c0 Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 27 Oct 2026 21:45:58 -0400 Subject: [PATCH 07/12] [Coffre Secrets] Phase 1a/1b : tests crypto, store, autorisations, distribution --- tests/test_coffre_secrets.py | 457 +++++++++++++++++++++++++++++++++++ 1 file changed, 457 insertions(+) create mode 100644 tests/test_coffre_secrets.py diff --git a/tests/test_coffre_secrets.py b/tests/test_coffre_secrets.py new file mode 100644 index 0000000..3061ed1 --- /dev/null +++ b/tests/test_coffre_secrets.py @@ -0,0 +1,457 @@ +"""Tests du coffre Secrets ObsiGate (Phase 1a / 1b, issues #17 et #18). + +Couverture exigée par les issues : +- chiffrement/déchiffrement, tag invalide refusé, rotations (secret, DEK, + KEK), mode dégradé sans KEK ; +- matrice d'autorisation, y compris admin contre coffre personnel d'autrui + et ACL « * » qui ne donne aucun droit sur un coffre ; +- contexte manquant refusé, octroi révoqué refusé au prochain appel, + expiration respectée, audit complet sans valeur ; +- lectures = métadonnées seules, jamais de valeur ni de texte chiffré. + +Toutes les valeurs de secret utilisées ici sont fictives et ne servent +qu'aux tests. Le store est isolé par test via OBSIGATE_DATA_DIR et le +journal d'audit via backend.audit.AUDIT_LOG_FILE. +""" + +from __future__ import annotations + +import base64 +import json +import os +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient + +from backend import audit as audit_module +from backend.auth.middleware import require_admin, require_auth +from backend.coffre_crypto import ( + KEKAbsenteError, + KEKInvalideError, + TagInvalideError, + decrypt_secret_value, + encrypt_secret_value, + generate_dek, + generate_kek_b64, + load_kek, + parse_kek, + unwrap_dek, + wrap_dek, +) +from backend.coffre_store import ( + CoffreExisteError, + SecretExpireError, + ValidationError, + create_coffre, + create_secret, + distribuer_valeur, + get_coffre_or_404, + rotate_dek, + rotate_kek, + rotate_secret, +) +from backend.routers.coffres import router as coffres_router + +VALEUR_TEST = "VALEUR-TEST-FICTIVE-7f3a9c-ne-pas-utiliser" +VALEUR_TEST_2 = "VALEUR-TEST-FICTIVE-2-b4d8e1-ne-pas-utiliser" + +# Utilisateur courant injecté dans les routes via dependency_overrides. +_COURANT = {"user": None} + + +def _fake_auth(): + if _COURANT["user"] is None: + raise HTTPException(status_code=401, detail="Authentification requise") + return _COURANT["user"] + + +def _fake_admin(): + user = _fake_auth() + if user.get("role") != "admin": + raise HTTPException(status_code=403, detail="Accès admin requis") + return user + + +def _user(username: str, role: str = "user", vaults: list | None = None) -> dict: + return { + "username": username, + "role": role, + "vaults": vaults if vaults is not None else [f"home-{username}"], + "active": True, + } + + +@pytest.fixture +def env_coffre(tmp_path, monkeypatch): + """Environnement isolé : store, audit, KEK et feature flag.""" + monkeypatch.setenv("OBSIGATE_SECRETS_ENABLED", "1") + monkeypatch.setenv("OBSIGATE_DATA_DIR", str(tmp_path)) + monkeypatch.setenv("OBSIGATE_KEK", generate_kek_b64()) + monkeypatch.delenv("OBSIGATE_KEK_FILE", raising=False) + monkeypatch.setattr(audit_module, "AUDIT_LOG_FILE", tmp_path / "audit.log") + _COURANT["user"] = _user("bruno", role="admin") + yield tmp_path + _COURANT["user"] = None + + +@pytest.fixture +def client(env_coffre): + app = FastAPI() + app.include_router(coffres_router) + app.dependency_overrides[require_auth] = _fake_auth + app.dependency_overrides[require_admin] = _fake_admin + with TestClient(app, raise_server_exceptions=False) as test_client: + yield test_client + + +def _connecter(client, username: str, role: str = "user", vaults=None): + _COURANT["user"] = _user(username, role=role, vaults=vaults) + + +def _creer_coffre_secret(client, slug="personnel", nom="gitea-token", valeur=VALEUR_TEST): + resp = client.post( + "/api/coffres", + json={"slug": slug, "nom": "Personnel", "perimetre": "personnel"}, + ) + assert resp.status_code == 200, resp.text + resp = client.post( + f"/api/coffres/{slug}/secrets", + json={ + "nom": nom, + "type": "token", + "service": "Gitea", + "valeur": valeur, + }, + ) + assert resp.status_code == 200, resp.text + return resp.json() + + +def _creer_consommateur_octroi(client, slug="personnel", nom="gitea-token"): + resp = client.post("/api/coffres/consommateurs", json={"nom": "spark"}) + assert resp.status_code == 200, resp.text + consommateur = resp.json() + jeton = consommateur["jeton"] + resp = client.post( + f"/api/coffres/{slug}/octrois", + json={ + "cible_type": "consommateur", + "cible_id": consommateur["id"], + "secret_nom": nom, + }, + ) + assert resp.status_code == 200, resp.text + return consommateur, jeton, resp.json() + + +def _distribuer(client, jeton, reference="coffre:personnel/gitea-token", + contexte_type="mission", contexte_id="mission-test-1"): + return client.post( + "/api/coffres/distribuer", + json={ + "reference": reference, + "contexte_type": contexte_type, + "contexte_id": contexte_id, + }, + headers={"Authorization": f"Bearer {jeton}"}, + ) + + +def _fichiers_sans_valeur(tmp_path, valeur=VALEUR_TEST): + for path in tmp_path.iterdir(): + if path.is_file() and path.suffix in (".json", ".log"): + assert valeur not in path.read_text(encoding="utf-8"), path.name + + +# --------------------------------------------------------------------------- +# Crypto (Phase 1a) +# --------------------------------------------------------------------------- + +class TestCrypto: + def test_aller_retour_aes_gcm(self): + kek = bytes(range(32)) + dek = generate_dek() + wrapped = wrap_dek(dek, kek, "coffre-test") + assert unwrap_dek(wrapped, kek, "coffre-test") == dek + chiffre = encrypt_secret_value(dek, "secret-1", 1, VALEUR_TEST) + assert decrypt_secret_value(dek, "secret-1", 1, chiffre["nonce"], chiffre["ciphertext"]) == VALEUR_TEST + + def test_tag_invalide_refuse(self): + dek = generate_dek() + chiffre = encrypt_secret_value(dek, "secret-1", 1, VALEUR_TEST) + alt = bytearray(base64.b64decode(chiffre["ciphertext"])) + alt[-1] ^= 1 + chiffre_alt = { + "nonce": chiffre["nonce"], + "ciphertext": base64.b64encode(bytes(alt)).decode("ascii"), + } + with pytest.raises(TagInvalideError): + decrypt_secret_value(dek, "secret-1", 1, chiffre_alt["nonce"], chiffre_alt["ciphertext"]) + + def test_aad_incorrect_refuse(self): + dek = generate_dek() + chiffre = encrypt_secret_value(dek, "secret-1", 1, VALEUR_TEST) + with pytest.raises(TagInvalideError): + decrypt_secret_value(dek, "secret-2", 1, chiffre["nonce"], chiffre["ciphertext"]) + + def test_parse_kek_invalide(self, monkeypatch): + with pytest.raises(KEKInvalideError): + parse_kek("trop-court") + assert len(parse_kek(generate_kek_b64())) == 32 + + def test_kek_absente(self, tmp_path, monkeypatch): + monkeypatch.delenv("OBSIGATE_KEK", raising=False) + monkeypatch.delenv("OBSIGATE_KEK_FILE", raising=False) + with pytest.raises(KEKAbsenteError): + load_kek() + + def test_kek_fichier_0600_ok_et_trop_permissif_refuse(self, tmp_path, monkeypatch): + kek_file = tmp_path / "kek.txt" + kek_file.write_text(generate_kek_b64(), encoding="utf-8") + os.chmod(kek_file, 0o600) + monkeypatch.delenv("OBSIGATE_KEK", raising=False) + monkeypatch.setenv("OBSIGATE_KEK_FILE", str(kek_file)) + assert len(load_kek()) == 32 + os.chmod(kek_file, 0o644) + with pytest.raises(KEKInvalideError): + load_kek() + + +# --------------------------------------------------------------------------- +# Store (Phase 1a) +# --------------------------------------------------------------------------- + +class TestStore: + def test_roundtrip_et_aucun_clair_au_repos(self, env_coffre): + coffre = create_coffre("personnel", "Personnel", "bruno") + secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST, service="Gitea") + assert distribuer_valeur(coffre, secret) == VALEUR_TEST + _fichiers_sans_valeur(env_coffre) + + def test_rotation_secret(self, env_coffre): + coffre = create_coffre("personnel", "Personnel", "bruno") + secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST) + secret = rotate_secret(coffre, "gitea-token", VALEUR_TEST_2) + assert secret["version_courante"] == 2 + assert distribuer_valeur(coffre, secret) == VALEUR_TEST_2 + + def test_rotation_dek_puis_kek(self, env_coffre, monkeypatch): + coffre = create_coffre("personnel", "Personnel", "bruno") + secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST) + rotate_dek("personnel") + assert distribuer_valeur(get_coffre_or_404("personnel"), secret) == VALEUR_TEST + nouvelle_kek = generate_kek_b64() + assert rotate_kek(nouvelle_kek) == 1 + monkeypatch.setenv("OBSIGATE_KEK", nouvelle_kek) + assert distribuer_valeur(get_coffre_or_404("personnel"), secret) == VALEUR_TEST + + def test_mode_degrade_sans_kek(self, env_coffre, monkeypatch): + monkeypatch.delenv("OBSIGATE_KEK", raising=False) + coffre = create_coffre("personnel", "Personnel", "bruno") + assert coffre["dek_chiffree"] is None + with pytest.raises(KEKAbsenteError): + create_secret(coffre, "gitea-token", "token", VALEUR_TEST) + + def test_validation_et_doublons(self, env_coffre): + coffre = create_coffre("personnel", "Personnel", "bruno") + with pytest.raises(ValidationError): + create_coffre("MAUVAIS SLUG !!", "X", "bruno") + with pytest.raises(CoffreExisteError): + create_coffre("personnel", "Autre", "bruno") + create_secret(coffre, "gitea-token", "token", VALEUR_TEST) + with pytest.raises(ValidationError): + create_secret( + coffre, "autre-secret", "token", VALEUR_TEST, + note="api-key: ceci-ressemble-a-un-secret", + ) + + def test_secret_expire_refuse_a_la_distribution(self, env_coffre): + coffre = create_coffre("personnel", "Personnel", "bruno") + hier = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat() + secret = create_secret( + coffre, "vieux-jeton", "token", VALEUR_TEST, expiration=hier + ) + with pytest.raises(SecretExpireError): + distribuer_valeur(coffre, secret) + + +# --------------------------------------------------------------------------- +# API et autorisations (Phase 1b) +# --------------------------------------------------------------------------- + +class TestApiAutorisations: + def test_flag_desactive_repond_503(self, client, monkeypatch): + monkeypatch.setenv("OBSIGATE_SECRETS_ENABLED", "0") + assert client.get("/api/coffres").status_code == 503 + + def test_lectures_metadonnees_seules(self, client, env_coffre): + _creer_coffre_secret(client) + resp = client.get("/api/coffres/personnel/secrets") + assert resp.status_code == 200 + corps = resp.text + assert VALEUR_TEST not in corps + assert "ciphertext" not in corps + assert "nonce" not in corps + item = resp.json()[0] + assert item["has_value"] is True + assert item["reference"] == "coffre:personnel/gitea-token" + assert "valeur" not in item + + def test_admin_ne_voit_pas_le_coffre_personnel_d_autrui(self, client): + _creer_coffre_secret(client) # propriétaire : bruno (admin de test) + _connecter(client, "alice", role="user") + assert client.get("/api/coffres/personnel").status_code == 403 + assert client.get("/api/coffres/personnel/secrets").status_code == 403 + # Même un admin avec vaults=["*"] n'a aucun droit sur un coffre + # personnel dont il n'est pas propriétaire. + _connecter(client, "admin2", role="admin", vaults=["*"]) + assert client.get("/api/coffres/personnel").status_code == 403 + + def test_acl_etoile_ne_donne_aucun_droit_coffre(self, client): + _creer_coffre_secret(client) + _connecter(client, "mallory", role="user", vaults=["*"]) + assert client.get("/api/coffres/personnel/secrets").status_code == 403 + + def test_coffre_instance_reserve_admin(self, client): + _connecter(client, "bruno", role="user") + resp = client.post( + "/api/coffres", + json={"slug": "instance", "nom": "Instance", "perimetre": "instance"}, + ) + assert resp.status_code == 403 + _connecter(client, "bruno", role="admin") + resp = client.post( + "/api/coffres", + json={"slug": "instance", "nom": "Instance", "perimetre": "instance"}, + ) + assert resp.status_code == 200 + _connecter(client, "alice", role="user") + assert client.get("/api/coffres/instance").status_code == 403 + + def test_creation_secret_sans_kek_repond_503(self, client, monkeypatch): + monkeypatch.delenv("OBSIGATE_KEK", raising=False) + resp = client.post("/api/coffres", json={"slug": "personnel", "nom": "Personnel"}) + assert resp.status_code == 200 # métadonnées créées en mode dégradé + resp = client.post( + "/api/coffres/personnel/secrets", + json={"nom": "gitea-token", "type": "token", "valeur": VALEUR_TEST}, + ) + assert resp.status_code == 503 + assert resp.json()["detail"] == "kek_absente" + + +class TestDistribution: + def test_distribution_ok_puis_revoquee_puis_consommateur_revoque(self, client, env_coffre): + _creer_coffre_secret(client) + consommateur, jeton, octroi = _creer_consommateur_octroi(client) + resp = _distribuer(client, jeton) + assert resp.status_code == 200, resp.text + assert resp.json()["valeur"] == VALEUR_TEST + assert resp.json()["version"] == 1 + # Le jeton complet n'est jamais relisté, ni son hash (seul le + # préfixe d'affichage, non exploitable, apparaît). + liste = client.get("/api/coffres/consommateurs").json() + assert jeton not in json.dumps(liste) + assert "jeton_hash" not in json.dumps(liste) + assert all(set(item.keys()) == {"id", "nom", "jeton_prefixe", "statut", "cree_par", "cree_le"} for item in liste) + # Révocation de l'octroi : refusé dès le prochain appel. + resp = client.delete(f"/api/coffres/octrois/{octroi['id']}") + assert resp.status_code == 200 + resp = _distribuer(client, jeton) + assert resp.status_code == 403 + assert resp.json()["detail"] == "octroi_revoque" + # Révocation du consommateur : tous ses octrois tombent. + resp = client.post( + "/api/coffres/personnel/octrois", + json={"cible_type": "consommateur", "cible_id": consommateur["id"], "secret_nom": "gitea-token"}, + ) + assert resp.status_code == 200 + resp = client.delete(f"/api/coffres/consommateurs/{consommateur['id']}") + assert resp.status_code == 200 + resp = _distribuer(client, jeton) + assert resp.status_code == 403 + assert resp.json()["detail"] == "consommateur_revoque" + + def test_contexte_manquant_refuse(self, client): + _creer_coffre_secret(client) + _, jeton, _ = _creer_consommateur_octroi(client) + resp = client.post( + "/api/coffres/distribuer", + json={"reference": "coffre:personnel/gitea-token"}, + headers={"Authorization": f"Bearer {jeton}"}, + ) + assert resp.status_code == 400 + assert resp.json()["detail"] == "contexte_manquant" + + def test_session_utilisateur_ne_distribue_pas(self, client): + _creer_coffre_secret(client) + _creer_consommateur_octroi(client) + resp = _distribuer(client, "jeton-session-utilisateur-fictif") + assert resp.status_code == 401 + assert resp.json()["detail"] == "consommateur_invalide" + + def test_octroi_absent_et_secret_expire(self, client): + _creer_coffre_secret(client) + resp = client.post("/api/coffres/consommateurs", json={"nom": "sans-octroi"}) + jeton = resp.json()["jeton"] + resp = _distribuer(client, jeton) + assert resp.status_code == 403 + assert resp.json()["detail"] == "octroi_absent" + # Secret expiré : 410, même avec un octroi actif. + _connecter(client, "bruno", role="admin") + hier = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat() + resp = client.post( + "/api/coffres/personnel/secrets", + json={"nom": "vieux-jeton", "type": "token", "valeur": VALEUR_TEST, "expiration": hier}, + ) + assert resp.status_code == 200 + _, jeton2, _ = _creer_consommateur_octroi(client, nom="vieux-jeton") + resp = _distribuer(client, jeton2, reference="coffre:personnel/vieux-jeton") + assert resp.status_code == 410 + assert resp.json()["detail"] == "secret_expire" + + def test_rotation_change_la_valeur_distribuee(self, client): + _creer_coffre_secret(client) + _, jeton, _ = _creer_consommateur_octroi(client) + resp = client.post( + "/api/coffres/personnel/secrets/gitea-token/rotation", + json={"valeur": VALEUR_TEST_2}, + ) + assert resp.status_code == 200 + assert resp.json()["version"] == 2 + resp = _distribuer(client, jeton) + assert resp.json()["valeur"] == VALEUR_TEST_2 + + def test_suppression_casse_les_octrois(self, client): + _creer_coffre_secret(client) + _, jeton, _ = _creer_consommateur_octroi(client) + resp = client.delete("/api/coffres/personnel/secrets/gitea-token") + assert resp.status_code == 200 + resp = _distribuer(client, jeton) + assert resp.status_code == 403 + assert resp.json()["detail"] == "octroi_absent" + + def test_octroi_par_defaut_90_jours(self, client): + _creer_coffre_secret(client) + _, _, octroi = _creer_consommateur_octroi(client) + expiration = datetime.fromisoformat(octroi["expiration"]) + delta = expiration - datetime.now(timezone.utc) + assert 89 <= delta.days <= 91 + + def test_audit_sans_valeur_et_journal(self, client, env_coffre): + _creer_coffre_secret(client) + _, jeton, _ = _creer_consommateur_octroi(client) + assert _distribuer(client, jeton).status_code == 200 + _fichiers_sans_valeur(env_coffre) + audit = (env_coffre / "audit.log").read_text(encoding="utf-8") + assert "distribution_ok" in audit + assert VALEUR_TEST not in audit + resp = client.get("/api/coffres/personnel/journal") + assert resp.status_code == 200 + actions = [e["action"] for e in resp.json()] + assert "secret_cree" in actions + assert "distribution_ok" in actions + assert "valeur" not in json.dumps(resp.json()) -- 2.54.0 From 040e80a36dae6433b10a400b19f4940eb51083d0 Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 27 Oct 2026 22:08:00 -0400 Subject: [PATCH 08/12] [Coffre Secrets] Phase 1c UI : nouveau module frontend/js/coffres.js (vue Secrets, modales, copie manuelle) --- frontend/js/coffres.js | 1070 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1070 insertions(+) create mode 100644 frontend/js/coffres.js diff --git a/frontend/js/coffres.js b/frontend/js/coffres.js new file mode 100644 index 0000000..e4862b1 --- /dev/null +++ b/frontend/js/coffres.js @@ -0,0 +1,1070 @@ +/* ObsiGate — Coffre Secrets (Phase 1c, issue #19, spec §8–§9). + * + * Nœud virtuel « Secrets » sous home- + vue Secrets dédiée. + * + * Règles de sécurité appliquées ici (jamais assouplies) : + * - Le backend ne renvoie que des métadonnées (has_value, jamais la valeur, + * jamais un texte chiffré, jamais un nonce). Ce module n'affiche donc + * jamais de valeur de secret après enregistrement : colonne valeur = + * « •••••• » seulement, aucun bouton « afficher ». + * - Aucune valeur ne transite par un titre d'onglet, une URL, un hash, un + * journal console ou un log. Les variables contenant une valeur sont + * effacées dès que l'opération est terminée (champ vidé, variable = null). + * - Seule exception, voulue par le contrat backend : le jeton d'un + * consommateur (csk_…) et une KEK générée sont rendus UNE fois, dans une + * modale, avec un bouton Copier et un avertissement ; la modale fermée, + * ils sont perdus. Ce ne sont pas des valeurs de secret du coffre. + * - La copie manuelle à usage unique (admin seulement, voir + * _copieManuelle) passe par un consommateur temporaire créé puis révoqué + * immédiatement, avec distribution auditée ; il n'existe pas encore + * d'endpoint de distribution par session utilisateur côté backend. + * - La vue est rendue directement dans #content-area : elle ne passe ni + * par TabManager.openNav (navigation de fichiers), ni par l'indexeur, + * ni par la recherche, ni par le graphe. + */ + +import { api, AuthManager } from './auth.js'; +import { showToast, showConfirm, closeMobileSidebar } from './ui.js'; +import { el } from './viewer.js'; +import { safeCreateIcons } from './utils.js'; +import { t } from './i18n.js'; + +// --------------------------------------------------------------------------- +// Constantes (miroir exact du backend Phase 1b — ne pas deviner d'autres) +// --------------------------------------------------------------------------- + +const TYPES_SECRET = ['api_key', 'token', 'password', 'certificate', 'autre']; +const CIBLES_OCTROI = ['consommateur', 'utilisateur', 'executeur', 'workspace']; +const PERIMETRES = ['personnel', 'partage', 'instance']; +const NOM_SECRET_RE = /^[a-z0-9][a-z0-9_-]{2,127}$/; +const SLUG_RE = /^[a-z0-9][a-z0-9_-]{2,63}$/; + +// --------------------------------------------------------------------------- +// État + cache sidebar +// --------------------------------------------------------------------------- + +let _coffresCache = null; +let _coffresCacheAt = 0; +const CACHE_MS = 30000; + +const viewState = { + coffres: [], + currentSlug: null, + secrets: [], + octrois: [], + consommateurs: [], + journal: [], + filter: '', + detail: null, // métadonnées d'un secret, jamais une valeur + seq: 0, +}; + +function me() { + return (AuthManager.getUser() || {}).username || ''; +} + +function isAdmin() { + return (AuthManager.getUser() || {}).role === 'admin'; +} + +function slugPersonnel() { + return `personnel-${String(me()).toLowerCase()}`; +} + +function notifyChanged() { + invalidateCoffresCache(); + document.dispatchEvent(new CustomEvent('obsigate:coffres-changed')); +} + +export function invalidateCoffresCache() { + _coffresCache = null; + _coffresCacheAt = 0; +} + +/** Liste des coffres visibles, mise en cache (sidebar + vue). */ +export async function getCoffresForSidebar(force = false) { + if (!force && _coffresCache && Date.now() - _coffresCacheAt < CACHE_MS) { + return _coffresCache; + } + try { + const coffres = await api('/api/coffres'); + const personnel = (coffres || []).find( + (c) => c.proprietaire === me() && c.perimetre === 'personnel' + ) || null; + _coffresCache = { + coffres: coffres || [], + personnel, + kekConfiguree: personnel ? !!personnel.kek_configuree : false, + disabled: false, + }; + } catch (err) { + _coffresCache = { + coffres: [], + personnel: null, + kekConfiguree: false, + disabled: err && (err.message === 'secrets_desactives' || err.status === 503), + error: err, + }; + } + _coffresCacheAt = Date.now(); + return _coffresCache; +} + +/** Création paresseuse du coffre personnel, à la première ouverture de la + * vue Secrets. Le slug est globalement unique côté backend, d'où + * `personnel-` (et non un simple `personnel` partagé). + * Sans KEK, le coffre est créé sans DEK (mode dégradé) : les métadonnées + * existent, aucun secret n'est ajoutable tant que la KEK n'est pas là. */ +async function ensurePersonalCoffre() { + const data = await getCoffresForSidebar(true); + if (data.disabled) return null; + if (data.personnel) return data.personnel; + try { + const created = await api('/api/coffres', { + method: 'POST', + body: JSON.stringify({ + slug: slugPersonnel(), + nom: 'Personnel', + perimetre: 'personnel', + description: '', + }), + }); + notifyChanged(); + return created; + } catch (err) { + // Course : un autre onglet l'a créé entre-temps → relecture. + if (err && err.status === 409) { + const again = await getCoffresForSidebar(true); + return again.personnel; + } + throw err; + } +} + +// --------------------------------------------------------------------------- +// Petits utilitaires d'affichage (données via textContent uniquement) +// --------------------------------------------------------------------------- + +function fmtDate(iso) { + if (!iso) return '—'; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return String(iso); + return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); +} + +function fmtDateTime(iso) { + if (!iso) return '—'; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return String(iso); + return d.toLocaleString(); +} + +function joursJusquau(iso) { + if (!iso) return null; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return null; + return Math.ceil((d.getTime() - Date.now()) / 86400000); +} + +function statutSecret(s) { + if (s.expire) return t('coffres.expired'); + const j = joursJusquau(s.expiration); + if (j !== null && j <= 30) return t('coffres.expiring_soon'); + return t('coffres.active'); +} + +function text(tag, str, cls) { + const n = document.createElement(tag); + if (cls) n.className = cls; + n.textContent = str == null ? '' : String(str); + return n; +} + +async function copyText(value, okMsg) { + try { + await navigator.clipboard.writeText(value); + showToast(okMsg || t('coffres.copied'), 'success'); + return true; + } catch { + showToast(t('coffres.copy_failed'), 'error'); + return false; + } +} + +function genererValeur(longueur = 32) { + if (!window.crypto || !window.crypto.getRandomValues) return null; + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*'; + const buf = new Uint32Array(longueur); + window.crypto.getRandomValues(buf); + return Array.from(buf, (n) => chars[n % chars.length]).join(''); +} + +// --------------------------------------------------------------------------- +// Modales (classes existantes obsigate-modal-*, jamais d'innerHTML de données) +// --------------------------------------------------------------------------- + +function openModal(title, bodyEl, footerEls) { + const overlay = el('div', { class: 'obsigate-modal-overlay' }, []); + const modal = el('div', { class: 'obsigate-modal', role: 'dialog' }, []); + modal.setAttribute('aria-modal', 'true'); + const header = el('div', { class: 'obsigate-modal-header' }, [ + el('h3', { class: 'obsigate-modal-title' }, [document.createTextNode(title)]), + ]); + const body = el('div', { class: 'obsigate-modal-body' }, [bodyEl]); + const footer = el('div', { class: 'obsigate-modal-footer' }, footerEls || []); + modal.appendChild(header); + modal.appendChild(body); + modal.appendChild(footer); + overlay.appendChild(modal); + document.body.appendChild(overlay); + setTimeout(() => overlay.classList.add('active'), 10); + let closed = false; + const close = () => { + if (closed) return; + closed = true; + overlay.classList.remove('active'); + document.removeEventListener('keydown', onKey); + setTimeout(() => overlay.remove(), 200); + }; + const onKey = (e) => { if (e.key === 'Escape') close(); }; + document.addEventListener('keydown', onKey); + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); + return { overlay, modal, close }; +} + +function modalBtn(label, cls, onClick) { + const b = el('button', { class: `modal-btn ${cls || ''}`.trim(), type: 'button' }, [ + document.createTextNode(label), + ]); + b.addEventListener('click', onClick); + return b; +} + +function field(labelText, inputEl, hintText) { + const wrap = el('div', { class: 'modal-form-group' }, []); + if (labelText) { + wrap.appendChild(el('label', { class: 'modal-label' }, [document.createTextNode(labelText)])); + } + wrap.appendChild(inputEl); + if (hintText) { + wrap.appendChild(el('div', { class: 'modal-hint' }, [document.createTextNode(hintText)])); + } + return wrap; +} + +function textInput(value, placeholder) { + const i = el('input', { class: 'modal-input', type: 'text', spellcheck: 'false' }, []); + if (value) i.value = value; + if (placeholder) i.placeholder = placeholder; + return i; +} + +function selectInput(options, value) { + const s = el('select', { class: 'modal-select' }, []); + options.forEach(([v, label]) => { + const o = el('option', { value: v }, [document.createTextNode(label)]); + if (v === value) o.selected = true; + s.appendChild(o); + }); + return s; +} + +/** Champ valeur : input mot de passe (+ œil) ou textarea pour les certificats. + * La valeur n'est jamais écrite ailleurs que dans ce champ, et le champ + * est vidé à la fermeture / à l'enregistrement. */ +function valeurField(initialType) { + let input = null; + const wrap = el('div', { style: 'display:flex;gap:8px;align-items:flex-start' }, []); + const build = (type) => { + const old = input ? input.value : ''; + if (input) input.remove(); + if (type === 'certificate') { + input = el('textarea', { class: 'modal-input', rows: '4', spellcheck: 'false' }, []); + } else { + input = el('input', { class: 'modal-input', type: 'password', spellcheck: 'false', autocomplete: 'new-password' }, []); + } + input.value = old; + wrap.insertBefore(input, eyeBtn); + eyeBtn.style.display = type === 'certificate' ? 'none' : ''; + }; + const eyeBtn = modalBtn('👁', '', () => { + if (input && input.type === 'password') input.type = 'text'; + else if (input) input.type = 'password'; + }); + eyeBtn.title = t('coffres.show_before_save'); + build(initialType); + return { + wrap, + setType: build, + get value() { return input ? input.value : ''; }, + clear() { if (input) input.value = ''; }, + focus() { if (input) input.focus(); }, + }; +} + +function errorBox() { + const p = el('p', { class: 'modal-error', style: 'display:none' }, []); + return { + el: p, + show(msg) { p.textContent = msg || ''; p.style.display = msg ? '' : 'none'; }, + }; +} + +// --------------------------------------------------------------------------- +// Vue Secrets — rendu dans #content-area, hors navigation de fichiers +// --------------------------------------------------------------------------- + +function coffreActuel() { + return viewState.coffres.find((c) => c.slug === viewState.currentSlug) || null; +} + +function secretsFiltres() { + const q = (viewState.filter || '').trim().toLowerCase(); + if (!q) return viewState.secrets; + return viewState.secrets.filter( + (s) => s.nom.toLowerCase().includes(q) || (s.service || '').toLowerCase().includes(q) + ); +} + +function renderAlerts(host) { + const alerts = []; + viewState.secrets.forEach((s) => { + const j = joursJusquau(s.expiration); + if (s.expire) alerts.push(`${s.nom} : ${t('coffres.expired')}`); + else if (j !== null && j <= 30) alerts.push(`${s.nom} : ${t('coffres.expiring_soon')}`); + }); + viewState.octrois.forEach((o) => { + if (o.statut === 'actif' && !o.expiration) { + alerts.push(`${t('coffres.octroi_no_expiration')} (${o.cible_type} ${o.cible_id})`); + } + }); + if (!alerts.length) return; + const box = el('div', { class: 'coffres-alerts' }, []); + alerts.forEach((a) => box.appendChild(text('div', `⚠ ${a}`, 'coffres-alert'))); + host.appendChild(box); +} + +function renderDetail(host) { + const s = viewState.detail; + host.innerHTML = ''; + if (!s) { + host.appendChild(text('p', t('coffres.select_secret'), 'coffres-empty')); + return; + } + host.appendChild(el('h3', {}, [document.createTextNode(s.nom)])); + const rows = [ + [t('coffres.reference'), s.reference], + [t('coffres.type'), s.type], + [t('coffres.service'), s.service || '—'], + [t('coffres.note'), s.note || '—'], + [t('coffres.status'), statutSecret(s)], + [t('coffres.expires'), s.expiration ? fmtDate(s.expiration) : '—'], + [t('coffres.version'), String(s.version)], + [t('coffres.created'), fmtDateTime(s.cree_le)], + [t('coffres.modified'), fmtDateTime(s.modifie_le)], + [t('coffres.last_use'), s.derniere_utilisation ? fmtDateTime(s.derniere_utilisation) : '—'], + [t('coffres.octrois'), String(s.octrois_actifs)], + [t('coffres.value'), '••••••'], + ]; + const dl = el('dl', { class: 'coffres-dl' }, []); + rows.forEach(([k, v]) => { + dl.appendChild(el('dt', {}, [document.createTextNode(k)])); + dl.appendChild(el('dd', {}, [document.createTextNode(v)])); + }); + host.appendChild(dl); + const actions = el('div', { class: 'coffres-actions' }, []); + actions.appendChild(modalBtn(t('coffres.copy_reference'), '', () => copyText(s.reference))); + actions.appendChild(modalBtn(t('coffres.rotation'), 'primary', () => openRotationModal(s))); + actions.appendChild(modalBtn(t('coffres.acces'), '', () => { + document.getElementById('coffres-acces')?.scrollIntoView({ behavior: 'smooth' }); + })); + actions.appendChild(modalBtn(t('coffres.manual_copy'), '', () => copieManuelle(s))); + if (!isAdmin()) { + actions.lastChild.disabled = true; + actions.lastChild.title = t('coffres.manual_copy_admin_only'); + } + actions.appendChild(modalBtn(t('coffres.delete'), 'danger', () => supprimerSecret(s))); + host.appendChild(actions); + + // Octrois de ce secret (dont les octrois coffre-wide, secret_id null). + const oct = viewState.octrois.filter( + (o) => o.secret_id === s.id || o.secret_id === null + ); + host.appendChild(el('h4', {}, [document.createTextNode(t('coffres.octrois'))])); + if (!oct.length) host.appendChild(text('p', '—', 'coffres-empty')); + oct.forEach((o) => { + const line = el('div', { class: 'coffres-line' }, []); + line.appendChild(text('span', `${o.cible_type} ${o.cible_id} — ${o.statut}${o.secret_id ? '' : ` (${t('coffres.grant_all_coffre')})`} — ${o.expiration ? fmtDate(o.expiration) : t('coffres.octroi_no_expiration')}`)); + const btn = modalBtn(t('coffres.revoke'), 'danger', async () => { + await revoquerOctroi(o.id); + }); + line.appendChild(btn); + host.appendChild(line); + }); + + // Journal récent de ce secret (métadonnées seules, jamais de valeur). + host.appendChild(el('h4', {}, [document.createTextNode(t('coffres.journal'))])); + const entries = viewState.journal.filter((e) => e.secret_ref === s.reference).slice(0, 20); + if (!entries.length) host.appendChild(text('p', '—', 'coffres-empty')); + entries.forEach((e) => { + host.appendChild(text('div', `${fmtDateTime(e.timestamp)} — ${e.action} — ${e.username || ''} — ${e.resultat || ''}`, 'coffres-line')); + }); +} + +function renderTable(host) { + host.innerHTML = ''; + const secrets = secretsFiltres(); + if (!secrets.length) { + host.appendChild(text('p', t('coffres.no_secrets'), 'coffres-empty')); + return; + } + const table = el('table', { class: 'coffres-table' }, []); + const thead = el('thead', {}, []); + const hr = el('tr', {}, []); + [t('coffres.name'), t('coffres.service'), t('coffres.type'), t('coffres.modified'), t('coffres.expires'), t('coffres.octrois'), t('coffres.status'), t('coffres.value'), t('coffres.actions')].forEach((h) => { + hr.appendChild(el('th', {}, [document.createTextNode(h)])); + }); + thead.appendChild(hr); + table.appendChild(thead); + const tbody = el('tbody', {}, []); + secrets.forEach((s) => { + const tr = el('tr', {}, []); + const add = (v) => tr.appendChild(el('td', {}, [document.createTextNode(v)])); + add(s.nom); + add(s.service || '—'); + add(s.type); + add(fmtDate(s.modifie_le)); + add(s.expiration ? fmtDate(s.expiration) : '—'); + add(String(s.octrois_actifs)); + add(statutSecret(s)); + add('••••••'); + const td = el('td', {}, []); + td.appendChild(modalBtn(t('coffres.detail'), '', () => { + viewState.detail = s; + renderDetail(document.getElementById('coffres-detail')); + })); + tr.appendChild(td); + tr.addEventListener('click', () => { + viewState.detail = s; + renderDetail(document.getElementById('coffres-detail')); + }); + tbody.appendChild(tr); + }); + table.appendChild(tbody); + host.appendChild(table); +} + +function renderAcces(host) { + host.innerHTML = ''; + host.appendChild(el('h3', {}, [document.createTextNode(t('coffres.acces'))])); + const coffre = coffreActuel(); + if (!coffre) return; + + // Consommateurs (admin seulement : la route l'exige). + if (isAdmin()) { + host.appendChild(el('h4', {}, [document.createTextNode(t('coffres.consumers'))])); + if (!viewState.consommateurs.length) host.appendChild(text('p', '—', 'coffres-empty')); + viewState.consommateurs.forEach((c) => { + const line = el('div', { class: 'coffres-line' }, []); + line.appendChild(text('span', `${c.nom} — ${c.jeton_prefixe || ''} — ${c.statut} — ${fmtDate(c.cree_le)}`)); + if (c.statut === 'actif') { + line.appendChild(modalBtn(t('coffres.revoke'), 'danger', async () => { + const ok = await showConfirm({ + title: t('coffres.revoke'), + message: `${c.nom} : ${t('coffres.confirm_revoke_consumer')}`, + danger: true, + }); + if (!ok) return; + await api(`/api/coffres/consommateurs/${encodeURIComponent(c.id)}`, { method: 'DELETE' }); + notifyChanged(); + await openCoffresView(coffre.slug, { panel: 'acces' }); + })); + } + host.appendChild(line); + }); + host.appendChild(modalBtn(t('coffres.new_consumer'), 'primary', () => openConsumerModal())); + // Consommateurs sans distribution dans le journal récent (limite 100 entrées). + const vus = new Set( + viewState.journal.filter((e) => e.action === 'distribution_ok').map((e) => e.consommateur) + ); + viewState.consommateurs.forEach((c) => { + if (c.statut === 'actif' && !vus.has(c.nom)) { + host.appendChild(text('div', `⚠ ${c.nom} : ${t('coffres.consumer_no_distribution')}`, 'coffres-alert')); + } + }); + } + + // Création d'octroi, avec confirmation résumant exactement l'accès accordé. + host.appendChild(el('h4', {}, [document.createTextNode(t('coffres.create_octroi'))])); + const cibleType = selectInput(CIBLES_OCTROI.map((v) => [v, v]), 'consommateur'); + const cibleIdWrap = el('div', {}, []); + const cibleIdText = textInput('', t('coffres.target_id')); + const cibleIdSelect = selectInput( + viewState.consommateurs.map((c) => [c.id, `${c.nom} (${c.jeton_prefixe || ''})`]), + viewState.consommateurs[0] ? viewState.consommateurs[0].id : '' + ); + const syncCibleId = () => { + cibleIdWrap.innerHTML = ''; + cibleIdWrap.appendChild(cibleType.value === 'consommateur' ? cibleIdSelect : cibleIdText); + }; + cibleType.addEventListener('change', syncCibleId); + syncCibleId(); + const secretSelect = selectInput( + [['', t('coffres.grant_all_coffre')]].concat( + viewState.secrets.map((s) => [s.nom, s.nom]) + ), + '' + ); + const expirationInput = el('input', { class: 'modal-input', type: 'date' }, []); + const form = el('div', { class: 'coffres-form' }, [ + field(t('coffres.target_type'), cibleType), + field(t('coffres.target_id'), cibleIdWrap), + field(t('coffres.secret'), secretSelect), + field(t('coffres.expiration'), expirationInput, t('coffres.octroi_default_90')), + ]); + form.appendChild(modalBtn(t('coffres.create_octroi'), 'primary', async () => { + const cibleId = cibleType.value === 'consommateur' ? cibleIdSelect.value : cibleIdText.value.trim(); + if (!cibleId) { showToast(t('coffres.target_id_required'), 'error'); return; } + const resume = secretSelect.value + ? `${cibleType.value} ${cibleId} → ${secretSelect.value}` + : `${cibleType.value} ${cibleId} → ${t('coffres.grant_all_coffre')}`; + const ok = await showConfirm({ + title: t('coffres.create_octroi'), + message: `${resume} — ${expirationInput.value ? fmtDate(expirationInput.value) : t('coffres.octroi_default_90')}`, + }); + if (!ok) return; + const body = { cible_type: cibleType.value, cible_id: cibleId }; + if (secretSelect.value) body.secret_nom = secretSelect.value; + if (expirationInput.value) body.expiration = expirationInput.value; + await api(`/api/coffres/${encodeURIComponent(coffre.slug)}/octrois`, { + method: 'POST', + body: JSON.stringify(body), + }); + showToast(t('coffres.octroi_created'), 'success'); + notifyChanged(); + await openCoffresView(coffre.slug, { panel: 'acces' }); + })); + host.appendChild(form); + + // Octrois du coffre. + host.appendChild(el('h4', {}, [document.createTextNode(t('coffres.octrois'))])); + if (!viewState.octrois.length) host.appendChild(text('p', '—', 'coffres-empty')); + viewState.octrois.forEach((o) => { + const secretNom = o.secret_id + ? (viewState.secrets.find((s) => s.id === o.secret_id) || {}).nom || o.secret_id + : t('coffres.grant_all_coffre'); + const line = el('div', { class: 'coffres-line' }, []); + line.appendChild(text('span', `${o.cible_type} ${o.cible_id} — ${secretNom} — ${o.statut} — ${o.expiration ? fmtDate(o.expiration) : t('coffres.octroi_no_expiration')}`)); + if (o.statut === 'actif') { + line.appendChild(modalBtn(t('coffres.revoke'), 'danger', () => revoquerOctroi(o.id))); + } + host.appendChild(line); + }); +} + +async function revoquerOctroi(id) { + const ok = await showConfirm({ + title: t('coffres.revoke'), + message: t('coffres.confirm_revoke_octroi'), + danger: true, + }); + if (!ok) return; + await api(`/api/coffres/octrois/${encodeURIComponent(id)}`, { method: 'DELETE' }); + showToast(t('coffres.octroi_revoked'), 'success'); + notifyChanged(); + await openCoffresView(viewState.currentSlug); +} + +function renderJournal(host) { + host.innerHTML = ''; + host.appendChild(el('h3', {}, [document.createTextNode(t('coffres.journal'))])); + if (!viewState.journal.length) host.appendChild(text('p', '—', 'coffres-empty')); + viewState.journal.slice(0, 100).forEach((e) => { + host.appendChild(text('div', `${fmtDateTime(e.timestamp)} — ${e.action} — ${e.username || ''} — ${e.secret_ref || ''} — ${e.resultat || ''}`, 'coffres-line')); + }); +} + +// --------------------------------------------------------------------------- +// Modales : création, rotation, suppression, consommateur, coffre, KEK +// --------------------------------------------------------------------------- + +function openCreateSecretModal(prefillSlug) { + const coffre = coffreActuel(); + const slug = prefillSlug || (coffre ? coffre.slug : null); + if (!slug) return; + const nomInput = textInput('', 'gitea-token'); + const typeSelect = selectInput(TYPES_SECRET.map((v) => [v, v]), 'token'); + const serviceInput = textInput('', 'Gitea'); + const expirationInput = el('input', { class: 'modal-input', type: 'date' }, []); + const noteInput = textInput('', ''); + const valeur = valeurField('token'); + const genBtn = modalBtn(t('coffres.generate'), '', () => { + const v = genererValeur(32); + if (v === null) { showToast(t('coffres.generate_unavailable'), 'error'); return; } + // La valeur générée vit uniquement dans le champ, jusqu'à l'enregistrement. + const input = valeur.wrap.querySelector('input, textarea'); + if (input) input.value = v; + }); + const copyGenBtn = modalBtn(t('coffres.copy_generated'), '', async () => { + // Seul moment où la valeur peut être copiée : avant l'enregistrement. + await copyText(valeur.value); + }); + typeSelect.addEventListener('change', () => { + valeur.setType(typeSelect.value); + genBtn.disabled = typeSelect.value === 'certificate'; + }); + const err = errorBox(); + const grantCheck = el('input', { type: 'checkbox' }, []); + const grantCibleType = selectInput(CIBLES_OCTROI.map((v) => [v, v]), 'consommateur'); + const grantCibleId = textInput('', t('coffres.target_id')); + const grantWrap = el('div', { style: 'display:none' }, [ + field(t('coffres.target_type'), grantCibleType), + field(t('coffres.target_id'), grantCibleId), + ]); + grantCheck.addEventListener('change', () => { + grantWrap.style.display = grantCheck.checked ? '' : 'none'; + }); + const body = el('div', {}, [ + field(t('coffres.name'), nomInput, t('coffres.name_hint')), + field(t('coffres.type'), typeSelect), + field(t('coffres.service'), serviceInput), + field(t('coffres.expiration'), expirationInput), + field(t('coffres.note'), noteInput, t('coffres.never_in_note')), + field(t('coffres.value'), valeur.wrap), + el('div', { class: 'coffres-actions' }, [genBtn, copyGenBtn]), + field(t('coffres.propose_octroi'), grantCheck), + grantWrap, + err.el, + ]); + const m = openModal(t('coffres.create_secret_title'), body, [ + modalBtn(t('common.cancel'), '', () => { valeur.clear(); m.close(); }), + modalBtn(t('coffres.save'), 'primary', async () => { + const nom = nomInput.value.trim(); + if (!NOM_SECRET_RE.test(nom)) { err.show(t('coffres.name_hint')); return; } + if (!valeur.value) { err.show(t('coffres.value_required')); return; } + try { + const payload = { + nom, + type: typeSelect.value, + valeur: valeur.value, + service: serviceInput.value.trim(), + note: noteInput.value.trim(), + }; + if (expirationInput.value) payload.expiration = expirationInput.value; + await api(`/api/coffres/${encodeURIComponent(slug)}/secrets`, { + method: 'POST', + body: JSON.stringify(payload), + }); + // Effacement immédiat : la valeur ne survit pas à l'enregistrement. + valeur.clear(); + if (grantCheck.checked && grantCibleId.value.trim()) { + try { + await api(`/api/coffres/${encodeURIComponent(slug)}/octrois`, { + method: 'POST', + body: JSON.stringify({ + cible_type: grantCibleType.value, + cible_id: grantCibleId.value.trim(), + secret_nom: nom, + }), + }); + } catch (grantErr) { + showToast(`${t('coffres.secret_saved_octroi_failed')} : ${grantErr.message}`, 'error'); + } + } + m.close(); + showToast(t('coffres.saved'), 'success'); + notifyChanged(); + await openCoffresView(slug); + } catch (e) { + err.show(e.message || t('common.error')); + } + }), + ]); + valeur.focus(); +} + +function openRotationModal(s) { + const valeur = valeurField(s.type); + const err = errorBox(); + const body = el('div', {}, [ + field(t('coffres.name'), (() => { const i = textInput(s.nom, ''); i.disabled = true; return i; })()), + field(t('coffres.value'), valeur.wrap), + err.el, + ]); + const m = openModal(t('coffres.rotation_title'), body, [ + modalBtn(t('common.cancel'), '', () => { valeur.clear(); m.close(); }), + modalBtn(t('coffres.rotation'), 'primary', async () => { + if (!valeur.value) { err.show(t('coffres.value_required')); return; } + try { + await api(`/api/coffres/${encodeURIComponent(s.reference.split('/')[0].slice(7))}/secrets/${encodeURIComponent(s.nom)}/rotation`, { + method: 'POST', + body: JSON.stringify({ valeur: valeur.value }), + }); + valeur.clear(); + m.close(); + showToast(t('coffres.rotated'), 'success'); + notifyChanged(); + await openCoffresView(viewState.currentSlug); + } catch (e) { + err.show(e.message || t('common.error')); + } + }), + ]); + valeur.focus(); +} + +async function supprimerSecret(s) { + const coffreSlug = s.reference.split('/')[0].slice(7); + const casses = viewState.octrois.filter((o) => o.secret_id === s.id || o.secret_id === null).length; + const ok = await showConfirm({ + title: t('coffres.delete'), + message: `${s.nom} — ${t('coffres.confirm_delete')} (${casses} ${t('coffres.octrois')})`, + danger: true, + }); + if (!ok) return; + await api(`/api/coffres/${encodeURIComponent(coffreSlug)}/secrets/${encodeURIComponent(s.nom)}`, { + method: 'DELETE', + }); + showToast(t('coffres.deleted'), 'success'); + viewState.detail = null; + notifyChanged(); + await openCoffresView(coffreSlug); +} + +function openConsumerModal() { + const nomInput = textInput('', 'spark'); + const err = errorBox(); + const body = el('div', {}, [field(t('coffres.name'), nomInput), err.el]); + const m = openModal(t('coffres.new_consumer'), body, [ + modalBtn(t('common.cancel'), '', () => m.close()), + modalBtn(t('coffres.save'), 'primary', async () => { + if (!nomInput.value.trim()) { err.show(t('coffres.name_required')); return; } + try { + const created = await api('/api/coffres/consommateurs', { + method: 'POST', + body: JSON.stringify({ nom: nomInput.value.trim() }), + }); + m.close(); + // Le jeton n'est rendu qu'ici : modale à usage unique, jamais loggé. + const tokenInput = textInput(created.jeton || '', ''); + tokenInput.readOnly = true; + const tokenBody = el('div', {}, [ + field(t('coffres.token_once'), tokenInput, t('coffres.token_once_hint')), + ]); + const tm = openModal(t('coffres.new_consumer'), tokenBody, [ + modalBtn(t('coffres.copy_reference'), '', async () => { await copyText(created.jeton || ''); }), + modalBtn(t('common.close'), 'primary', () => tm.close()), + ]); + void tm; + notifyChanged(); + await openCoffresView(viewState.currentSlug, { panel: 'acces' }); + } catch (e) { + err.show(e.message || t('common.error')); + } + }), + ]); +} + +function openCreateCoffreModal() { + const nomInput = textInput('', 'Homelab'); + const slugInput = textInput('', 'homelab'); + nomInput.addEventListener('input', () => { + if (!slugInput.dataset.touched) { + slugInput.value = nomInput.value.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, ''); + } + }); + slugInput.addEventListener('input', () => { slugInput.dataset.touched = '1'; }); + const perimetreOptions = PERIMETRES.filter((p) => p !== 'instance' || isAdmin()); + const perimetreSelect = selectInput(perimetreOptions.map((v) => [v, v]), 'partage'); + const descInput = textInput('', ''); + const err = errorBox(); + const body = el('div', {}, [ + field(t('coffres.name'), nomInput), + field(t('coffres.slug'), slugInput, t('coffres.slug_hint')), + field(t('coffres.perimetre'), perimetreSelect), + field(t('coffres.description'), descInput), + err.el, + ]); + const m = openModal(t('coffres.create_coffre_title'), body, [ + modalBtn(t('common.cancel'), '', () => m.close()), + modalBtn(t('coffres.save'), 'primary', async () => { + const slug = slugInput.value.trim().toLowerCase(); + if (!SLUG_RE.test(slug)) { err.show(t('coffres.slug_hint')); return; } + if (!nomInput.value.trim()) { err.show(t('coffres.name_required')); return; } + try { + const created = await api('/api/coffres', { + method: 'POST', + body: JSON.stringify({ + slug, + nom: nomInput.value.trim(), + perimetre: perimetreSelect.value, + description: descInput.value.trim(), + }), + }); + m.close(); + notifyChanged(); + await openCoffresView(created.slug); + } catch (e) { + err.show(e.message || t('common.error')); + } + }), + ]); +} + +/** Copie manuelle à usage unique, admin seulement. + * Le backend n'expose pas (encore) de distribution par session : on passe + * par un consommateur temporaire créé, utilisé une fois, puis révoqué + * immédiatement. La valeur est copiée au presse-papiers sans jamais être + * affichée, puis la variable est effacée, même en cas d'échec. */ +async function copieManuelle(s) { + if (!isAdmin()) return; + const ok = await showConfirm({ + title: t('coffres.manual_copy'), + message: t('coffres.manual_copy_confirm'), + danger: true, + }); + if (!ok) return; + let consommateurId = null; + let valeur = null; + try { + const created = await api('/api/coffres/consommateurs', { + method: 'POST', + body: JSON.stringify({ nom: `copie-manuelle-${me()}-${Date.now()}` }), + }); + consommateurId = created.id; + const jeton = created.jeton; + const res = await fetch('/api/coffres/distribuer', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jeton}`, + }, + body: JSON.stringify({ + reference: s.reference, + contexte_type: 'copie_manuelle', + contexte_id: `copie-manuelle-${Date.now()}`, + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.detail || `Erreur ${res.status}`); + valeur = data.valeur; + await copyText(valeur); + } catch (e) { + showToast(e.message || t('common.error'), 'error'); + } finally { + valeur = null; + if (consommateurId) { + try { + await api(`/api/coffres/consommateurs/${encodeURIComponent(consommateurId)}`, { method: 'DELETE' }); + } catch { /* révocation best-effort, l'octroi reste révocable depuis Accès */ } + } + } +} + +function openKekModal(mode) { + if (!isAdmin()) return; + if (mode === 'generer') { + const body = el('div', {}, [field(t('coffres.kek_generate'), text('p', t('coffres.kek_generate_hint'), 'modal-hint'))]); + const m = openModal(t('coffres.kek_generate'), body, [ + modalBtn(t('common.cancel'), '', () => m.close()), + modalBtn(t('coffres.kek_generate'), 'primary', async () => { + const data = await api('/api/coffres/admin/generer-kek', { method: 'POST', body: '{}' }); + m.close(); + const tokenInput = textInput(data.kek || '', ''); + tokenInput.readOnly = true; + const tb = el('div', {}, [field(t('coffres.token_once'), tokenInput, t('coffres.kek_once_hint'))]); + const tm = openModal(t('coffres.kek_generate'), tb, [ + modalBtn(t('coffres.copy_reference'), '', async () => { await copyText(data.kek || ''); }), + modalBtn(t('common.close'), 'primary', () => tm.close()), + ]); + void tm; + }), + ]); + } else if (mode === 'rotation-kek') { + const input = el('input', { class: 'modal-input', type: 'password', spellcheck: 'false', autocomplete: 'new-password' }, []); + const err = errorBox(); + const body = el('div', {}, [field(t('coffres.kek_new'), input, t('coffres.kek_rotation_hint')), err.el]); + const m = openModal(t('coffres.kek_rotate'), body, [ + modalBtn(t('common.cancel'), '', () => { input.value = ''; m.close(); }), + modalBtn(t('coffres.kek_rotate'), 'primary', async () => { + if (!input.value) { err.show(t('coffres.value_required')); return; } + try { + await api('/api/coffres/admin/rotation-kek', { + method: 'POST', + body: JSON.stringify({ nouvelle_kek: input.value }), + }); + input.value = ''; + m.close(); + showToast(t('coffres.kek_rotated'), 'success'); + notifyChanged(); + await openCoffresView(viewState.currentSlug); + } catch (e) { + err.show(e.message || t('common.error')); + } + }), + ]); + } +} + +// --------------------------------------------------------------------------- +// Ouverture de la vue +// --------------------------------------------------------------------------- + +export async function openCoffresView(slug, options = {}) { + const area = document.getElementById('content-area'); + if (!area) return; + const seq = ++viewState.seq; + viewState.filter = ''; + const dashboard = document.getElementById('dashboard-home'); + if (dashboard) dashboard.remove(); + area.innerHTML = ''; + document.title = 'Secrets — ObsiGate'; + const container = el('div', { class: 'coffres-view', id: 'coffres-view' }, []); + container.appendChild(text('p', t('coffres.loading'), 'coffres-empty')); + area.appendChild(container); + closeMobileSidebar(); + document.querySelectorAll('.tree-item.active').forEach((n) => n.classList.remove('active')); + document.querySelector('[data-type="virtual-secrets"]')?.classList.add('active'); + + let coffres; + try { + coffres = await api('/api/coffres'); + } catch (err) { + if (seq !== viewState.seq) return; + container.innerHTML = ''; + container.appendChild(el('h2', {}, [document.createTextNode(t('coffres.title'))])); + container.appendChild(text('p', err && err.message === 'secrets_desactives' + ? t('coffres.disabled') + : (err.message || t('common.error')), 'coffres-banner warn')); + return; + } + // Création paresseuse du coffre personnel (voir ensurePersonalCoffre). + const personnelExiste = (coffres || []).some( + (c) => c.proprietaire === me() && c.perimetre === 'personnel' + ); + if (!personnelExiste) { + try { + await ensurePersonalCoffre(); + coffres = await api('/api/coffres'); + } catch { /* la vue reste utilisable sans coffre personnel */ } + } + if (seq !== viewState.seq) return; + + viewState.coffres = coffres || []; + const wanted = slug || (viewState.coffres.find( + (c) => c.proprietaire === me() && c.perimetre === 'personnel' + ) || viewState.coffres[0] || {}).slug || null; + viewState.currentSlug = wanted; + const coffre = coffreActuel(); + + container.innerHTML = ''; + container.appendChild(el('h2', {}, [document.createTextNode(t('coffres.title'))])); + container.appendChild(text('p', t('coffres.subtitle'), 'coffres-subtitle')); + + // Bandeau KEK. + const kekOk = coffre ? !!coffre.kek_configuree : false; + const banner = el('div', { class: `coffres-banner ${kekOk ? '' : 'warn'}` }, []); + banner.appendChild(text('span', kekOk ? t('coffres.kek_ok') : t('coffres.kek_absente'))); + if (isAdmin()) { + banner.appendChild(modalBtn(t('coffres.kek_generate'), '', () => openKekModal('generer'))); + banner.appendChild(modalBtn(t('coffres.kek_rotate'), '', () => openKekModal('rotation-kek'))); + } + container.appendChild(banner); + + // Sélecteur de coffres (chips). + const chips = el('div', { class: 'coffres-chips' }, []); + viewState.coffres.forEach((c) => { + const label = c.proprietaire === me() ? c.nom : `${c.nom} (${c.proprietaire})`; + const chip = modalBtn(`${label} (${c.nb_secrets})`, c.slug === viewState.currentSlug ? 'primary' : '', async () => { + await openCoffresView(c.slug); + }); + chip.classList.add('coffres-chip'); + chips.appendChild(chip); + }); + chips.appendChild(modalBtn(`+ ${t('coffres.new_coffre')}`, '', () => openCreateCoffreModal())); + container.appendChild(chips); + + if (!coffre) { + container.appendChild(text('p', t('coffres.no_coffre'), 'coffres-empty')); + return; + } + + // Rotation DEK du coffre courant (gestionnaire seulement, backend 403 sinon). + const toolbar = el('div', { class: 'coffres-actions' }, []); + const search = textInput('', t('coffres.search_placeholder')); + search.addEventListener('input', () => { + viewState.filter = search.value; + renderTable(document.getElementById('coffres-table')); + }); + toolbar.appendChild(search); + toolbar.appendChild(modalBtn(t('coffres.new_secret'), 'primary', () => openCreateSecretModal(coffre.slug))); + toolbar.appendChild(modalBtn(t('coffres.dek_rotate'), '', async () => { + const ok = await showConfirm({ title: t('coffres.dek_rotate'), message: t('coffres.confirm_dek_rotate'), danger: true }); + if (!ok) return; + await api(`/api/coffres/${encodeURIComponent(coffre.slug)}/rotation-dek`, { method: 'POST', body: '{}' }); + notifyChanged(); + await openCoffresView(coffre.slug); + })); + container.appendChild(toolbar); + + // Données du coffre (métadonnées seules). + try { + const [secrets, octrois, journal] = await Promise.all([ + api(`/api/coffres/${encodeURIComponent(coffre.slug)}/secrets`), + api(`/api/coffres/${encodeURIComponent(coffre.slug)}/octrois`), + api(`/api/coffres/${encodeURIComponent(coffre.slug)}/journal`), + ]); + viewState.secrets = secrets || []; + viewState.octrois = octrois || []; + viewState.journal = journal || []; + if (isAdmin()) { + try { + viewState.consommateurs = await api('/api/coffres/consommateurs'); + } catch { + viewState.consommateurs = []; + } + } else { + viewState.consommateurs = []; + } + } catch (err) { + if (seq !== viewState.seq) return; + container.appendChild(text('p', err.message || t('common.error'), 'coffres-banner warn')); + return; + } + if (seq !== viewState.seq) return; + viewState.detail = null; + + const alertsHost = el('div', {}, []); + container.appendChild(alertsHost); + renderAlerts(alertsHost); + const tableHost = el('div', { id: 'coffres-table' }, []); + container.appendChild(tableHost); + renderTable(tableHost); + const detailHost = el('div', { id: 'coffres-detail', class: 'coffres-detail' }, []); + container.appendChild(detailHost); + renderDetail(detailHost); + const accesHost = el('div', { id: 'coffres-acces' }, []); + container.appendChild(accesHost); + renderAcces(accesHost); + const journalHost = el('div', { id: 'coffres-journal' }, []); + container.appendChild(journalHost); + renderJournal(journalHost); + safeCreateIcons(); + + if (options.panel === 'new') openCreateSecretModal(coffre.slug); + else if (options.panel === 'acces') accesHost.scrollIntoView({ behavior: 'smooth' }); + else if (options.panel === 'journal') journalHost.scrollIntoView({ behavior: 'smooth' }); +} + +export function openCoffresNewSecret() { + return openCoffresView(undefined, { panel: 'new' }); +} + +export function openCoffresAcces() { + return openCoffresView(undefined, { panel: 'acces' }); +} + +export function openCoffresJournal() { + return openCoffresView(undefined, { panel: 'journal' }); +} -- 2.54.0 From 2dc8be31270c1b6e7b7f1443f09816879c324148 Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 27 Oct 2026 22:08:05 -0400 Subject: [PATCH 09/12] [Coffre Secrets] Phase 1c UI : noeud virtuel Secrets sous Home-user dans sidebar.js (patron Partage #196) --- frontend/js/sidebar.js | 177 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) diff --git a/frontend/js/sidebar.js b/frontend/js/sidebar.js index 0eaec0a..d163453 100644 --- a/frontend/js/sidebar.js +++ b/frontend/js/sidebar.js @@ -4,6 +4,7 @@ import { api, AuthManager } from './auth.js'; import { populateCustomDropdown, TabManager, closeMobileSidebar, ContextMenuManager } from './ui.js'; import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory, filterRecentFiles } from './config.js'; import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText, filterSavedSearches } from './viewer.js'; +import { openCoffresView, openCoffresNewSecret, openCoffresAcces, openCoffresJournal, getCoffresForSidebar, invalidateCoffresCache } from './coffres.js'; import { performAdvancedSearch } from './search.js'; import { t } from './i18n.js'; import { filterChatMessages as _filterChat } from './filechat.js'; @@ -46,6 +47,168 @@ function _sharedIcon(size = 16) { return i; } +// --------------------------------------------------------------------------- +// Coffre Secrets — nœud virtuel « Secrets » sous le Home (Phase 1c, #19). +// Patron exact du dossier virtuel « Partage » (#196) : inséré à la racine +// du Home de l'utilisateur courant seulement, sans dossier sur disque, sans +// passer par l'indexeur. Contrairement à « Partage », le nœud est permanent +// (même sans coffre) : cliquer dessus ouvre la vue Secrets, qui crée le +// coffre personnel à la première ouverture si besoin. +// AUCUNE action de fichier n'est attachée à ce nœud ni à ses enfants : +// ni attachTreeItemActionButton, ni ContextMenuManager (qui proposerait +// renommer/déplacer/supprimer). Le menu ⋮ est un menu propre ci-dessous. +// --------------------------------------------------------------------------- + +/** Icône cadenas (U+1F510) du nœud Secrets, distincte de la maison et du diamant. */ +function _secretsIcon() { + const s = document.createElement("span"); + s.className = "coffres-icon-lock"; + s.textContent = "\u{1F510}"; + return s; +} + +function _isOwnHome(vaultName) { + const me = (AuthManager.getUser() || {}).username; + return !!me && vaultName === `home-${me}`; +} + +/** Construit le nœud Secrets + son conteneur d'enfants (coffres). */ +async function _buildSecretsNode(vaultName) { + const pair = document.createDocumentFragment(); + const item = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": "Secrets", "data-type": "virtual-secrets" }, [icon("chevron-right", 14), _secretsIcon(), el("span", { class: "tree-item-text" }, [document.createTextNode(t("coffres.secrets"))])]); + const sub = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-Secrets` }); + item.addEventListener("click", async () => { + scrollTreeItemIntoView(item, false); + if (sub.classList.contains("collapsed")) { + if (sub.children.length === 0) await _fillSecretsNode(item, sub, vaultName); + sub.classList.remove("collapsed"); + const chev = item.querySelector("[data-lucide]"); + if (chev) chev.setAttribute("data-lucide", "chevron-down"); + safeCreateIcons(); + } else { + sub.classList.add("collapsed"); + const chev = item.querySelector("[data-lucide]"); + if (chev) chev.setAttribute("data-lucide", "chevron-right"); + safeCreateIcons(); + } + await openCoffresView(); + closeMobileSidebar(); + }); + item.addEventListener("contextmenu", (e) => { + e.preventDefault(); + _openSecretsMenu(e.clientX, e.clientY); + }); + const actionBtn = el("button", { class: "tree-item-action-btn", type: "button", title: t("coffres.secrets") }, [document.createTextNode("\u22EE")]); + actionBtn.addEventListener("click", (e) => { + e.stopPropagation(); + const r = actionBtn.getBoundingClientRect(); + _openSecretsMenu(Math.min(r.right, window.innerWidth - 16), r.bottom); + }); + item.appendChild(actionBtn); + pair.appendChild(item); + pair.appendChild(sub); + // Compteur et enfants, en arrière-plan : le nœud existe même si l'API échoue. + _fillSecretsNode(item, sub, vaultName).catch(() => {}); + return pair; +} + +/** Remplit le compteur et les enfants (coffres) d'un nœud Secrets existant. */ +async function _fillSecretsNode(item, sub, vaultName) { + const data = await getCoffresForSidebar(); + item.querySelectorAll(".badge-small, .coffres-alert-dot").forEach((n) => n.remove()); + if (!data.disabled && data.personnel) { + if (!data.personnel.kek_configuree) { + const dot = document.createElement("span"); + dot.className = "coffres-alert-dot"; + dot.textContent = "\u26A0"; + dot.title = t("coffres.kek_absente"); + item.appendChild(dot); + } else { + item.appendChild(smallBadge(data.personnel.nb_secrets)); + } + } + sub.innerHTML = ""; + const coffres = (data.coffres || []).slice().sort((a, b) => { + const aPers = a.proprietaire === (AuthManager.getUser() || {}).username && a.perimetre === "personnel" ? 0 : 1; + const bPers = b.proprietaire === (AuthManager.getUser() || {}).username && b.perimetre === "personnel" ? 0 : 1; + return aPers - bPers || String(a.nom).localeCompare(String(b.nom)); + }); + coffres.forEach((c) => { + const me = (AuthManager.getUser() || {}).username; + const label = c.proprietaire === me ? c.nom : `${c.nom} (${c.proprietaire})`; + const child = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": `Secrets/${c.slug}`, "data-type": "virtual-coffre", "data-coffre-slug": c.slug }, [icon("lock", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(label)]), smallBadge(c.nb_secrets)]); + child.addEventListener("click", async () => { + scrollTreeItemIntoView(child, false); + await openCoffresView(c.slug); + closeMobileSidebar(); + }); + child.addEventListener("contextmenu", (e) => { + e.preventDefault(); + _openSecretsMenu(e.clientX, e.clientY); + }); + sub.appendChild(child); + }); + safeCreateIcons(); +} + +/** Rafraîchit tous les nœuds Secrets affichés (après création, rotation, révocation…). */ +async function _refreshSecretsNodes() { + const items = Array.from(document.querySelectorAll('[data-type="virtual-secrets"]')); + for (const item of items) { + const vaultName = item.dataset.vault; + const sub = item.nextElementSibling; + if (sub && sub.classList.contains("tree-children")) { + await _fillSecretsNode(item, sub, vaultName); + } + } +} + +if (!window.__obsigateSecretsListener) { + window.__obsigateSecretsListener = true; + document.addEventListener("obsigate:coffres-changed", () => { + invalidateCoffresCache(); + _refreshSecretsNodes().catch(() => {}); + }); +} + +/** Menu ⋮ du nœud Secrets : Ouvrir, Nouveau secret, Gérer les accès, Journal. + * Menu propre, jamais ContextMenuManager (pas d'action de fichier). */ +function _openSecretsMenu(x, y) { + document.querySelectorAll(".coffres-context-menu").forEach((n) => n.remove()); + const menu = el("div", { class: "coffres-context-menu" }, []); + const entries = [ + [t("coffres.detail"), () => openCoffresView()], + [t("coffres.new_secret"), () => openCoffresNewSecret()], + [t("coffres.acces"), () => openCoffresAcces()], + [t("coffres.journal"), () => openCoffresJournal()], + ]; + entries.forEach(([label, run]) => { + const b = el("button", { class: "coffres-context-item", type: "button" }, [document.createTextNode(label)]); + b.addEventListener("click", async () => { + menu.remove(); + await run(); + closeMobileSidebar(); + }); + menu.appendChild(b); + }); + document.body.appendChild(menu); + const rect = menu.getBoundingClientRect(); + menu.style.left = Math.max(8, Math.min(x, window.innerWidth - rect.width - 8)) + "px"; + menu.style.top = Math.max(8, Math.min(y, window.innerHeight - rect.height - 8)) + "px"; + const closer = (e) => { + if (!menu.contains(e.target)) { + menu.remove(); + document.removeEventListener("click", closer, true); + document.removeEventListener("keydown", onKey); + } + }; + const onKey = (e) => { if (e.key === "Escape") { menu.remove(); document.removeEventListener("click", closer, true); document.removeEventListener("keydown", onKey); } }; + setTimeout(() => { + document.addEventListener("click", closer, true); + document.addEventListener("keydown", onKey); + }, 0); +} + /** Fichier partagé (créé ou reçu) → icône share-2 bleue, sinon icône usuelle. */ function sharedFileIconSync(sharedPaths, itemName, vaultName, itemPath) { if (sharedPaths && sharedPaths.has(`${vaultName}::${itemPath}`)) return _sharedIcon(); @@ -494,6 +657,12 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) { } }); + // Coffre Secrets (Phase 1c) : même nœud virtuel que dans loadDirectory. + if (me && vaultName === `home-${me}` && !dirPath) { + const pair = await _buildSecretsNode(vaultName); + if (pair) fragment.insertBefore(pair, fragment.firstChild); + } + // Replace container content in a single batch operation to avoid flash container.textContent = ""; container.appendChild(fragment); @@ -838,6 +1007,14 @@ async function loadDirectory(vaultName, dirPath, container) { } }); + // Coffre Secrets (Phase 1c) : nœud virtuel permanent en tête, à la racine + // du Home de l'utilisateur courant seulement, jamais sous un autre Home + // ni sous un vault de notes, et jamais comme un dossier physique. + if (me && vaultName === `home-${me}` && !dirPath) { + const pair = await _buildSecretsNode(vaultName); + if (pair) fragment.insertBefore(pair, fragment.firstChild); + } + container.appendChild(fragment); safeCreateIcons(); } -- 2.54.0 From d5ae66466a5e3201af9394fa452b2562514faaaa Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 27 Oct 2026 22:08:16 -0400 Subject: [PATCH 10/12] [Coffre Secrets] Phase 1c UI : styles vue Secrets et menu contextuel --- frontend/style.css | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/frontend/style.css b/frontend/style.css index b23887f..1d405c8 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -14331,3 +14331,35 @@ body.has-draco-bar .app-container { height: calc(100vh - 60px); } color: var(--text-primary); font: inherit; font-size: 13.5px; } .draco-liens-erreur { color: var(--err); font-size: 13px; margin: 8px 0 0; } + +/* --------------------------------------------------------------------------- + * Coffre Secrets (Phase 1c, issue #19) — vue Secrets + nœud sidebar. + * Réutilise les variables de thème existantes ; aucune valeur de secret + * n'est jamais affichée par ces styles (colonne valeur = •••••• en texte). + * --------------------------------------------------------------------------- */ +.coffres-view { padding: 16px 20px; max-width: 1100px; } +.coffres-subtitle { color: var(--text-muted); margin: 0 0 12px; } +.coffres-banner { padding: 10px 12px; margin: 12px 0; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.coffres-banner.warn { border-color: var(--warning, #f59e0b); } +.coffres-chips { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; } +.coffres-chip { white-space: nowrap; } +.coffres-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 12px 0; } +.coffres-actions .modal-input { flex: 1 1 220px; } +.coffres-table { width: 100%; border-collapse: collapse; margin: 12px 0; } +.coffres-table th, .coffres-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border, #21262d); vertical-align: top; } +.coffres-table tbody tr { cursor: pointer; } +.coffres-table tbody tr:hover { background: var(--bg-hover, #1f2430); } +.coffres-detail { margin-top: 16px; padding: 14px; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; } +.coffres-dl { display: grid; grid-template-columns: 160px 1fr; gap: 4px 12px; margin: 12px 0; } +.coffres-dl dt { color: var(--text-muted); } +.coffres-dl dd { margin: 0; overflow-wrap: anywhere; } +.coffres-line { padding: 6px 0; border-bottom: 1px solid var(--border, #21262d); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: space-between; } +.coffres-empty { color: var(--text-muted); } +.coffres-alerts { margin: 12px 0; } +.coffres-alert { color: var(--warning, #f59e0b); padding: 2px 0; } +.coffres-form { display: grid; gap: 8px; max-width: 560px; margin: 8px 0; } +.coffres-icon-lock { font-size: 16px; line-height: 1; } +.coffres-alert-dot { color: var(--warning, #f59e0b); font-weight: 700; margin-left: 4px; } +.coffres-context-menu { position: fixed; z-index: 1000; min-width: 220px; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); } +.coffres-context-item { display: block; width: 100%; text-align: left; padding: 8px 12px; background: transparent; border: none; color: var(--text-primary, #e6edf3); cursor: pointer; border-radius: 6px; } +.coffres-context-item:hover { background: var(--bg-hover, #1f2430); } -- 2.54.0 From 69f3968d4a80c2f9d6b2ec53b8b3528d59ccc231 Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 27 Oct 2026 22:08:20 -0400 Subject: [PATCH 11/12] [Coffre Secrets] Phase 1c UI : cles i18n coffres.* (fr) --- frontend/locales/fr.json | 93 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index eb43f0a..6a0a4dc 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -2666,5 +2666,96 @@ "guide105.dl_md_title": "Télécharger ce guide en Markdown", "guide105.dl_pdf_title": "Télécharger ce guide en PDF", "guide105.export_title": "Guide d'utilisation ObsiGate", - "guide105.export_footer": "Généré depuis ObsiGate {version} — {date}. Ce document est la copie du guide intégré ; la version la plus récente est toujours dans l'application." + "guide105.export_footer": "Généré depuis ObsiGate {version} — {date}. Ce document est la copie du guide intégré ; la version la plus récente est toujours dans l'application.", + "coffres.acces": "Accès et consommateurs", + "coffres.actions": "Actions", + "coffres.active": "Actif", + "coffres.confirm_dek_rotate": "Pivoter la DEK de ce coffre ? Les secrets seront re-chiffrés, sans changement pour les consommateurs.", + "coffres.confirm_delete": "Supprimer ce secret ? Les octrois suivants seront cassés :", + "coffres.confirm_revoke_consumer": "Révoquer ce consommateur ? Toutes ses distributions seront refusées.", + "coffres.confirm_revoke_octroi": "Révoquer cet octroi ? L'accès sera refusé dès le prochain appel.", + "coffres.consumer_no_distribution": "Aucune distribution dans le journal récent.", + "coffres.consumers": "Consommateurs", + "coffres.copied": "Copié.", + "coffres.copy_failed": "Copie impossible.", + "coffres.copy_generated": "Copier la valeur générée", + "coffres.copy_reference": "Copier", + "coffres.create_coffre_title": "Nouveau coffre", + "coffres.create_octroi": "Créer un octroi", + "coffres.create_secret_title": "Nouveau secret", + "coffres.created": "Créé", + "coffres.dek_rotate": "Rotation DEK", + "coffres.delete": "Supprimer", + "coffres.deleted": "Secret supprimé.", + "coffres.description": "Description", + "coffres.detail": "Détail", + "coffres.disabled": "Les coffres de secrets sont désactivés sur cette instance (OBSIGATE_SECRETS_ENABLED).", + "coffres.expiration": "Expiration", + "coffres.expired": "Expiré", + "coffres.expires": "Expire", + "coffres.expiring_soon": "Expire bientôt", + "coffres.generate": "Générer", + "coffres.generate_unavailable": "Génération indisponible dans ce navigateur.", + "coffres.grant_all_coffre": "Tout le coffre", + "coffres.journal": "Journal d'accès", + "coffres.kek_absente": "Clé maître absente : les métadonnées sont visibles, mais aucun secret ne peut être créé ou distribué.", + "coffres.kek_generate": "Générer une KEK", + "coffres.kek_generate_hint": "La KEK générée n'est rendue qu'une fois et n'est jamais stockée par ObsiGate : sauvegardez-la hors dépôt immédiatement.", + "coffres.kek_new": "Nouvelle KEK", + "coffres.kek_ok": "Clé maître configurée.", + "coffres.kek_once_hint": "Copiez-la maintenant et sauvegardez-la hors dépôt : elle ne sera plus jamais affichée.", + "coffres.kek_rotate": "Rotation KEK", + "coffres.kek_rotated": "KEK pivotée. Pensez à remplacer la KEK dans l'environnement ou le fichier KEK.", + "coffres.kek_rotation_hint": "Après la rotation, remplacez la KEK dans l'environnement ou le fichier KEK, sinon les distributions échoueront.", + "coffres.last_use": "Dernière utilisation", + "coffres.loading": "Chargement des coffres…", + "coffres.manual_copy": "Copie manuelle à usage unique", + "coffres.manual_copy_admin_only": "Réservée aux administrateurs (aucun endpoint de distribution par session n'existe encore côté backend).", + "coffres.manual_copy_confirm": "Un consommateur temporaire sera créé, la valeur sera copiée au presse-papiers sans être affichée, puis le consommateur sera révoqué immédiatement. La distribution sera auditée.", + "coffres.modified": "Modifié", + "coffres.name": "Nom", + "coffres.name_hint": "Minuscules, chiffres, - ou _ ; 3 à 128 caractères (ex. gitea-token).", + "coffres.name_required": "Un nom est requis.", + "coffres.never_in_note": "Ne collez jamais la valeur dans la note.", + "coffres.new_coffre": "Nouveau coffre", + "coffres.new_consumer": "Nouveau consommateur", + "coffres.new_secret": "Nouveau secret", + "coffres.no_coffre": "Aucun coffre visible.", + "coffres.no_secrets": "Aucun secret dans ce coffre.", + "coffres.note": "Note", + "coffres.octroi_created": "Octroi créé.", + "coffres.octroi_default_90": "Vide = expiration par défaut (90 jours).", + "coffres.octroi_no_expiration": "Sans expiration", + "coffres.octroi_revoked": "Octroi révoqué.", + "coffres.octrois": "Octrois", + "coffres.perimetre": "Périmètre", + "coffres.propose_octroi": "Proposer un octroi (optionnel)", + "coffres.reference": "Référence", + "coffres.revoke": "Révoquer", + "coffres.rotated": "Secret pivoté : la nouvelle version est active.", + "coffres.rotation": "Rotation", + "coffres.rotation_title": "Rotation du secret", + "coffres.save": "Enregistrer", + "coffres.saved": "Secret enregistré. La valeur ne sera plus affichée.", + "coffres.search_placeholder": "Filtrer les secrets…", + "coffres.secret": "Secret", + "coffres.secret_saved_octroi_failed": "Secret enregistré, mais l'octroi a échoué", + "coffres.secrets": "Secrets", + "coffres.select_secret": "Sélectionnez un secret pour voir ses métadonnées.", + "coffres.service": "Service", + "coffres.show_before_save": "Afficher avant l'enregistrement", + "coffres.slug": "Identifiant (slug)", + "coffres.slug_hint": "Minuscules, chiffres, - ou _ ; 3 à 64 caractères.", + "coffres.status": "Statut", + "coffres.subtitle": "Coffres de secrets — métadonnées seules, aucune valeur n'est jamais affichée après l'enregistrement.", + "coffres.target_id": "Identifiant de la cible", + "coffres.target_id_required": "L'identifiant de la cible est requis.", + "coffres.target_type": "Type de cible", + "coffres.title": "Secrets", + "coffres.token_once": "Jeton (affiché une seule fois)", + "coffres.token_once_hint": "Copiez-le maintenant : il ne sera plus jamais affiché.", + "coffres.type": "Type", + "coffres.value": "Valeur", + "coffres.value_required": "Une valeur est requise.", + "coffres.version": "Version" } -- 2.54.0 From 06c12d0383dc5775607b27fd28a44f4cc0799bfd Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 27 Oct 2026 22:08:23 -0400 Subject: [PATCH 12/12] [Coffre Secrets] Phase 1c UI : cles i18n coffres.* (en) --- frontend/locales/en.json | 93 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 29d5db4..841d55f 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -2666,5 +2666,96 @@ "guide105.dl_md_title": "Download this guide as Markdown", "guide105.dl_pdf_title": "Download this guide as PDF", "guide105.export_title": "ObsiGate User Guide", - "guide105.export_footer": "Generated from ObsiGate {version} — {date}. This document mirrors the in-app guide; the latest version always lives in the application." + "guide105.export_footer": "Generated from ObsiGate {version} — {date}. This document mirrors the in-app guide; the latest version always lives in the application.", + "coffres.acces": "Access and consumers", + "coffres.actions": "Actions", + "coffres.active": "Active", + "coffres.confirm_dek_rotate": "Rotate this vault's DEK? Secrets will be re-encrypted, with no change for consumers.", + "coffres.confirm_delete": "Delete this secret? The following grants will break:", + "coffres.confirm_revoke_consumer": "Revoke this consumer? All of its distributions will be denied.", + "coffres.confirm_revoke_octroi": "Revoke this grant? Access will be denied on the next call.", + "coffres.consumer_no_distribution": "No distribution in the recent log.", + "coffres.consumers": "Consumers", + "coffres.copied": "Copied.", + "coffres.copy_failed": "Copy failed.", + "coffres.copy_generated": "Copy the generated value", + "coffres.copy_reference": "Copy", + "coffres.create_coffre_title": "New vault", + "coffres.create_octroi": "Create a grant", + "coffres.create_secret_title": "New secret", + "coffres.created": "Created", + "coffres.dek_rotate": "Rotate DEK", + "coffres.delete": "Delete", + "coffres.deleted": "Secret deleted.", + "coffres.description": "Description", + "coffres.detail": "Details", + "coffres.disabled": "Secret vaults are disabled on this instance (OBSIGATE_SECRETS_ENABLED).", + "coffres.expiration": "Expiration", + "coffres.expired": "Expired", + "coffres.expires": "Expires", + "coffres.expiring_soon": "Expiring soon", + "coffres.generate": "Generate", + "coffres.generate_unavailable": "Generation is unavailable in this browser.", + "coffres.grant_all_coffre": "Whole vault", + "coffres.journal": "Access log", + "coffres.kek_absente": "Master key missing: metadata is visible, but no secret can be created or distributed.", + "coffres.kek_generate": "Generate a KEK", + "coffres.kek_generate_hint": "The generated KEK is returned only once and is never stored by ObsiGate: back it up outside the repository immediately.", + "coffres.kek_new": "New KEK", + "coffres.kek_ok": "Master key configured.", + "coffres.kek_once_hint": "Copy it now and back it up outside the repository: it will never be shown again.", + "coffres.kek_rotate": "Rotate KEK", + "coffres.kek_rotated": "KEK rotated. Remember to replace the KEK in the environment or KEK file.", + "coffres.kek_rotation_hint": "After rotation, replace the KEK in the environment or KEK file, otherwise distributions will fail.", + "coffres.last_use": "Last use", + "coffres.loading": "Loading vaults…", + "coffres.manual_copy": "One-time manual copy", + "coffres.manual_copy_admin_only": "Admin only (no session-based distribution endpoint exists yet on the backend).", + "coffres.manual_copy_confirm": "A temporary consumer will be created, the value will be copied to the clipboard without being shown, then the consumer will be revoked immediately. The distribution will be audited.", + "coffres.modified": "Modified", + "coffres.name": "Name", + "coffres.name_hint": "Lowercase, digits, - or _ ; 3 to 128 characters (e.g. gitea-token).", + "coffres.name_required": "A name is required.", + "coffres.never_in_note": "Never paste the value into the note.", + "coffres.new_coffre": "New vault", + "coffres.new_consumer": "New consumer", + "coffres.new_secret": "New secret", + "coffres.no_coffre": "No visible vault.", + "coffres.no_secrets": "No secret in this vault.", + "coffres.note": "Note", + "coffres.octroi_created": "Grant created.", + "coffres.octroi_default_90": "Empty = default expiration (90 days).", + "coffres.octroi_no_expiration": "No expiration", + "coffres.octroi_revoked": "Grant revoked.", + "coffres.octrois": "Grants", + "coffres.perimetre": "Scope", + "coffres.propose_octroi": "Propose a grant (optional)", + "coffres.reference": "Reference", + "coffres.revoke": "Revoke", + "coffres.rotated": "Secret rotated: the new version is active.", + "coffres.rotation": "Rotate", + "coffres.rotation_title": "Rotate secret", + "coffres.save": "Save", + "coffres.saved": "Secret saved. The value will no longer be shown.", + "coffres.search_placeholder": "Filter secrets…", + "coffres.secret": "Secret", + "coffres.secret_saved_octroi_failed": "Secret saved, but the grant failed", + "coffres.secrets": "Secrets", + "coffres.select_secret": "Select a secret to see its metadata.", + "coffres.service": "Service", + "coffres.show_before_save": "Show before saving", + "coffres.slug": "Slug", + "coffres.slug_hint": "Lowercase, digits, - or _ ; 3 to 64 characters.", + "coffres.status": "Status", + "coffres.subtitle": "Secret vaults — metadata only, a value is never shown again after it is saved.", + "coffres.target_id": "Target id", + "coffres.target_id_required": "A target id is required.", + "coffres.target_type": "Target type", + "coffres.title": "Secrets", + "coffres.token_once": "Token (shown only once)", + "coffres.token_once_hint": "Copy it now: it will never be shown again.", + "coffres.type": "Type", + "coffres.value": "Value", + "coffres.value_required": "A value is required.", + "coffres.version": "Version" } -- 2.54.0