Skip to main content
Glama
MCP Vault

MCP Vault

by stephen94125

🔐 MCP Vault

Gestion sĂ©curisĂ©e des secrets pour agents IA — OpenBao embedded

🇬🇧 English version

MCP Vault est un serveur MCP qui fournit un coffre-fort de secrets pour les agents IA et les missions. Il embarque OpenBao (fork open-source de HashiCorp Vault, Linux Foundation) comme moteur de chiffrement.

Pensez 1Password, mais pour vos agents IA.

📾 Console d'administration

Dashboard

Vaults & Secrets

Audit & Alertes

Dashboard

Vaults

Audit


📖 Documentation

Document

Description

ARCHITECTURE.md

SpĂ©cification complĂšte — vision, architecture ASGI 6 couches, vaults, SSH CA, policies MCP (6 exemples prĂȘts Ă  l'emploi), sĂ©curitĂ© des clĂ©s unseal (3 facteurs), roadmap HSM

TECHNICAL.md

Documentation technique — modules source (incl. PKI v0.5.1), stack ASGI 6 couches, Docker, tests, dĂ©pendances, roadmap

SECURITY_AUDIT.md

Rapport d'audit de sĂ©curitĂ© consolidĂ© — 60 findings V2.1, 28 corrigĂ©s, 13 rĂ©siduels documentĂ©s

scripts/README.md

Guide CLI complet — groupes de commandes, exemples, confort d'utilisation

tests/README.md

Guide d'exĂ©cution des tests — 4 niveaux, ~600 tests, commandes pour auditeurs

TEST_CATALOG.md

Catalogue des tests e2e — 15 catĂ©gories, 349 assertions, objectif de chaque section (pour auditeurs)


Related MCP server: agentkeychain

⚡ DĂ©marrage rapide

# 1. Cloner et configurer
cp .env.example .env
# Adapter les credentials S3 dans .env

# 2. Build et démarrer
docker compose build
docker compose up -d

# 3. Vérifier (depuis le conteneur)
docker compose exec mcp-vault python scripts/mcp_cli.py health

# 4. Tester (349 tests e2e)
docker compose exec mcp-vault python tests/test_e2e.py

Lifecycle automatique

Au démarrage, MCP Vault :

  1. Charge les tokens depuis S3

  2. Restaure les donnĂ©es OpenBao (volume Docker en prioritĂ© ; sinon S3 — un Ă©chec S3 ambigu, ex. rĂ©seau, refuse le dĂ©marrage plutĂŽt que d'initialiser un coffre vide par-dessus une sauvegarde distante valide)

  3. Démarre OpenBao, l'initialise (1Úre fois) et le déverrouille

  4. ClĂ©s unseal : chiffrĂ©es (AES-256-GCM) sur S3, jamais en clair sur disque — uniquement en mĂ©moire

  5. Active le sync S3 pĂ©riodique (60s, conditionnel : un PUT n'est effectuĂ© que si l'Ă©tat a changĂ© depuis le dernier upload rĂ©ussi — cf. s3_sync.py)

À l'arrĂȘt (docker compose stop) :

  1. Scelle OpenBao 🔒

  2. Upload final vers S3 đŸ“€

  3. ArrĂȘte le processus — clĂ©s effacĂ©es de la mĂ©moire


đŸ› ïž Outils MCP (39)

System (2)

Outil

Description

system_health

État de santĂ© (OpenBao + S3)

system_about

Informations service (version, outils, plateforme)

💡 Introspection : l'endpoint /admin/api/whoami et la commande CLI whoami permettent de vĂ©rifier l'identitĂ© et les permissions du token courant.

Vaults — coffres de secrets (5)

Outil

Perm

Description

vault_create(vault_id, description?)

write

Crée un vault (mount KV v2) + métadonnées (owner, date)

vault_list()

read

Liste les vaults accessibles (filtrés par token)

vault_info(vault_id)

read

Détails d'un vault (métadonnées, secrets_count, owner)

vault_update(vault_id, description)

write

Met Ă  jour la description d'un vault

vault_delete(vault_id, confirm)

admin

Supprime un vault et tous ses secrets ⚠

Secrets (6)

Outil

Perm

Description

secret_write(vault_id, path, data, type?)

write

Écrit un secret typĂ©

secret_read(vault_id, path, version?)

read

Lit un secret (derniÚre version ou spécifique)

secret_list(vault_id, path?)

read

Liste les clés d'un vault

secret_delete(vault_id, path)

write

Supprime un secret et toutes ses versions

secret_types()

read

Liste les 14 types de secrets

secret_generate_password(length?, ...)

read

GénÚre un mot de passe CSPRNG

SSH Certificate Authority (5)

Chaque vault possĂšde sa propre CA SSH isolĂ©e — les CA sont cryptographiquement diffĂ©rentes entre vaults. Un certificat signĂ© par la CA d'un vault ne fonctionne PAS sur les serveurs configurĂ©s pour un autre vault.

Outil

Perm

Description

ssh_ca_setup(vault_id, role, allowed_users?, ttl?)

write

Configure une CA SSH + rĂŽle dans un vault

ssh_sign_key(vault_id, role, public_key, ttl?)

read

Signe une clĂ© publique → certificat Ă©phĂ©mĂšre

ssh_ca_public_key(vault_id)

read

Clé publique CA (pour TrustedUserCAKeys sur les serveurs)

ssh_ca_list_roles(vault_id)

read

Liste les rÎles SSH CA configurés dans un vault

ssh_ca_role_info(vault_id, role)

read

Détails d'un rÎle (TTL, allowed_users, extensions)

AccÚs SSH JIT opérateur (2) (v0.9.0)

Outil

Perm

Description

ssh_operator_access_profiles()

read

Liste les profils fermés utilisables par le bearer nominatif courant

ssh_request_operator_access(profile_id, public_key, reason)

write

Émet un certificat court liĂ© Ă  une clĂ© publique prĂ©-enrĂŽlĂ©e

Le demandeur ne choisit jamais le coffre CA, le rĂŽle, le principal, la cible ou le TTL. L'identitĂ© de l'opĂ©rateur repose sur un bearer token nominatif scopĂ© par une policy dĂ©diĂ©e (mĂȘme mĂ©canisme que le reste du systĂšme), plus une clĂ© publique SSH standard (ssh-ed25519/ecdsa-sha2-nistp256) prĂ©-enrĂŽlĂ©e par empreinte. Ce parcours humain exceptionnel reste distinct du breaking-glass externe et du parcours agentique. Les profils peuvent ĂȘtre fournis par SSH_OPERATOR_PROFILES_JSON ou, pour un renderer .env strict, par son Ă©quivalent Base64 URL-safe SSH_OPERATOR_PROFILES_B64 ; les deux sources sont mutuellement exclusives.

Policies MCP — contrîle d'accùs granulaire (4)

Les policies permettent de restreindre finement les outils accessibles par token, avec support des wildcards (system_*, ssh_*...) et des rùgles par vault (prod-* → lecture seule).

Outil

Perm

Description

policy_create(policy_id, description?, allowed_tools?, denied_tools?, path_rules?)

admin

Crée une policy avec rÚgles d'accÚs

policy_list()

admin

Liste les policies avec compteurs

policy_get(policy_id)

admin

Détails complets (allowed/denied tools, path_rules)

policy_delete(policy_id, confirm)

admin

Supprime une policy ⚠

📋 6 policies prĂȘtes Ă  l'emploi documentĂ©es dans ARCHITECTURE.md §6.4.1 : readonly, operator-ssh-jit, developer, prod-reader-dev-writer, ci-cd-agent, security-auditor

🔒 Fail-close sur panne S3 (v0.8.3, #86) : aprĂšs expiration du cache (5 min), une panne ou une corruption S3 dĂ©tectĂ©e fait refuser explicitement les dĂ©cisions de policy, plutĂŽt que de continuer Ă  servir silencieusement d'anciennes rĂšgles.

Token Management (1)

Outil

Perm

Description

token_update(hash_prefix, policy_id?, permissions?, vaults?)

admin

Modifier un token existant (policy, permissions, vaults)

Purge des tokens rĂ©voquĂ©s (v0.7.0 — opĂ©ration admin, exposĂ©e en REST / CLI / SPA ; ce n'est PAS un outil MCP) : supprime dĂ©finitivement les tokens rĂ©voquĂ©s depuis plus de N jours (rĂ©tention, dĂ©faut 30). Fail-close (jamais un token actif ni un token expirĂ© non rĂ©voquĂ©), dry-run + confirmation, rollback si S3 indisponible, chaque purge auditĂ©e. Via POST /admin/api/tokens/purge, la commande CLI token purge-revoked, ou le bouton « đŸ§č Purger rĂ©voquĂ©s » de la console /admin.

🔒 Validation stricte (v0.8.3, #86) : permissions/allowed_resources sont dĂ©sormais validĂ©s strictement Ă  la crĂ©ation, la modification et le chargement — jamais permissifs par dĂ©faut sur une entrĂ©e malformĂ©e (corrige une Ă©lĂ©vation de privilĂšge possible via une valeur du mauvais type).

PKI interne — CA + ACME (8) (v0.5.0)

CA souveraine pour l'écosystÚme : les WAF Caddy s'enrÎlent via ACME exactement comme avec Let's Encrypt, mais sur une CA interne isolée du réseau public. Utilisable en lab (*.lesur.lan) et en prod air-gapped.

Outil

Perm

Description

pki_ca_setup(lab_mode, allowed_domains, leaf_ttl)

admin

Initialise CA racine + intermédiaire + rÎle ACME

pki_ca_public_key()

read

CA racine PEM, empreinte SHA-256, URL stable

pki_ca_list_roles()

read

Liste les rÎles d'émission

pki_ca_role_info(role_name)

read

Détails rÎle (domaines, TTL, flags TLS)

pki_list_certs(limit?, offset?)

admin

Inventaire paginé des certificats émis

pki_issue_cert(common_name, ttl?, alt_names?, ip_sans?)

admin

Émission manuelle d'un certificat (hors ACME) — clĂ© privĂ©e one-shot

pki_revoke_cert(serial_number)

admin

Révocation + mise à jour CRL

pki_ca_rotate_intermediate(keep_old_issuer?, overlap_ttl?)

admin

Rotation CA intermédiaire sans coupure

Endpoints publics (non-auth, standard ACME/PKI) : /acme/directory, /pki/ca/root.pem, /pki/ca/chain.pem, /pki/ca/crl.pem

PKI_BASE_URL (optionnel) : URL de base pour les CDPs et le cluster path OpenBao ACME. Vide = dĂ©duit de MCP_ALLOWED_HOSTS. Override test Docker : http://mcp-vault:8030. Doit ĂȘtre http(s)://.

JIT Wrap Broker + consommation mĂ©diĂ©e — C18 (5) (v0.4.13 / v0.6.x)

Contrat pour le CredentialBrokerService de mcp-mission : livraison de credentials single-use via response wrapping OpenBao (cubbyhole), registry write-ahead sur S3 pour la compensation des orphelins, et validation anti-confused-deputy (C18).

⚠ ttl_seconds borne la fenĂȘtre pour OBTENIR le secret, PAS la durĂ©e de vie du secret OBTENU (#156). L'enveloppe est Ă  usage unique et meurt Ă  son TTL (bornĂ© ici entre 60 s et 3600 s ; mcp-mission se plafonne Ă  300 s, ce qui est son choix et non notre plafond). MCP Vault n'impose aucune expiration au secret KV ni au credential qu'il contient, et sa validitĂ© rĂ©elle dĂ©pend de son Ă©metteur — pas de nous. ⚠ Et ni l'un ni l'autre de nos deux verbes ne suffit Ă  l'invalider : secret_write ajoute une version (la prĂ©cĂ©dente reste lisible par son numĂ©ro), et secret_delete retire les versions du coffre, pas le mot de passe de la machine ni la clĂ© chez son fournisseur. Seule une rĂ©vocation ou une rotation coordonnĂ©e chez l'Ă©metteur invalide effectivement un credential externe. ⇒ Si une mission meurt aprĂšs un dĂ©ballage, la durĂ©e d'exposition n'est bornĂ©e par personne, et le remĂšde n'est pas dans cet outil. Ce que nous ne gardons pas : aucune copie de la valeur dĂ©ballĂ©e. Elle est rendue Ă  l'appelant sans ĂȘtre stockĂ©e, le champ data est retirĂ© de la piste d'audit, et aucun journal ne la porte. L'entrĂ©e de registre conserve l'accessor, vault_id et secret_path — jamais la valeur.

Outil

Perm

Description

secret_wrap(vault_id, secret_path, mission_id, operation_id, ttl_seconds?, tenant_id?, expected_aud?)

wrap

CrĂ©e un wrap token single-use (write-ahead registry). ⚠ tenant_id OBLIGATOIRE si ENFORCE_MISSION_TOKEN_VALIDATION=true — le serveur ne peut pas le dĂ©duire, et une provision au binding incomplet serait refusĂ©e Ă  la consommation (#78). expected_aud absente (ou blanche) est complĂ©tĂ©e depuis la configuration ; une audience dĂ©signant une autre instance est refusĂ©e (binding_mismatch) car le wrap serait inconsommable ; configuration durcie incohĂ©rente (JWKS vide, audience non rĂ©soluble) → misconfigured

secret_revoke_wrap(lease_id)

wrap

RĂ©vocation idempotente, limitĂ©e au pĂ©rimĂštre de l'appelant ; registre non initialisĂ© → error/registry_unavailable (#120). ⚠ Un accessor introuvable dans un registre disponible rend ok/not_found : c'est un succĂšs d'appel, pas une rĂ©vocation — aucun appel OpenBao n'a Ă©tĂ© tentĂ© (voir la table de recrĂ©ation plus bas)

secret_wrap_lookup(operation_id, mission_id)

wrap

Retrouve & rĂ©voque les wraps du couple (compensation orphelins #74), limitĂ© au pĂ©rimĂštre de l'appelant. ⚠ mission_id REQUIS : un operation_id n'est pas unique entre missions, et sans lui la compensation d'un orphelin rĂ©voquait la provision vivante d'une AUTRE mission

secret_wrap_status(operation_id, mission_id)

wrap

Consulte l'Ă©tat d'un wrap sans le rĂ©voquer — lecture seule, instantanĂ© best-effort (#77), limitĂ© au pĂ©rimĂštre de l'appelant. ⚠ mission_id REQUIS : sans lui, l'Ă©tat des provisions d'une autre mission Ă©tait divulguĂ©

secret_consume(wrap_token, operation_id, mission_token)

admin

Valide JWT ES256/JWKS (contrat PEP complet depuis (#86) : exp/iat/iss/aud/mission_id/jti/scope/tenant_id + component_id), vĂ©rifie binding complet (mission_id, tenant_id, aud) — en mode durci les DEUX champs sont exigĂ©s (#78), unwrap OpenBao (C18). Contrat d'erreurs non rĂ©essayables : wrap_unusable, consume_outcome_unknown, empty_secret (#78)

(#115) Permission wrap (ou admin) : un jeton wrap non-admin doit porter allowed_resources non vide et une policy explicite (allowed_tools nommĂ©s, path_rule avec allowed_paths non vides — Ă©valuation stricte). Un jeton wrap-only (sans read/write/admin) est confinĂ© Ă  ces 4 outils (MCP) et refusĂ© sur tout /admin/api/*. ⚠ Downgrade ≀ 0.9.2 : rĂ©voquer/purger les jetons wrap d'abord (sinon tokens.json rejetĂ© en bloc, cf. CHANGELOG). Activer la validation C18 avec ENFORCE_MISSION_TOKEN_VALIDATION=true. Par dĂ©faut (false) : un jeton invalide est journalisĂ© puis non opposĂ© — zĂ©ro impact standalone sans mcp-mission. ⚠ RectifiĂ© en v0.16.3 (#154) : « continue » Ă©tait trompeur. Sans enforcement, un Ă©chec de validation laisse mission_id vide, donc la clĂ© composite ne correspond Ă  rien et la consommation est refusĂ©e au registre. Et surtout, ce qui exprime notre propre incapacitĂ© — service de clĂ©s injoignable, audience non rĂ©solue, validateur non initialisĂ© — est refusĂ© dans les deux postures, en backend_unavailable ou misconfigured : enforce arbitre si un jeton invalide est fatal, il n'arbitre pas si nous disons la vĂ©ritĂ© sur le motif du refus. tenant_id et expected_aud dans secret_wrap alimentent le binding C18 complet cĂŽtĂ© secret_consume (v0.6.8). ⚠ (#86) DĂšs que ENFORCE_MISSION_TOKEN_VALIDATION=true (seul, ou via le PEP /mcp ci-dessous), MISSION_JWKS_URL, MCP_INSTANCE_ID/MISSION_TOKEN_AUD et MISSION_STATUS_URL deviennent obligatoires (fail-fast au boot) — sans quoi une mission abortĂ©e conserverait l'accĂšs jusqu'Ă  expiration du mission_token. ⚠ (#86) Le validateur applique dĂ©sormais EXACTEMENT le mĂȘme contrat que le PEP /mcp ci-dessous (mĂȘmes claims requis, mĂȘme vĂ©rification component_id) — un JWT authentique mais destinĂ© Ă  une autre instance vault est rejetĂ© par les deux points d'application, pas seulement le premier.

Matrice d'effet externe — ce qu'un code d'erreur permet de DÉDUIRE (v0.14.0)

Tous les codes mĂ©tier arrivent en ENVELOPPE, isError = false. Un appelant qui ne regarde que isError les prend pour des succĂšs. Lire status, puis error_type — dans cet ordre, car certains refus n'ont pas encore de code (voir la note en fin de section).

isError = true ne signale que deux choses : une non-conformitĂ© au schĂ©ma (paramĂštre requis absent ou de mauvais type — le corps de l'outil n'a pas tournĂ©, donc aucune Ă©criture, aucun appel OpenBao, aucune rĂ©vocation), ou un dĂ©faut non prĂ©vu. Un paramĂštre conforme au schĂ©ma mais refusĂ© sĂ©mantiquement revient en enveloppe.

secret_wrap — le nom du code ne suffit pas : une ressource peut exister.

Code

Appel OpenBao

Ressource Ă  compenser

invalid_input, backend_unavailable, operation_pending

absent

non, pour cet appel

operation_failed (v0.13.0)

absent pour cet appel

⚠ possiblement, d'une tentative ANTÉRIEURE — sans accessor, donc non compensable

operation_revocable (v0.13.0)

absent pour cet appel

⚠ possiblement, d'une provision ANTÉRIEURE — mais elle porte un accessor, donc elle est compensable

operation_terminated (v0.14.0)

absent pour cet appel

non — provision antĂ©rieure tenue pour terminĂ©e, rien Ă  compenser

registry_inconsistent (v0.14.0)

absent pour cet appel

⚠ inconnu — entrĂ©e illisible, rien ne peut en ĂȘtre dĂ©duit

registry_unavailable

absent

non

wrap_created_revoked

wrap créé

non — rĂ©vocation confirmĂ©e

wrap_created_orphaned

wrap créé

⚠ possiblement — active jusqu'au TTL, non compensable

not_found, backend_error

émis

⚠ inconnu — ne pas rejouer la mĂȘme clĂ©

⚠ backend_error peut suivre un appel qu'OpenBao a reçu (dĂ©lai dĂ©passĂ©, coupure) ; not_found est dĂ©duit d'un motif cherchĂ© dans le texte de l'exception, donc non prouvĂ©. Dans les deux cas l'intention passe failed sans accessor.

La clĂ© est alors BLOQUÉE DÉFINITIVEMENT — un rejeu rend operation_failed, sans aucun appel OpenBao. (Jusqu'en v0.12.1 elle redevenait rejouable, et le rejeu crĂ©ait un second wrap pendant que le premier pouvait vivre.) Si la persistance du marquage Ă©choue, l'entrĂ©e revient pending et la clĂ© est bloquĂ©e sous operation_pending : le blocage est le mĂȘme, seul le code diffĂšre. Reprise = nouvel operation_id, dans tous les cas.

⚠ v0.14.0 — UNE CLÉ EST À USAGE UNIQUE, SANS CONDITION. La moindre entrĂ©e pour le couple (operation_id, mission_id) bloque secret_wrap, quel que soit son Ă©tat, entrĂ©e corrompue comprise : une entrĂ©e illisible est une raison de refuser, jamais d'autoriser. Plus rien ne libĂšre une clĂ©.

En v0.13.0, revoked, consumed et unusable la rouvraient — pour prĂ©server la reprise « rĂ©voquer puis recrĂ©er » du broker. Celle-ci a Ă©tĂ© retirĂ©e de sa conception, et le recensement des porteurs de jeton wrap en production n'en a trouvĂ© qu'un seul. Cette libĂ©ration Ă©tait aussi le dernier chemin par lequel un jeton Ă©tranger prĂ©sentĂ© Ă  secret_consume pouvait marquer une entrĂ©e unusable — et libĂ©rer une clĂ© dont le wrap rĂ©el vivait encore.

⚠ La garde ne porte QUE sur la crĂ©ation. secret_revoke_wrap, secret_wrap_status et secret_wrap_lookup restent ouverts sur une clĂ© engagĂ©e : sans cela l'appelant serait enfermĂ© avec des ressources qu'il ne pourrait plus ni voir ni couper. Un test compte les appelants de la garde et Ă©choue s'il en apparaĂźt un second.

⚠ Deux contournements ne sont PAS fermĂ©s : la perte du fichier de registre (registre vide = toutes les clĂ©s redeviennent vierges) et la course entre deux instances, faute de CAS/ETag (#51). Ce ne sont pas des libĂ©rations mĂ©tier, mais l'unicitĂ© n'est pas absolue pour autant.

⚠ Ce premier contournement Ă©tait plus large que cette phrase ne le disait, jusqu'Ă  v0.15.0 incluse (#149, corrigĂ© en v0.16.0). Il ne fallait pas perdre le fichier : il suffisait d'une erreur de stockage mal lue. Le registre concluait « objet absent » dĂšs que le texte de l'erreur contenait « 404 » — un identifiant de requĂȘte, un port, un 404 Ă©mis par un intermĂ©diaire — puis repartait vide en se dĂ©clarant fiable, si bien que le refus fail-close ne se dĂ©clenchait pas et que la sonde ne signalait rien. Une seconde porte menait au mĂȘme effet sans aucune erreur : une lecture qui rĂ©ussissait en renvoyant un JSON valide mais sans la clĂ© wraps (un {}, un corps tronquĂ©) Ă©tait prise pour un registre vide. DĂ©sormais, seule une absence authentifiĂ©e par le champ d'erreur structurĂ© (NoSuchKey) vide le registre, et le document est validĂ© avant toute mutation d'Ă©tat ; tout le reste refuse la crĂ©ation. C'est la rĂšgle que les trois magasins d'autorisation appliquent depuis #69.

Une intention failed (ou pending) sans accessor rend found_unattached sur secret_wrap_lookup : aucune rĂ©vocation n'est possible, seul le TTL borne la ressource Ă©ventuelle. ⚠ Ce verdict ne dit pas qu'une ressource existe — il dit que nous ne pouvons pas l'exclure. (v0.12.1 : ce cas rĂ©pondait already_revoked, affirmant une rĂ©vocation inexistante.)

Les quatre autres outils, sur les codes d'indisponibilité :

Outil

Effet externe

Conduite

secret_revoke_wrap — ok + registry_persisted: false (v0.14.1)

révocation FAITE, mais non inscrite au registre

rejouer la compensation plus tard — voir l'encadrĂ© ci-dessous

secret_revoke_wrap — registry_unavailable, backend_unavailable

absent, aucune révocation tentée

rejeu sûr et nécessaire

secret_revoke_wrap — backend_error

appel émis, effet inconnu

rejeu sûr (révocation idempotente)

secret_wrap_lookup — backend_unavailable

absent : les deux gardes fail-close précÚdent toute révocation

attente puis rejeu, sûr

secret_wrap_lookup — partial_revocation

inconnu sur les entrées non comptées (count_revoked dit ce qui a abouti)

rejeu sûr, mais il ne résoudra pas les entrées figées

secret_wrap_status — backend_unavailable

absent : registre absent ou dernier rafraßchissement S3 échoué

rejeu toujours sûr

⚠ UN EFFET CONFIRMÉ PEUT NE PAS SURVIVRE À UN REDÉMARRAGE (limite structurelle, #140 — arbitrĂ©e, cf. #123). Quand une rĂ©vocation rĂ©ussit cĂŽtĂ© coffre mais que l'Ă©criture du registre Ă©choue, le fait n'est inscrit nulle part de durable : au redĂ©marrage, l'entrĂ©e peut rĂ©apparaĂźtre non rĂ©voquĂ©e, dans un Ă©tat que nous venons d'annoncer rĂ©voquĂ© Ă  l'appelant.

« Peut » et non « va » : une exception S3 ne prouve pas que l'Ă©criture n'a pas abouti — un dĂ©lai d'attente peut suivre une Ă©criture dĂ©jĂ  acceptĂ©e — et le registre est en dernier-Ă©crivain-gagne sans verrou, une autre instance a pu Ă©crire entre-temps. L'appelant ne peut donc pas dĂ©duire l'Ă©tat durable de l'Ă©chec ; il ne peut que le tenir pour indĂ©terminĂ©.

Ce n'est pas rĂ©parable avec un seul support durable — inscrire le fait exigerait un support disponible au moment prĂ©cis oĂč le support habituel ne l'est pas. Le tenir supposerait un second support : un journal local, rejouĂ© en rĂ©conciliation avant de resservir le registre. Le volume local existe dĂ©jĂ  dans le dĂ©ploiement ; le mĂ©canisme, non — et il serait local Ă  l'instance, invisible d'une autre et perdu avec l'hĂŽte.

⚠ DÉCISION (#123) : ce second support ne sera pas construit. La limite est assumĂ©e et publiĂ©e, elle n'est pas en attente d'un correctif. Ne dimensionnez pas votre contrat en supposant qu'elle disparaĂźtra.

Ce que nous faisons Ă  la place, depuis v0.14.1 : nous ne rendons pas le fait durable, mais nous le disons — la rĂ©ponse porte alors registry_persisted: false et un warning. Le champ est absent sur le chemin nominal.

⚠ Le signal a la MÊME forme sur les DEUX verbes de rĂ©vocation — secret_revoke_wrap et secret_wrap_lookup : registry_persisted: false, count_not_persisted (le nombre d'entrĂ©es effectivement muĂ©es et non inscrites) et un warning. Sur la compensation il est portĂ© aussi par les sorties partial_revocation et consume_terminal — l'appelant y gĂšre dĂ©jĂ  un incident, c'est le pire moment pour lui taire une rĂ©vocation non consignĂ©e.

⇒ Conduite : traiter un retour portant registry_persisted: false comme une rĂ©vocation rĂ©elle mais non consignĂ©e — ne pas clore son propre registre dessus, et repasser la compensation plus tard. La mĂȘme limite touche la rĂ©vocation d'urgence de secret_wrap, oĂč l'entrĂ©e reste pending et oĂč secret_wrap_lookup rĂ©pondra found_unattached pour une ressource que nous tenons pour morte — le serveur traite un retour OpenBao sans exception comme une rĂ©vocation confirmĂ©e, il ne relit rien pour l'attester.

secret_wrap_status ne mute ni OpenBao ni le registre (cache et journal d'audit, eux, bougent). Il ne distingue pas le transitoire du durable : instantanĂ© best-effort. ⚠ CorrigĂ© en v0.15.0 — une panne S3 survenant pendant la fenĂȘtre de cache n'Ă©tait alors pas dĂ©tectĂ©e du tout ; le rafraĂźchisseur de fond la constate dĂ©sormais Ă  sa prochaine tentative (mi-TTL, puis toutes les 10 s), et status bascule en backend_unavailable. La dĂ©tection n'est donc pas instantanĂ©e, mais elle a lieu.

Ce que le registre PROUVE — et ce qu'il ne prouve pas

🔮 secret_wrap_status rend ce que le REGISTRE CROIT, pas un constat OpenBao. Aucun client OpenBao n'est appelĂ©. Chaque rĂ©ponse est une croyance datĂ©e :

  • active ne prouve pas qu'un wrap vit — il a pu expirer, ĂȘtre consommĂ© ou rĂ©voquĂ© chez OpenBao sans qu'aucune transition de registre ne soit Ă©crite.

  • consuming n'atteste pas qu'un dĂ©ballage soit parti : l'Ă©tat est persistĂ© avant l'appel d'unwrap.

  • consumed, revoked, unusable ne parlent que d'UNE entrĂ©e, jamais du couple : plusieurs entrĂ©es peuvent coexister pour un mĂȘme (operation_id, mission_id) — historiquement, parce que le verrou de clĂ© ne regardait que pending jusqu'en v0.12.1. ⚠ Un Ă©tat terminal sur l'une ne dit rien des autres : une entrĂ©e unusable peut coexister avec une active.

  • revoked est aussi posĂ© quand OpenBao rĂ©pond 400/404 : c'est de l'idempotence, pas une preuve du sort du jeton.

⚠ ambiguous de secret_wrap_status et ambiguous de secret_wrap_lookup ne sont pas le mĂȘme mot. Le status est pur ; le lookup est destructif — face Ă  plusieurs entrĂ©es il rĂ©voque les actives. Ne jamais appeler secret_wrap_lookup pour comprendre un ambiguous : il dĂ©truirait ce qu'on cherche Ă  diagnostiquer. Aucun verbe ne dĂ©taille un ambiguous (ni cardinalitĂ©, ni Ă©tat individuel) : c'est un refus terminal, sans investigation.

Quand peut-on recréer aprÚs secret_revoke_wrap ?

Réponse

Recréer ?

ok/revoked, ok/already_revoked

✅ rĂ©vocation Ă©mise et acceptĂ©e, ou jeton dĂ©clarĂ© invalide/absent par OpenBao

ok/not_found

❌ aucun appel OpenBao tentĂ© — accessor inconnu du registre ou hors pĂ©rimĂštre

backend_error, backend_unavailable, registry_unavailable

❌ rien d'attestĂ©

secret_consume — « aucun appel Ă©mis » ne veut pas dire « jeton intact » : sur ces chemins nous ne regardons pas le jeton, qui peut avoir expirĂ© ou avoir Ă©tĂ© consommĂ© en parallĂšle.

Code

Cette invocation a présenté le wrap ?

État du jeton

invalid_input, misconfigured, jwt_invalid, mission_inactive

non

non constaté

registry_unavailable, backend_unavailable

non

non constaté

permission_denied (v0.12.1 — remplace une enveloppe sans code)

non

non constaté

binding_mismatch, not_found, already_consuming

non

non constaté

binding_incomplete

non

inconsommable en mode durci → reprovisionner

already_consumed

non

dépensé ou révoqué

wrap_unusable

selon le chemin

mort

consume_outcome_unknown

possible

à considérer comme perdu

empty_secret

oui

brûlé

⚠ LĂ  oĂč l'Ă©tat est « non constatĂ© », dĂ©truire l'accĂšs de son propre fait est une perte sĂšche. Mais une nouvelle tentative n'est pas une sonde : si la cause du refus est levĂ©e, elle peut consommer le wrap. ⚠ backend_error n'est pas atteignable sur secret_consume (toute exception d'unwrap tombe en wrap_unusable ou consume_outcome_unknown), et wrap_expired n'existe pas — il a figurĂ© Ă  tort dans une version antĂ©rieure de ce contrat. ⚠ already_consumed regroupe consumed et revoked ; secret_wrap_status ne dĂ©sambiguĂŻse que s'il n'existe qu'une entrĂ©e, sinon il rend ambiguous.

Refus sans error_type — la taxonomie fermĂ©e (unauthenticated, permission_denied, access_denied, invalid_input, invalid_token, policy_store_unavailable) couvre les gardes d'autorisation partagĂ©es. Des refus de validation propres Ă  des outils hors pĂ©rimĂštre wrapping (secret_list, surfaces /admin/api) rendent encore une enveloppe sans code. Tester status avant error_type.

PEP mission JWT — porte /mcp (v0.8.0, #47 + #69)

Second point d'application (PEP) du mission_token JWT ES256 de mcp-mission, à l'entrée /mcp (le premier étant secret_consume/C18, à la consommation). Piloté par MCP_AUTH_MODE :

Mode

Comportement /mcp

bearer (défaut)

Bearer opaque obligatoire : une requĂȘte sans jeton, ou avec un jeton invalide, reçoit 401 au middleware (#116).

bearer-anonymous

⚠ Mode d'exception — ancien comportement de bearer : un appelant sans identitĂ© atteint les outils. Repli temporaire uniquement ; CRITICAL au dĂ©marrage.

jwt

mission_token JWT ES256 obligatoire (bearer opaque refusé). Requiert aussi ENFORCE_MISSION_TOKEN_VALIDATION=true et MISSION_STATUS_URL (fail-fast, #86).

dual-stack

JWT valide OU bearer opaque valide (migration). MĂȘmes exigences que jwt ci-dessus.

En jwt/dual-stack, le middleware refuse activement : 401 (token absent/opaque/JWT invalide), 403 (aud/component_id ≠ instance, mission inactive), 503 (JWKS indisponible — fail-close). Un JWT invalide ne retombe jamais sur le bearer. Le token est vĂ©rifiĂ© contre le contrat rĂ©el mcp-mission (aud contient MCP_INSTANCE_ID, component_id[MCP_COMPONENT_KIND] == MCP_INSTANCE_ID, iss, exp leeway 0, iat anti-skew). La bootstrap key admin reste acceptĂ©e (break-glass).

Une identité mission valide est authentifiée et liée à l'instance, puis mcp-vault (PDP local) lui résout un périmÚtre vault provisionné localement. Sans octroi, aucun accÚs (deny-by-default) ; les outils d'infra (system_*, inventaire PKI) et l'admin-plane (vault_create/delete, ssh_*) lui sont refusés. L'endpoint admin POST /admin/api/auth/jwks/reload force le rechargement du JWKS (révocation urgente de kid).

Octroi de pĂ©rimĂštre vault — MissionBindingStore (#69)

Le mission_token ne porte aucune autorisation vault : l'octroi est provisionné localement, indexé par tenant_id (claim stable), et stocké un fichier par instance sur S3.

  • Un octroi (« binding ») = {tenant_id, allowed_resources:[coffres
], permissions, policy_id?, enabled, expires_at?}.

  • Permissions strictes : read ou read,write (jamais write seul, jamais admin). Data-plane uniquement : lire/Ă©crire des secrets dans les coffres autorisĂ©s — jamais crĂ©er/dĂ©truire un coffre ni gĂ©rer une CA SSH.

  • Fail-close : binding absent/dĂ©sactivĂ©/expirĂ© → aucun accĂšs. Registre indisponible/corrompu → 503 (refus observable, jamais un deny silencieux ni un Ă©crasement destructeur).

  • Administration (bearer/bootstrap admin uniquement — jamais via mission JWT) :

# Octroyer un périmÚtre lecture à un client mission
mcp-cli mission-binding create acme-corp --vaults prod-app --permissions read
# Lecture + écriture sur plusieurs coffres
mcp-cli mission-binding create acme-corp --vaults prod-app,staging --permissions read,write
mcp-cli mission-binding list
mcp-cli mission-binding get acme-corp
mcp-cli mission-binding delete acme-corp
mcp-cli mission-binding purge --older-than 30 --dry-run   # bindings expirés

GranularitĂ© tenant : toutes les missions actives d'un mĂȘme client (tenant_id) partagent le pĂ©rimĂštre octroyĂ©. Le grain fin par mission existe dĂ©jĂ  Ă  la consommation (secret_consume/C18).

Audit (1)

Outil

Perm

Description

audit_log(limit?, client?, vault_id?, tool?, category?, status?, since?)

admin

Journal d'audit filtrable (ring buffer 5000 entrées + JSONL persistant)

TraçabilitĂ© du cycle de vie des accĂšs (v0.7.0 — conformitĂ© SecNumCloud/HDS) : toute mutation du plan de contrĂŽle — crĂ©ation / mise Ă  jour / rĂ©vocation / purge de token, crĂ©ation / suppression de policy — est journalisĂ©e, via l'API REST admin comme via les outils MCP. Les Ă©checs de persistance des opĂ©rations de retrait (rĂ©vocation, purge, suppression de policy — celles qui laissent un accĂšs actif si elles Ă©chouent) sont Ă©galement tracĂ©s. La trace vit dans audit-mcp.jsonl, physiquement indĂ©pendant de _system/tokens.json → elle survit Ă  une purge. Le token brut n'apparaĂźt jamais dans le dĂ©tail d'audit.

# 1. Setup initial (ONE-TIME) — crĂ©er vault + rĂŽles SSH
vault_create("llmaas-infra", description="SSH CA LLMaaS")
ssh_ca_setup("llmaas-infra", "adminct", allowed_users="adminct", ttl="1h")
ssh_ca_setup("llmaas-infra", "agentic", allowed_users="agentic,iaagentic", ttl="30m")

# 2. Déployer la CA sur les serveurs (ONE-TIME)
result = ssh_ca_public_key("llmaas-infra")
# → Mettre result["public_key"] dans /etc/ssh/trusted-user-ca-keys.pem

# 3. Usage quotidien — signer une clĂ© publique
cert = ssh_sign_key("llmaas-infra", "adminct", public_key="ssh-ed25519 AAAA...", ttl="1h")
# → cert["signed_key"] = certificat signĂ©, valide 1h
# → OpenSSH l'utilise automatiquement si placĂ© Ă  cĂŽtĂ© de la clĂ© privĂ©e

🔑 Types de secrets (style 1Password)

Type

IcĂŽne

Champs requis

Usage

login

🔑

username, password

Identifiants web/app

password

🔒

password

Mot de passe simple

secure_note

📝

content

Notes sécurisées

api_key

🔌

key

Clés API

ssh_key

đŸ—ïž

private_key

Paires de clés SSH

database

đŸ—„ïž

host, username, password

Connexions BDD

server

đŸ–„ïž

host, username

AccĂšs serveur

certificate

📜

certificate, private_key

Certificats TLS/SSL

env_file

📄

content

Fichiers .env

credit_card

💳

number, expiry, cvv

Cartes bancaires

identity

đŸ‘€

name

Identités

wifi

đŸ“¶

ssid, password

Réseaux Wi-Fi

crypto_wallet

₿

(tout optionnel)

Wallets crypto

custom

⚙

(champs libres)

Tout le reste

Chaque secret supporte : tags, favorite, versioning KV v2 automatique.


🔒 Authentification

⚠ Seul le header Authorization: Bearer <token> est acceptĂ©. L'authentification par query string (?token=) a Ă©tĂ© supprimĂ©e pour des raisons de sĂ©curitĂ© (v0.3.1).

Authorization: Bearer <token>

Permission

Lecture

Écriture

Admin

read

✅

❌

❌

write

✅

✅

❌

admin

✅

✅

✅

3 couches d'isolation :

  1. Vault-level : allowed_resources=[] → owner-based (seuls les vaults créés par le token), ou liste explicite

  2. Tool-level : policies avec allowed_tools/denied_tools (wildcards fnmatch)

  3. Path-level : allowed_paths dans les path_rules → contrîle par secret individuel


đŸ–„ïž CLI

MCP Vault inclut un CLI complet avec Click + Rich :

# Commandes scriptables
python scripts/mcp_cli.py health
python scripts/mcp_cli.py about
python scripts/mcp_cli.py whoami                       # Identité du token courant
python scripts/mcp_cli.py vault list
python scripts/mcp_cli.py vault create serveurs-prod -d "Clés SSH prod"
python scripts/mcp_cli.py secret write serveurs-prod web/github -d '{"username":"me","password":"s3cr3t"}' -t login
python scripts/mcp_cli.py secret read serveurs-prod web/github
python scripts/mcp_cli.py secret password -l 32
python scripts/mcp_cli.py token create agent-sre --vaults prod --policy readonly
python scripts/mcp_cli.py token list
python scripts/mcp_cli.py token purge-revoked --older-than 30 --dry-run    # aperçu (rien supprimé)
python scripts/mcp_cli.py token purge-revoked --older-than 30 --yes        # purge effective
python scripts/mcp_cli.py policy create no-ssh -d "Pas de SSH" --denied "ssh_*"
python scripts/mcp_cli.py policy create team-x --allowed "secret_*" --path-rules '[{"vault_pattern":"shared-*","allowed_paths":["shared/*"]}]'
python scripts/mcp_cli.py audit --status denied --limit 10

# AccÚs SSH JIT opérateur
python scripts/mcp_cli.py ssh profiles
python scripts/mcp_cli.py ssh request bastion-prod \
  --key ~/.ssh/id_ed25519.pub --reason "INC-230 maintenance bastion"

# PKI interne (v0.5.0)
python scripts/mcp_cli.py pki setup --lab --domains '*.lesur.lan,lesur.lan'
python scripts/mcp_cli.py pki ca-key
python scripts/mcp_cli.py pki certs
python scripts/mcp_cli.py pki revoke 12:34:ab:cd:ef:12:34:56

L'aide --help de chaque commande explique le modÚle de sécurité à 3 couches et guide l'utilisateur.

Voir scripts/README.md pour la documentation complĂšte du CLI.


⚙ Variables d'environnement

Copier .env.example → .env et adapter. .env.example est le contrat de configuration de rĂ©fĂ©rence : il dĂ©clare exactement les 45 variables consommĂ©es par Settings (src/mcp_vault/config.py), ni plus ni moins, et il est vĂ©rifiĂ© par tests/test_env_example_contract.py.

⚠ extra="forbid" : une clĂ© absente de ce contrat, portant une valeur non vide dans votre .env, fait Ă©chouer le dĂ©marrage. Ne composez jamais un .env depuis une autre source que .env.example.

Marqueur REQUIRED : une variable dont la valeur d'exemple est REQUIRED n'a aucun dĂ©faut utilisable. Le marqueur Ă©choue volontairement la validation — le service refuse de dĂ©marrer tant qu'un dĂ©ploiement ne l'a pas substituĂ© par une vraie valeur.

Groupe

Variables

Obligatoire

Serveur

MCP_SERVER_NAME, MCP_SERVER_HOST, MCP_SERVER_PORT, MCP_SERVER_DEBUG, MCP_ALLOWED_HOSTS, MCP_ALLOWED_ORIGINS

Non — tous ont un dĂ©faut. Mais derriĂšre un reverse-proxy, MCP_ALLOWED_HOSTS doit lister les FQDN publics, sinon le SDK rejette le header Host en HTTP 421 (issue #3)

WAF

WAF_PORT

Non — dĂ©faut 8085

Auth

ADMIN_BOOTSTRAP_KEY

Oui — seule variable sans dĂ©faut utilisable. Chiffre les clĂ©s unseal et sert de credential admin de secours. Minimum 32 caractĂšres, ≄ 3 classes de caractĂšres ; dĂ©marrage refusĂ© sinon

SSH JIT opérateur (v0.9.0)

SSH_OPERATOR_PROFILES_JSON ou SSH_OPERATOR_PROFILES_B64, plus les 7 bornes SSH_OPERATOR_JIT_*

Non — vide = dĂ©sactivĂ© ; aucun secret dans le profil

S3

S3_ENDPOINT_URL, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_BUCKET_NAME, S3_REGION_NAME

Oui pour un dĂ©ploiement fonctionnel. Le processus dĂ©marre sans S3, mais en mode dĂ©gradĂ© : les stores restent en mĂ©moire (tokens perdus Ă  chaque redĂ©marrage) et, sur un volume vierge, la tentative de restauration Ă©choue de façon ambiguĂ«, ce qui empĂȘche OpenBao de dĂ©marrer (fail-close volontaire, cf. lifecycle.py)

Storage sync

VAULT_S3_PREFIX, VAULT_S3_SYNC_INTERVAL

Non

OpenBao

OPENBAO_ADDR, OPENBAO_SHARES, OPENBAO_THRESHOLD, OPENBAO_DATA_DIR, OPENBAO_CONFIG_DIR

Non

PKI (v0.5.x)

PKI_BASE_URL

Non — vide = dĂ©duit du premier FQDN ; override de l'URL ACME en test Docker

Mission JWT (v0.6.0)

ENFORCE_MISSION_TOKEN_VALIDATION, MISSION_JWKS_URL, MISSION_TOKEN_AUD, MISSION_JWKS_CACHE_TTL, MISSION_JWKS_MAX_REFRESH_PER_MIN, MISSION_TOKEN_LEEWAY_SECONDS, MISSION_STATUS_URL, MISSION_STATUS_CACHE_TTL

Non — standalone sans mcp-mission

PEP mission JWT (v0.8.0)

MCP_AUTH_MODE (bearer/bearer-anonymous/jwt/dual-stack), MCP_INSTANCE_ID, MCP_COMPONENT_KIND

⚠ Oui depuis #116 — le dĂ©faut bearer exige dĂ©sormais un jeton valide (401 sinon). Repli : bearer-anonymous (mode d'exception). jwt/dual-stack exigent MISSION_JWKS_URL, MCP_INSTANCE_ID, MISSION_STATUS_URL et ENFORCE_MISSION_TOKEN_VALIDATION Ă  true (fail-fast au boot, #86)

Tokens sensibles du CLI — ce ne sont PAS des variables du serveur. VAULT_WRAP_TOKEN et VAULT_MISSION_TOKEN sont lus uniquement par le CLI (scripts/cli/) et ne figurent pas dans Settings : les placer dans .env ferait Ă©chouer le dĂ©marrage du serveur. Ils changent Ă  chaque opĂ©ration (usage unique, TTL court) — les passer inline devant la commande, jamais dans un fichier, sinon ils fuitent dans l'historique de votre shell systĂšme :

VAULT_WRAP_TOKEN=hvs.CAES... mcp-vault secret consume op-123

Voir .env.example pour la documentation détaillée de chaque variable.


đŸ—ïž Architecture

📐 Documentation complĂšte : le dossier DESIGN/mcp-vault/ contient la spĂ©cification dĂ©taillĂ©e (ARCHITECTURE.md — vision, sĂ©curitĂ©, SSH CA, policies, roadmap HSM) et la documentation technique (TECHNICAL.md — modules, Docker, tests, dĂ©pendances).

Internet → WAF (Caddy + Coraza :8085) → MCP Vault (Python :8030) → OpenBao (:8200 localhost)
                  ↕ OWASP CRS v4                  ↕
              Protection L7                 S3 Dell ECS (persistance)

WAF — Caddy + Coraza (OWASP CRS v4)

Le WAF protĂšge l'API contre les attaques L7 (injections SQL, XSS, LFI, RCE, SSRF) :

  • Caddy v2.11.2 compilĂ© avec coraza-caddy v2.5.0 (coraza v3.7.0) via xcaddy — montĂ©e depuis v2.2.0/coraza 3.3.3 en v0.9.2, requise par SecRequestBodyJsonDepthLimit (cf. #107)

  • 23 fichiers de rĂšgles OWASP CoreRuleSet v4.7.0 chargĂ©s (dĂ©compte vĂ©rifiable : grep -c '^Include /opt/crs/rules/' waf/coraza.conf)

  • Mode Blocking sur TOUS les endpoints (health, /mcp, /admin/api)

  • Corps JSON-RPC rĂ©ellement parsĂ© sur POST /mcp (ctl:requestBodyProcessor=JSON, v0.9.2). Sans ce rĂ©glage, Coraza traitait le corps entier comme une variable unique : toute chaĂźne d'une liste de phrases CRS prĂ©sente n'importe oĂč — y compris dans une valeur de secret — dĂ©clenchait un blocage, et aucun ciblage fin d'exclusion n'Ă©tait possible

  • Exclusions de RÈGLES (ctl:ruleRemoveById) dĂ©clarĂ©es avant l'Include CRS — pattern « exclusions before CRS », requis pour neutraliser les rĂšgles Ă©valuĂ©es en phase:1. Faux positifs couverts : Unicode français 920540, noms PowerShell 932120, distribution CA/CRL .pem 920440, Content-Type JOSE ACME 920420

  • Exclusions de CIBLES (SecRuleUpdateTargetById) dĂ©clarĂ©es aprĂšs l'Include CRS — sĂ©mantique inverse : cette directive modifie la dĂ©finition de la rĂšgle, qui doit donc ĂȘtre dĂ©jĂ  chargĂ©e. Couvre le faux positif 930120 sur .env (v0.9.2) : les noms d'arguments sous l'enveloppe MCP params._meta et les valeurs de la charge opaque de secret_write (seul outil Ă  en recevoir une — transmise Ă  OpenBao, qui la chiffre au repos). Le reste demeure inspectĂ© — chemin, nom de coffre, Ă©tiquettes, nom d'outil, et toutes les rĂšgles de path traversal

  • Profondeur JSON bornĂ©e Ă  32 sur POST /mcp (SecRequestBodyJsonDepthLimit, v0.9.2). Sans elle, un corps de 15 Ko d'imbrication — JSON valide, 2 octets par niveau — faisait tomber le WAF en OOM. La limite seule crĂ©ait toutefois une Ă©vasion silencieuse (au-delĂ  du seuil, plus aucune inspection) : la rĂšgle 10011 refuse donc explicitement (400) tout corps dĂ©clarĂ© non analysable par REQBODY_ERROR. ⚠ Limite de compatibilitĂ© : un secret dont data dĂ©passe ~28 niveaux d'imbrication interne est refusĂ© — les types de secrets du produit sont des dictionnaires plats, aucun usage connu n'y touche

  • Gardes anti-Ă©vasion 10009/10010 (v0.9.2). Ces exclusions sont des directives globales dont le sĂ©lecteur est un nom d'argument, donc une chaĂźne choisie par le client : ARGS/ARGS_NAMES agrĂšgent aussi query string et formulaire. Avant ces gardes, GET /health?json.params.arguments.data.x=.env passait (200) lĂ  oĂč GET /health?legit=.env Ă©tait refusĂ© (403) — soit une neutralisation globale de 930120 par simple choix du nom de paramĂštre. Les gardes refusent dĂ©sormais tout nom prĂ©fixĂ© json. hors du corps JSON MCP analysĂ©, discriminĂ© par un drapeau serveur non forgeable. La propriĂ©tĂ© est imposĂ©e, pas supposĂ©e

  • Headers de sĂ©curitĂ© : CSP, X-Frame-Options DENY, X-XSS-Protection, nosniff

  • MĂ©thodes autorisĂ©es adaptĂ©es au protocole MCP : GET, POST, DELETE, PUT, PATCH

Stack ASGI (6 couches)

PkiMiddleware → AdminMiddleware → HealthCheckMiddleware → AuthMiddleware → LoggingMiddleware → MCPServer

PkiMiddleware (v0.5.0) est la couche la plus externe — intercepte /acme/* et /pki/ca/*.pem avant l'auth (endpoints publics par design PKI/ACME).

Depuis la v0.20.0, le serveur utilise mcp==2.1.1 et accepte Ă  la fois le protocole moderne 2026-07-28 et les clients legacy jusqu'Ă  2025-11-25. Le transport HTTP est stateless dans les deux Ăšres : il n'exige plus d'affinitĂ© de session. MCP Vault reste nĂ©anmoins mono-instance : OpenBao embarquĂ©, la synchronisation S3 last-write-wins et les caches d'autorisation ne permettent pas encore un dĂ©ploiement multi-rĂ©plica sĂ»r. Le SDK annonce automatiquement la capability subscriptions/listen, mais MCP Vault ne publie ni ressource ni Ă©vĂ©nement et ne configure aucun store de reprise ; cette capability est donc inerte et doit ĂȘtre rĂ©auditĂ©e Ă  chaque montĂ©e de version du SDK.

AprÚs une fermeture de connexion sans réponse terminale, un client ne doit pas réessayer automatiquement une mutation : son effet peut déjà avoir eu lieu. Cela vaut notamment pour secret_wrap_lookup, qui révoque des wraps malgré son nom de consultation.

Le point de décision lit la mémoire, jamais le réseau (v0.15.0, #123)

Un appel S3 bloquant ne gelait pas une requĂȘte : il gelait le service entier, sonde de santĂ© comprise — 488 s mesurĂ©es en production (#110). La cause est toujours la mĂȘme : boto3 est synchrone, et un appel synchrone dans la boucle asyncio monopolise l'unique thread qui sert toutes les requĂȘtes.

Le lot 1 (v0.10.1) a bornĂ© la durĂ©e de ces appels, le lot 2 (#122) en a sorti cinq de la boucle. Ce lot ferme les derniers, et ce sont les plus frĂ©quents : les magasins d'autorisation, interrogĂ©s Ă  chaque requĂȘte authentifiĂ©e.

Plus aucun appel S3 ne part d'un point de dĂ©cision. Les gardes (check_policy, check_path_policy, check_wrap_*, get_by_hash, resolve) lisent un instantanĂ© publiĂ© en mĂ©moire. Des tĂąches de fond — une par magasin — le renouvellent hors boucle, Ă  la moitiĂ© du TTL, et toutes les 10 s aprĂšs une panne.

✅ Le dernier appel rĂ©seau synchrone du chemin d'authentification est fermĂ© (v0.16.1, #148). Le rafraĂźchissement du cache JWKS (mission_jwt.py, httpx.get synchrone) Ă©tait dĂ©clenchĂ© depuis la validation d'un jeton mission, appelĂ©e sans await dans une coroutine. MĂȘme classe de dĂ©faut que #110, sur un chemin aussi chaud. Il Ă©tait dĂ©clarĂ© ici comme rĂ©sidu depuis v0.15.0 ; il ne l'est plus.

Mesuré en production le 17/08 avant correction : 117,2 ms de gel du service entier par rafraßchissement, au plus une fois par TTL de 60 s. Trois chemins y menaient, tous des coroutines, et les trois sont déportés hors boucle par run_blocking :

Site

Ce qui l'active

PEP transport (AuthMiddleware)

MCP_AUTH_MODE ∈ {jwt, dual-stack}

secret_consume

⚠ la simple prĂ©sence de MISSION_JWKS_URL — c'est le chemin du dĂ©ballage de chaque enveloppe de credentials

POST /admin/api/auth/jwks/reload

appel opĂ©rateur — le plus lent, il force le fetch

⚠ JWKSCache n'est PAS réécrit. Il est dĂ©jĂ  thread-safe (threading.Lock) : plusieurs threads d'exĂ©cuteur s'y sĂ©rialisent, un seul fait le fetch, les autres trouvent l'instantanĂ© frais. Son fail-close (un cache pĂ©rimĂ© n'est jamais servi), son backoff et son throttle « kid inconnu » sont inchangĂ©s — les dĂ©placer aurait transformĂ© un gel en martĂšlement de l'endpoint de mcp-mission.

⚠ MISSION_JWKS_CACHE_TTL n'est pas un rĂ©glage de latence. Une clĂ© rĂ©voquĂ©e disparaĂźt du JWKS de mcp-mission : ce TTL est donc la fenĂȘtre de propagation d'une rĂ©vocation de clĂ© de signature. Il vaut 60 s, cinq fois plus strict que le max-age=300 qu'ils publient, et il ne doit pas ĂȘtre allongĂ© pour espacer les appels.

Ce qui change

Ce qui ne change pas

Le chargement quitte la boucle

Le fail-close de #86/#69 : sur le Policy Store, le Mission Binding Store et le registre wrap, un instantané périmé au-delà de son TTL ne sert plus aucune décision

Les mutations aussi (acreate, adelete, arevoke, 
)

La forme, les codes d'erreur et les signatures des outils existants. Deux évolutions bornées : system_health gagne stores / stores_stale, et le texte du warning de révocation énonce désormais l'incertitude au lieu d'un futur certain

Une opération wrap est atomique vis-à-vis d'un rechargement

La posture du Token Store au point de décision : available y reste diagnostique et un instantané périmé continue techniquement d'authentifier. Depuis v0.20.1, la readiness passe néanmoins à 503 pour signaler que cette décision n'est plus garantie

Deux horloges, pas une. Le code historique n'en portait qu'une, qui servait Ă  la fois Ă  dater l'instantanĂ© et Ă  brider les re-tentatives. Une panne les mettait en dĂ©saccord : repousser la date pour ne pas marteler S3 rajeunissait aussi, mĂ©caniquement, un instantanĂ© jamais rechargĂ© — un magasin en panne restait donc « frais » indĂ©finiment. Elles sont dĂ©sormais sĂ©parĂ©es, et en temps monotone : un recul d'horloge murale ne peut plus rajeunir un Ă©tat pĂ©rimĂ©.

Copy-on-write sur les trois magasins d'autorisation : une mutation construit un candidat, le persiste, et ne le publie qu'aprĂšs confirmation du PUT. Une garde synchrone ne peut donc plus observer une policy créée mais non persistĂ©e, ni une suppression que l'Ă©chec du PUT annulera. Les rollbacks disparaissent — donc aussi le risque d'en oublier un.

⚠ Le registre wrap n'a dĂ©libĂ©rĂ©ment PAS de copy-on-write. Aucun lecteur synchrone ne l'interroge, et la publication-aprĂšs-PUT y dĂ©truirait un acquis de v0.14.1 : mark_entries_revoked conserve volontairement en mĂ©moire une rĂ©vocation confirmĂ©e mais non inscrite, parce que la mĂ©moire y dit la vĂ©ritĂ© et que c'est le support durable qui est en retard.

Observabilité. system_health porte désormais la fraßcheur de chaque magasin (stores, et stores_stale s'il y a lieu). Sans cela, un rafraßchisseur mort ne se verrait qu'au premier refus : le service répondrait normalement, puis refuserait tout d'un coup.

Le registre wrap y ajoute sa volumĂ©trie (v0.16.0, #146) : entries, le nombre d'entrĂ©es, et last_write_bytes, le poids du dernier corps Ă©crit — null tant qu'aucune Ă©criture n'a eu lieu depuis le dĂ©marrage. Le registre n'a ni purge ni Ă©criture incrĂ©mentale : il est réécrit intĂ©gralement Ă  chaque transition, et nous n'avions aucun chiffre pour dimensionner ce coĂ»t.

⚠ last_write_bytes est relevĂ© Ă  l'Ă©criture, jamais calculĂ© par la sonde. SĂ©rialiser le registre Ă  chaque appel de supervision ferait de la sonde une cause du problĂšme qu'elle mesure. entries, lui, est un simple dĂ©compte en mĂ©moire.

/health et /ready lisent dĂ©sormais la fraĂźcheur en mĂ©moire : un magasin configurĂ© jamais chargĂ© ou pĂ©rimĂ© rend le service 503 unavailable, car les dĂ©cisions d'autorisation ne peuvent plus ĂȘtre garanties. Ces sondes ne font aucun appel S3 ; les rafraĂźchisseurs existants peuvent les rĂ©tablir sans redĂ©marrage. /healthz reste une liveness indĂ©pendante. system_health reste plus strict et vĂ©rifie en plus la connectivitĂ© S3 courante.

Ce fail-close de readiness est uniforme, y compris pour le Token Store : il ne bloque pas lui-mĂȘme une requĂȘte MCP dĂ©jĂ  routĂ©e, mais demande Ă  l'orchestrateur de retirer l'instance. Le produit Ă©tant mono-instance, une panne S3 assez longue pour pĂ©rimer tous les magasins provoque donc une indisponibilitĂ© totale cĂŽtĂ© load balancer. C'est le contrat explicitement retenu en v0.20.1 : une dĂ©cision d'autorisation non garantie ne doit pas ĂȘtre annoncĂ©e disponible.

⚠ Limite inchangĂ©e : rien ici ne ferme la race d'Ă©criture multi-instance (#13/#51). Le dernier Ă©crivain gagne toujours, et un rafraĂźchissement de fond Ă©largit mĂȘme la fenĂȘtre pendant laquelle deux instances peuvent diverger sans le savoir.

Sondes de santé (v0.11.0, #103)

Deux questions distinctes, deux contrats :

Endpoint

Question

Sain

Dégradé

/healthz

liveness — le processus rĂ©pond-il ?

200 alive

200 alive

/health, /ready

disponibilitĂ© — le coffre peut-il servir ?

200 healthy

503

status appartient Ă  une Ă©numĂ©ration fermĂ©e, une par question : healthy | sealed | unavailable pour la disponibilitĂ©, alive pour la liveness. Un coffre scellĂ© est donc identifiable de l'extĂ©rieur, sans qu'aucun dĂ©tail de dĂ©pendance ne soit exposĂ© — le diagnostic reste dans les journaux et sur /admin/api/health, qui exige un bearer valide.

Le HEALTHCHECK du conteneur vise /health : un coffre indisponible apparaĂźt dĂ©sormais unhealthy. Branchez une Ă©ventuelle sonde de liveness ou un autoheal sur /healthz, jamais sur /health — redĂ©marrer un coffre scellĂ© ne le descelle pas, et redĂ©marrer ne rĂ©tablit pas S3.

AprÚs un démarrage non abouti, l'état est latché : le signal ne repasse pas au vert tout seul, un redémarrage est requis. Cette rÚgle ne concerne pas un magasin temporairement périmé aprÚs un démarrage abouti : son refresher peut rétablir /health sans nouvelle génération.

Lifecycle OpenBao

STARTUP:  S3 download (3 Ă©tats : restaurĂ© / absence confirmĂ©e / Ă©chec ambigu —
          un Ă©chec ambigu refuse le dĂ©marrage) → bao server → init/unseal → periodic sync
RUNTIME:  secrets via hvac → sync S3 toutes les 60s, SEULEMENT si l'Ă©tat a changĂ©
SHUTDOWN: seal → S3 upload final (sautĂ© si le dĂ©marrage n'a pas abouti, pour ne pas Ă©craser une sauvegarde valide) → stop process
CRASH:    Docker volume local → redĂ©marrage immĂ©diat

Sécurité des clés unseal

Les clés unseal d'OpenBao sont protégées par séparation physique à 3 facteurs :

Facteur

Stockage

Compromis seul = insuffisant

Données chiffrées (barrier OpenBao)

Volume Docker + S3

Illisibles sans unseal key

Clés unseal (chiffrées AES-256-GCM)

S3 uniquement

Indéchiffrables sans bootstrap key

ADMIN_BOOTSTRAP_KEY

Variable d'env uniquement

Inutile sans les clés chiffrées

Invariants : les clĂ©s unseal ne sont jamais en clair sur disque — uniquement en mĂ©moire pendant le runtime. Un crash efface automatiquement les clĂ©s.

Roadmap sécurité :

Version

Approche

v0.8.0 (actuel)

Clés sur S3 chiffrées AES-256-GCM+AAD, mémoire seule au runtime

v1.0

Transit Auto-Unseal via OpenBao dédié (KMS Cloud Temple)

v2.0

Connexion HSM (Hardware Security Module) Cloud Temple — les clĂ©s ne quittent jamais le module matĂ©riel certifiĂ©

📖 Voir DESIGN/mcp-vault/ARCHITECTURE.md §8 et §11 pour les dĂ©tails complets.


📋 Tests (~1500 unitaires mockĂ©s + 349 e2e rĂ©els)

📖 Voir tests/README.md pour le guide complet d'exĂ©cution.

# 1. Tests CLI — parsing + affichage (197 tests, SANS serveur)
python tests/test_cli_all.py

# 2. Tests CLI LIVE — cycle complet (79 tests, serveur rĂ©el)
MCP_URL=http://localhost:8085 MCP_TOKEN=<key> python tests/test_cli_live.py

# 3. Tests e2e MCP (349 tests, dans Docker)
docker compose exec mcp-vault python tests/test_e2e.py

# 4. Tests crypto (18 tests, SANS serveur — AES-256-GCM + AAD + validation entropie)
python tests/test_crypto.py

# Un seul groupe CLI
python tests/test_cli_all.py --only policy

# Un seul groupe e2e
docker compose exec mcp-vault python tests/test_e2e.py --test enforcement

Couverture e2e (349 tests, 15 catégories)

Catégorie

Tests

Description

SystĂšme

7

health, about, services, tools_count (37)

Vault CRUD

28

create + métadonnées, list, info + owner, update, delete, confirm, erreurs

Secrets CRUD

24

10 types écrits, read/list/delete, validation

Versioning

8

v1→v2→v3, read latest, read spĂ©cifique

Passwords

14

longueurs, options, exclusions, CSPRNG

Isolation

7

secrets cloisonnés entre vaults

Erreurs

10

edge cases, vault inexistant, type invalide, protection _vault_meta

S3 Sync

3

archive tar.gz sur S3

SSH CA

33

setup, rĂŽles multiples, signature ed25519, list/info roles, isolation CA, cleanup

Types

14

14 types vérifiés individuellement

Admin API

15

health, whoami, generate-password, logs, unicité CSPRNG

Policies MCP

43

CRUD, validation, wildcards, path_rules, doublons, erreurs, Admin API REST

Policy Enforcement

37

check_policy, token_update, denied/allowed, changement policy, Admin API

Audit Log

31

audit_log MCP, filtres (category/tool/status/since/limit), stats, Admin API /audit

WAF Security

62

LFI, SQLi, XSS, RCE, scanners → 403 ; faux positif 930120 ; anti-Ă©vasion json.* ; bornes taille/profondeur JSON ; grammaire Content-Type ; non-rĂ©gression requĂȘtes lĂ©gitimes


📁 Structure du projet

mcp-vault/
├── .env.example              # Configuration (copier en .env)
├── docker-compose.yml        # WAF + MCP Vault + volumes
├── Dockerfile                # Multi-stage (OpenBao 2.5.1 + Python 3.12)
├── requirements.txt          # DĂ©pendances Python
├── requirements.lock         # DĂ©pendances pinnĂ©es (versions exactes)
├── VERSION                   # version courante du service
├── DESIGN/mcp-vault/
│   ├── ARCHITECTURE.md       # SpĂ©cification dĂ©taillĂ©e (v0.15.0)
│   ├── TECHNICAL.md          # Documentation technique (v0.15.0)
│   └── SECURITY_AUDIT.md     # Rapport d'audit consolidĂ© (60 findings V2.1)
├── scripts/
│   ├── mcp_cli.py            # CLI entry point
│   ├── README.md             # Documentation CLI
│   └── cli/                  # Module CLI (Click + Rich)
│       ├── __init__.py       # Config (.env, BASE_URL, TOKEN)
│       ├── client.py         # MCPClient (Streamable HTTP)
│       ├── commands.py       # Groupes Click
│       └── display.py        # Affichage Rich
├── src/mcp_vault/
│   ├── config.py             # Configuration pydantic-settings
│   ├── server.py             # MCPServer + 39 outils MCP + lifecycle + audit
│   ├── lifecycle.py          # Orchestrateur startup/shutdown
│   ├── s3_client.py          # Client S3 hybride SigV2/SigV4
│   ├── s3_sync.py            # Sync file backend ↔ S3
│   ├── auth/                 # Bearer tokens, check_access, ContextVar
│   ├── admin/                # Console web /admin + API REST
│   ├── openbao/              # Process manager, HCL config, lifecycle
│   ├── vault/                # Spaces, secrets, SSH CA, types
│   └── static/               # Console admin SPA (paritĂ© CLI 100%)
│       ├── admin.html        # HTML structure + 7 modals
│       ├── css/admin.css     # Design Cloud Temple (dark theme)
│       ├── js/               # 9 modules JS (config, api, app, dashboard, vaults, tokens, policies, activity, pki)
│       └── img/              # logo-cloudtemple.svg
├── tests/
│   ├── README.md             # Guide d'exĂ©cution des tests (auditeurs)
│   ├── TEST_CATALOG.md       # Catalogue des tests pour auditeurs
│   ├── test_cli_all.py       # 197 tests CLI parsing (sans serveur)
│   ├── test_cli_live.py      # 79 tests CLI live (serveur rĂ©el)
│   ├── test_e2e.py           # 349 tests MCP e2e (15 catĂ©gories)
│   ├── test_crypto.py        # 18 tests AES-256-GCM + AAD
│   ├── test_service.py       # 78 tests bas niveau
│   ├── test_integration.py   # Tests pytest
│   └── cli/                  # Tests CLI dĂ©coupĂ©s par groupe (7 fichiers)
└── waf/                      # WAF Caddy + Coraza (OWASP CRS v4)
    ├── Dockerfile            # Multi-stage (xcaddy + CRS v4.7.0)
    ├── Caddyfile             # Reverse proxy + coraza_waf
    └── coraza.conf           # Config Coraza + exceptions MCP

🌐 Écosystùme MCP Cloud Temple

Serveur

RĂŽle

Port

MCP Tools

BoĂźte Ă  outils (SSH, HTTP, shell)

:8010

Live Memory

Mémoire de travail partagée

:8002

Graph Memory

Mémoire long terme (graphe)

:8080

MCP Vault

🔐 Coffre-fort à secrets

:8030

MCP Agent

Runtime d'agents autonomes

:8040

MCP Mission

Orchestrateur de missions

:8020


Licence : Apache 2.0 | Auteur : Cloud Temple | Version : 0.20.1

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    MCP-native credential vault that enables AI agents to authenticate with external services without exposing secrets, supporting bearer, basic, OAuth2, and other auth patterns via MCP tools.
    10 npm
    1
    MIT
  • A
    license
    Not graded
    quality
    A
    maintenance
    Provides a zero-knowledge credential vault for AI agents, allowing secure storage, retrieval, and management of secrets with cross-agent delegation and tamper-evident audit.
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    Agent Vault is a local credential vault for AI agents. It enables agents to use secrets by name without ever seeing their values, with host allowlists, output scrubbing, and audit logging.
    4
    MIT
  • A
    license
    A
    quality
    B
    maintenance
    Enables AI agents to make authenticated API calls and run commands with secrets injected, while keeping credentials completely hidden from the model, with policy enforcement, grants, and audit logging.
    6
    MIT