diff --git a/backend/routers/coffres.py b/backend/routers/coffres.py index 34d0ab4..7aeb8fc 100644 --- a/backend/routers/coffres.py +++ b/backend/routers/coffres.py @@ -12,6 +12,14 @@ Règles d'or, appliquées côté serveur à chaque requête : - 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. +- Seule exception, voulue : ``POST /api/coffres/{slug}/secrets/{nom}/reveler`` + rend la valeur au gestionnaire du coffre authentifié par sa session + (propriétaire, ou admin sur coffre d'instance — jamais un simple + lecteur par octroi utilisateur), pour l'affichage et la copie manuelle + dans le panneau détail. Chaque révélation est auditée + (``revelation_manuelle``) et met à jour la dernière utilisation ; la + valeur n'est jamais journalisée et le frontend l'efface dès qu'elle + est masquée ou que le détail change. - 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 @@ -622,6 +630,50 @@ async def api_rotate_secret( return _metadata_secret(coffre, secret) +@router.post("/api/coffres/{slug}/secrets/{nom}/reveler") +async def api_reveler_secret( + slug: str, nom: str, current_user=Depends(require_auth) +): + """Révélation manuelle pour le panneau détail (afficher / copier). + + Session utilisateur + gestion du coffre exigées : un lecteur par + octroi utilisateur voit les métadonnées mais ne révèle jamais la + valeur. Chaque appel est audité et touche la dernière utilisation. + """ + _exiger_actives() + coffre = _coffre_or_404(slug) + _exiger_gerer(current_user, coffre) + secret = _secret_or_404(coffre, nom) + try: + valeur = distribuer_valeur(coffre, secret) + except SecretExpireError: + _audit(_username(current_user), "revelation_refusee", + coffre=coffre["slug"], + secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}", + resultat="secret_expire") + raise HTTPException(status_code=410, detail="secret_expire") from None + except KEKAbsenteError: + _audit(_username(current_user), "revelation_refusee", + coffre=coffre["slug"], + secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}", + resultat="kek_absente") + raise HTTPException(status_code=503, detail="kek_absente") from None + except CryptoIndisponibleError: + _audit(_username(current_user), "revelation_refusee", + coffre=coffre["slug"], + secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}", + resultat="crypto_indisponible") + raise HTTPException(status_code=503, + detail="crypto_indisponible") from None + toucher_utilisation(secret["id"]) + _audit(_username(current_user), "revelation_manuelle", + coffre=coffre["slug"], + secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}", + resultat="ok") + return {"reference": f"coffre:{coffre['slug']}/{secret['nom']}", + "valeur": valeur, "version": secret["version_courante"]} + + @router.delete("/api/coffres/{slug}/secrets/{nom}") async def api_delete_secret( slug: str, nom: str, current_user=Depends(require_auth) diff --git a/docs/COFFRE-contrat-distribution.md b/docs/COFFRE-contrat-distribution.md index 8a809f0..4b83cbb 100644 --- a/docs/COFFRE-contrat-distribution.md +++ b/docs/COFFRE-contrat-distribution.md @@ -73,6 +73,16 @@ configurée (sinon 503 `kek_absente`). SSO : une session utilisateur, même admin, ne distribue jamais (401 `consommateur_invalide`). Il n'existe pas d'endpoint de distribution par session (voir §6). +- Révélation manuelle (panneau détail, hors distribution) : `POST + /api/coffres/{slug}/secrets/{nom}/reveler` rend la valeur au + gestionnaire du coffre authentifié par sa session (propriétaire, + ou admin sur coffre d'instance), pour l'afficher ou la copier. + Ce n'est pas une distribution : aucun octroi n'est créé, aucun + consommateur ne reçoit la valeur, chaque révélation est auditée + (`revelation_manuelle`) et met à jour la dernière utilisation. + Un lecteur par octroi utilisateur (métadonnées visibles) reçoit + 403. L'ancienne copie manuelle par consommateur temporaire a + été supprimée (elle échouait : 403 octroi_absent). - Ajouter une app = enregistrer un consommateur + créer des octrois, **sans modification du coffre** et sans toucher à ses secrets. diff --git a/frontend/js/coffres.js b/frontend/js/coffres.js index 1334d33..123a9c2 100644 --- a/frontend/js/coffres.js +++ b/frontend/js/coffres.js @@ -4,9 +4,15 @@ * * 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 ». + * jamais un texte chiffré, jamais un nonce). La valeur d'un secret + * enregistré reste donc masquée (« •••••• ») par défaut ; seul le + * gestionnaire du coffre (propriétaire, ou admin sur coffre + * d'instance) peut la révéler, via POST .../secrets/{nom}/reveler + * (session + gestion, chaque révélation auditée) : bouton + * Afficher/Masquer dans le détail, ou « Copier la valeur » sans + * l'afficher. La valeur est effacée dès qu'elle est masquée ou que + * le détail change ; un simple lecteur par octroi utilisateur ne + * révèle jamais. * - 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). @@ -14,10 +20,11 @@ * 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. + * - L'ancienne copie manuelle à usage unique par consommateur + * temporaire a été supprimée : elle échouait systématiquement + * (aucun octroi pour le consommateur temporaire, donc 403 + * octroi_absent à la distribution). Elle est remplacée par + * « Copier la valeur », qui passe par l'endpoint de révélation. * - Phase 2 (issue #20) : « Importer l'existant » migre les clés de * data/api_keys.json vers le coffre d'instance « ObsiGate interne », * en deux temps (import, puis purge confirmée). Aucune valeur ne @@ -52,6 +59,8 @@ let _coffresCache = null; let _coffresCacheAt = 0; const CACHE_MS = 30000; +let _valeurAffichee = null; // valeur révélée du détail courant, sinon null + const viewState = { coffres: [], currentSlug: null, @@ -354,6 +363,7 @@ function renderAlerts(host) { function renderDetail(host) { const s = viewState.detail; + _valeurAffichee = null; host.innerHTML = ''; if (!s) { host.appendChild(text('p', t('coffres.select_secret'), 'coffres-empty')); @@ -377,7 +387,9 @@ function renderDetail(host) { 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)])); + const dd = el('dd', {}, [document.createTextNode(v)]); + if (k === t('coffres.value')) dd.id = 'coffres-valeur'; + dl.appendChild(dd); }); host.appendChild(dl); const actions = el('div', { class: 'coffres-actions' }, []); @@ -386,11 +398,36 @@ function renderDetail(host) { 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'); - } + const btnAfficher = modalBtn(t('coffres.show_value'), '', async () => { + if (_valeurAffichee !== null) { + _valeurAffichee = null; + document.getElementById('coffres-valeur').textContent = '••••••'; + btnAfficher.textContent = t('coffres.show_value'); + return; + } + let valeur; + try { + valeur = await revelerValeur(s); + } catch (e) { + showToast(e.message || t('common.error'), 'error'); + return; + } + _valeurAffichee = valeur; + document.getElementById('coffres-valeur').textContent = valeur; + btnAfficher.textContent = t('coffres.hide_value'); + }); + actions.appendChild(btnAfficher); + actions.appendChild(modalBtn(t('coffres.copy_value'), '', async () => { + let valeur; + try { + valeur = await revelerValeur(s); + } catch (e) { + showToast(e.message || t('common.error'), 'error'); + return; + } + await copyText(valeur); + valeur = null; + })); actions.appendChild(modalBtn(t('coffres.delete'), 'danger', () => supprimerSecret(s))); host.appendChild(actions); @@ -819,55 +856,19 @@ function openCreateCoffreModal() { ]); } -/** 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 */ } - } - } +/** Révélation manuelle (gestionnaire du coffre seulement, auditée + * côté backend). Ne conserve rien : l'appelant affiche ou copie + * puis efface. Un lecteur par octroi utilisateur reçoit 403. */ +async function revelerValeur(s) { + const ref = (s.reference || '').replace(/^coffre:/, ''); + const [slug, nom] = ref.split('/'); + const res = await fetch( + `/api/coffres/${encodeURIComponent(slug)}/secrets/${encodeURIComponent(nom)}/reveler`, + { method: 'POST', credentials: 'include' }, + ); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.detail || `Erreur ${res.status}`); + return data.valeur; } /** Phase 2 — « Importer l'existant » (admin seulement, deux temps). diff --git a/frontend/locales/en.json b/frontend/locales/en.json index e4958ff..cdbea61 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -2680,6 +2680,9 @@ "coffres.copy_failed": "Copy failed.", "coffres.copy_generated": "Copy the generated value", "coffres.copy_reference": "Copy", + "coffres.show_value": "Show", + "coffres.hide_value": "Hide", + "coffres.copy_value": "Copy value", "coffres.create_coffre_title": "New vault", "coffres.create_octroi": "Create a grant", "coffres.create_secret_title": "New secret", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 8968e75..fb65e70 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -2680,6 +2680,9 @@ "coffres.copy_failed": "Copie impossible.", "coffres.copy_generated": "Copier la valeur générée", "coffres.copy_reference": "Copier", + "coffres.show_value": "Afficher", + "coffres.hide_value": "Masquer", + "coffres.copy_value": "Copier la valeur", "coffres.create_coffre_title": "Nouveau coffre", "coffres.create_octroi": "Créer un octroi", "coffres.create_secret_title": "Nouveau secret", diff --git a/tests/test_coffre_secrets.py b/tests/test_coffre_secrets.py index 68391b4..57cea22 100644 --- a/tests/test_coffre_secrets.py +++ b/tests/test_coffre_secrets.py @@ -778,3 +778,56 @@ class TestDistribuables: # Flag désactivé : 503 comme les autres routes. monkeypatch.setenv("OBSIGATE_SECRETS_ENABLED", "0") assert self._lister(client, jeton).status_code == 503 + + +# --------------------------------------------------------------------------- +# Révélation manuelle (panneau détail : Afficher / Copier la valeur) +# --------------------------------------------------------------------------- + +class TestRevelation: + def test_proprietaire_revele_et_audit_sans_valeur(self, client, env_coffre): + _creer_coffre_secret(client) + resp = client.post("/api/coffres/personnel/secrets/gitea-token/reveler") + assert resp.status_code == 200, resp.text + corps = resp.json() + assert corps["valeur"] == VALEUR_TEST + assert corps["reference"] == "coffre:personnel/gitea-token" + assert corps["version"] == 1 + audit = (env_coffre / "audit.log").read_text(encoding="utf-8") + assert "revelation_manuelle" in audit + assert VALEUR_TEST not in audit + + def test_lecteur_octroi_utilisateur_ne_revele_pas(self, client): + _creer_coffre_secret(client) + resp = client.post("/api/coffres/personnel/octrois", json={ + "cible_type": "utilisateur", "cible_id": "alice", + "secret_nom": "gitea-token"}) + assert resp.status_code == 200, resp.text + _connecter(client, "alice", role="user") + # Voit les métadonnées… + assert client.get("/api/coffres/personnel/secrets").status_code == 200 + # …mais ne révèle jamais la valeur. + resp = client.post("/api/coffres/personnel/secrets/gitea-token/reveler") + assert resp.status_code == 403 + + def test_secret_inexistant_404(self, client): + _creer_coffre_secret(client) + resp = client.post("/api/coffres/personnel/secrets/absent/reveler") + assert resp.status_code == 404 + + def test_secret_expire_410(self, client): + _creer_coffre_secret(client) + 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, resp.text + resp = client.post("/api/coffres/personnel/secrets/vieux-jeton/reveler") + assert resp.status_code == 410 + assert resp.json()["detail"] == "secret_expire" + + def test_flag_desactive_503(self, client, monkeypatch): + _creer_coffre_secret(client) + monkeypatch.setenv("OBSIGATE_SECRETS_ENABLED", "0") + resp = client.post("/api/coffres/personnel/secrets/gitea-token/reveler") + assert resp.status_code == 503