Vault & PKI

Rôle

Cluster HashiCorp Vault : coffre-fort de secrets applicatifs Indio (moteur KV-v2) et autorité de certification interne (PKI racine + intermédiaire, avec CRL/OCSP), utilisée notamment pour l'émission de certificats RDP/KDC/ carte à puce du domaine Active Directory (voir Durcissement Active Directory) et plus largement comme source de confiance TLS interne. Le scellement (unseal) est automatisé via AWS KMS (voir Cloud AWS).

Architecture

VM vault_node_id IP
INDSERV016 vault-1 10.100.2.146
INDSERV017 vault-2 10.100.2.147
INDSERV018 vault-3 10.100.2.148
  • 3 nœuds en zone seg-service (admin), stockage Raft natif (HA sans dépendance externe type Consul), clonés depuis tmpl-rocky96-hardened.
  • VIP HAProxy 10.100.2.130:8200, DNS vault.infra.indio, en TCP passthrough — HAProxy ne termine pas le TLS : un client voit directement le certificat du nœud Vault backend réel, jamais un certificat propre à la VIP.
  • Deux chaînes de confiance distinctes à ne pas confondre :
  • une CA bootstrap (vault-bootstrap-ca), autosignée, qui sécurise uniquement le listener/cluster Vault lui-même (TLS des API, du retry_join Raft entre nœuds) ;
  • la PKI applicative que Vault opère en tant que service (pki = CA racine « Indio Root CA », pki_int = CA intermédiaire « Indio Intermediate CA ») — c'est celle-ci que consomment les autres composants de l'infra (ex. AD pour les certificats RDP/KDC).

Topologie du cluster et auto-unseal

flowchart TB
    subgraph AWS["AWS eu-west-3 (compte 340694419192)"]
        KMS["KMS alias/vault-unseal"]
    end
    subgraph SEG["seg-service — 10.100.2.128/26"]
        VIP["VIP HAProxy 10.100.2.130:8200\nvault.infra.indio — TCP passthrough"]
        V1["INDSERV016 vault-1\n10.100.2.146:8200"]
        V2["INDSERV017 vault-2\n10.100.2.147:8200"]
        V3["INDSERV018 vault-3\n10.100.2.148:8200"]
        V1 -. "Raft retry_join (mTLS, CA bootstrap)" .- V2
        V2 -. "Raft retry_join" .- V3
        V1 -. "Raft retry_join" .- V3
    end
    VIP -->|"httpchk GET /v1/sys/health\nexpect 200 (leader only)"| V1
    VIP -.-> V2
    VIP -.-> V3
    V1 -. "auto-unseal (Decrypt/Encrypt)" .-> KMS
    V2 -. "auto-unseal" .-> KMS
    V3 -. "auto-unseal" .-> KMS

Le health-check HAProxy (GET /v1/sys/health, expect status 200) exploite directement la sémantique de l'API Vault : seul le nœud actif (leader Raft) répond 200, les standby répondent 429 par défaut. La VIP route donc toujours vers le leader courant sans configuration supplémentaire — un follower ne reçoit jamais de trafic applicatif via la VIP.

Hiérarchie de la PKI applicative (bootstrap, vault_pki_setup.sh)

sequenceDiagram
    participant Op as Opérateur (root token, run_once)
    participant Root as pki (Indio Root CA)
    participant Int as pki_int (Indio Intermediate CA)
    Op->>Root: pki/root/generate/internal (CN="Indio Root CA", RSA 4096, TTL 87600h)
    Root-->>Op: CA racine auto-signée
    Op->>Int: pki_int/intermediate/generate/internal (CN="Indio Intermediate CA", RSA 4096)
    Int-->>Op: CSR intermédiaire
    Op->>Root: pki/root/sign-intermediate (csr, TTL 43800h)
    Root-->>Op: certificat intermédiaire signé
    Op->>Int: pki_int/intermediate/set-signed (certificate)
    Note over Int: pki_int est désormais opérationnelle —<br/>c'est ELLE qui signe tous les certificats leaf
    Op->>Int: pki_int/config/urls + pki_int/config/crl (AIA/CDP/OCSP, auto_rebuild, delta CRL)

Émission d'un certificat (principe sign-only)

sequenceDiagram
    participant C as Client (CSR généré localement)
    participant V as Vault (pki_int/sign/&lt;role&gt;)
    C->>C: génère sa propre paire de clés + CSR (clé privée jamais transmise)
    C->>V: authentification (token, AppRole ou Kerberos selon le consommateur)
    V-->>C: token + policies (ex. pki-sign-rdp, sign-only)
    C->>V: POST pki_int/sign/ROLE {csr, common_name, ttl}
    Note over V: JAMAIS pki_int/issue/* pour un consommateur externe —<br/>ce endpoint générerait ET transmettrait une clé privée
    V-->>C: certificat signé (PEM) + chaîne

Provisioning Terraform

Fichiers : main.tf, variables.tf, versions.tf, terraform.tfvars.example.

  • Providers hashicorp/vsphere et e-breuninger/netbox, même schéma que les autres dépôts VM de l'infra (clone de template + réservation IP NetBox par nom DNS).
  • vsphere_virtual_machine.vault en for_each sur var.nodes (défaut : INDSERV016/017/018), réseau seg-service (gateway 10.100.2.190/26), 2 vCPU / 4096 Mo par défaut.
  • output vms : map nom → IP.
  • Variables sensibles (vsphere_password, netbox_token) marquées sensitive = true ; le vrai terraform.tfvars est gitignoré, seul terraform.tfvars.example (valeurs CHANGE_ME) est versionné.

Configuration Ansible

  • inventory.ini : groupe [vault] avec vault_node_id par hôte.
  • group_vars/all.yml : vault_vip (10.100.2.130), région KMS (eu-west-3), identifiant de la clé KMS dédiée à l'auto-unseal (vault_kms_key_id, ARN complet), et variables de proxy sortant (Squid, no_proxy incluant les domaines internes) — nécessaires car le process vault a besoin de ses propres variables de proxy pour joindre l'API KMS (le environment: Ansible d'un play ne configure que l'exécution des tâches, pas le process vault lui-même une fois démarré).
  • group_vars/vault.yml : fichier chiffré ansible-vault — non consulté pour cette page (contient a priori les identifiants AWS du KMS). Valeurs gérées séparément.
  • templates/vault.hcl.j2/etc/vault.d/vault.hcl : bloc seal "awskms" (région + kms_key_id), cluster_addr/api_addr sur l'IP du nœud, storage "raft" avec retry_join vers les deux autres nœuds (mTLS via la CA bootstrap), listener "tcp" avec certificat de nœud et tls_client_ca_file pour le mTLS inter-cluster.
  • templates/kms.env.j2/etc/vault.d/kms.env (mode 0600) : variables d'environnement AWS + proxy, référencées par un drop-in systemd (EnvironmentFile=, /etc/systemd/system/vault.service.d/kms-unseal.conf) sur le service vault.
  • files/tls/ : certificat/clé par nœud (indservXXX.crt/.key) + CA bootstrap (vault-bootstrap-ca.crt/.key/.srl) — matériel TLS non lu ni reproduit ici (clés privées). Les tâches de copie de ces fichiers ont un notify: redémarrer vault — sans ce handler, un certificat mis à jour sur disque continue d'être ignoré par le process déjà démarré (piège rencontré et corrigé lors de l'ajout du SAN vault.infra.indio, voir Procédure manuelle).
  • files/vault_pki_setup.sh : script shell idempotent, exécuté une seule fois sur le leader (run_once) après déblocage du cluster. Active le moteur KV-v2 au path secret, la CA racine au path pki (TTL max 87600h = 10 ans, RSA 4096, CN « Indio Root CA »), la CA intermédiaire au path pki_int (TTL max 43800h = 5 ans, RSA 4096, CN « Indio Intermediate CA », CSR signé par la racine), les URLs issuing_certificates/crl_distribution_points/ocsp_servers (sur la VIP) et la config CRL (auto_rebuild=true, auto_rebuild_grace_period=12h, enable_delta=true, OCSP activé, expiry 72h), puis un rôle d'émission générique infra-indio (allowed_domains=infra.indio, allow_subdomains, max_ttl=8760h/1 an, RSA 2048). Les rôles spécifiques (KDC, smart-card) ne sont pas créés par ce script — voir Procédure manuelle.
  • site.yml : trois plays —
  • installation du paquet vault (dépôt YUM HashiCorp), dépôt du TLS, génération de vault.hcl, drop-in systemd KMS, ouverture firewalld (8200/8201), démarrage du service ;
  • initialisation (vault operator init -key-shares=5 -key-threshold=3, une seule fois sur le premier nœud) puis déblocage (vault operator unseal -migrate, harmless en unseal normal, indispensable seulement le temps d'une migration de seal Shamir → awskms) ;
  • exécution du script de setup PKI (run_once, sortie no_log).

Procédure manuelle

Trois familles d'actions réalisées directement contre l'environnement AWS ou le cluster Vault vivant, absentes du Terraform/Ansible versionné.

1. Prérequis AWS pour l'auto-unseal (hors Terraform)

La clé KMS et l'IAM user utilisés par seal "awskms" ne sont pas créés par ce dépôt ni par le projet Cloud AWS — provisionnés manuellement par l'opérateur AWS, credentials transmis séparément :

  1. Vérifier si un alias KMS dédié existe déjà (aws kms list-aliases) avant d'en créer un nouveau — un alias vault-unseal peut préexister d'un provisioning antérieur.
  2. Créer un utilisateur IAM dédié à cet usage unique (ne pas réutiliser un utilisateur IAM existant pour un autre usage AWS de l'infra).
  3. Attacher une policy strictement scopée à l'ARN de cette clé (kms:Encrypt, kms:Decrypt, kms:DescribeKey — pas Resource: "*").
  4. Fournir la région, l'ARN de la clé et les credentials au playbook via group_vars/vault.yml (chiffré ansible-vault).

2. Correction du SAN TLS des nœuds (ajout de vault.infra.indio)

Les certificats ansible/files/tls/indserv0NN.crt ont été générés avant que le nom vault.infra.indio n'existe. Le listener HAProxy faisant du TCP passthrough, un client qui se connecte via ce nom voit directement le certificat du nœud — sans ce SAN, la validation TLS échoue côté client malgré un certificat par ailleurs valide.

# CSR + re-signature avec la CA bootstrap existante (clé privée déjà
# présente localement, pas de nouvelle CA ni nouvelle paire de clés)
openssl req -new -key indservXXX.key -out indservXXX.csr \
  -subj "/CN=indservXXX.infra.indio" \
  -addext "subjectAltName=DNS:indservXXX.infra.indio,DNS:vault.infra.indio,DNS:localhost,IP:10.100.2.14X"
openssl x509 -req -in indservXXX.csr -CA vault-bootstrap-ca.crt -CAkey vault-bootstrap-ca.key \
  -CAcreateserial -out indservXXX.crt -days 825 -extfile <(echo "subjectAltName=...")

Déployé nœud par nœud (ansible-playbook site.yml --limit INDSERVxxx, jamais tous en même temps — pas de serial: dans le playbook). Toujours se connecter via vault.infra.indio, jamais l'IP de la VIP : celle-ci n'est dans aucun SAN.

3. Configuration Vault pour les consommateurs PKI de l'AD (RDP/KDC)

Réalisée en API directe sur le cluster vivant, absente de vault_pki_setup.sh. Détail exhaustif (avec chacun des blocages réels rencontrés) dans le README.md de Durcissement Active Directory ; résumé ici du côté Vault uniquement :

# Rôles PKI dédiés (le rôle serveur "infra-indio" existant suffit au RDP serveur)
vault write pki_int/roles/infra-indio-kdc        server_flag=true client_flag=true ext_key_usage=ServerAuth,ClientAuth ...
vault write pki_int/roles/infra-indio-smartcard  client_flag=true ext_key_usage=ClientAuth,SmartCardLogon ...

# Policies sign-only (jamais pki_int/issue/*)
vault policy write pki-sign-rdp        - <<< 'path "pki_int/sign/infra-indio"        { capabilities = ["create","update"] }'
vault policy write pki-sign-kdc        - <<< 'path "pki_int/sign/infra-indio-kdc"    { capabilities = ["create","update"] }'
vault policy write pki-sign-smartcard  - <<< 'path "pki_int/sign/infra-indio-smartcard" { capabilities = ["create","update"] }'

# Authentification Kerberos/SPNEGO (pas de secret partagé)
vault auth enable kerberos
vault write auth/kerberos/config keytab=@svc-vault-kerb.keytab service_account=svc-vault-kerb add_group_aliases=true
vault write auth/kerberos/config/ldap url="ldaps://<DC>:636" userattr=sAMAccountName \
  groupfilter="(&(objectClass=group)(member:1.2.840.113556.1.4.1941:={{.UserDN}}))" groupattr=cn
vault write sys/auth/kerberos/tune passthrough_request_headers="Authorization"

# Mapping identity group AD -> policy (noms localisés FR de ce domaine)
vault write identity/group-alias name="Vault-KDC-Cert-Issuers" mount_accessor=<kerberos accessor> canonical_id=<...>

Points durement appris à connaître avant de rejouer cette configuration ailleurs (détail complet dans le README ad-hardening) : upndomain doit rester vide sur le mount LDAP (un POST omettant le champ ne le réinitialise pas — merge partiel de l'API) ; passthrough_request_headers est obligatoire sur le mount kerberos (Vault Core ne transmet pas Authorization par défaut) ; les groupes intégrés « Contrôleurs de domaine »/« Ordinateurs du domaine » sont des primary groups implicites, invisibles à LDAP_MATCHING_RULE_IN_CHAIN — nécessite des groupes AD dédiés avec appartenance explicite.

Procédure de déploiement

  1. terraform apply — provisionne les 3 VM sur seg-service.
  2. ansible-playbook site.yml — installe et configure Vault sur les 3 nœuds, initialise puis débloque le cluster Raft (auto-unseal awskms dès le premier boot si vault.hcl est déjà configuré en conséquence — plus besoin de migration manuelle pour un déploiement neuf), active KV-v2 et la PKI (racine/intermédiaire/CRL/OCSP).
  3. Les clés de déblocage (5, seuil 3, valables comme recovery keys en mode auto-unseal) et le root token générés par vault operator init sont écrits uniquement dans /root/.vault_init.json sur le nœud de contrôle Ansible (hors dépôt git, mode 0600) — à sécuriser/archiver séparément selon la procédure du coffre-fort de secrets Indio, jamais versionnés.
  4. Prérequis AWS (Procédure manuelle §1) — à faire avant l'étape 2 pour un déploiement strictement dans l'ordre, ou en migration a posteriori sur un cluster déjà en Shamir pur (vault operator unseal -migrate).
  5. Intégrations consommatrices (RDP/KDC/carte à puce AD — Procédure manuelle §3) — configuration à réaliser directement sur le cluster vivant après l'étape 2.

Contrôle de santé / Vérification

# État individuel de chaque nœud (l'unseal ne se propage PAS entre nœuds Raft)
curl -sk https://10.100.2.146:8200/v1/sys/health
curl -sk https://10.100.2.147:8200/v1/sys/health
curl -sk https://10.100.2.148:8200/v1/sys/health
# sealed:false attendu sur les 3 ; un seul retourne 200 (le leader), les
# standby retournent 429 (comportement normal, cf. diagramme topologie)

# Toujours via le nom DNS, jamais l'IP de la VIP (absente de tout SAN)
curl -sk https://vault.infra.indio:8200/v1/sys/health

# Composition du cluster Raft
VAULT_ADDR=https://vault.infra.indio:8200 vault operator raft list-peers

# OCSP de la PKI applicative
openssl ocsp -issuer indio-intermediate-ca.pem -cert <certificat-a-verifier.pem> \
  -url https://vault.infra.indio:8200/v1/pki_int/ocsp -CAfile indio-root-ca.pem
# -> "Response verify OK" / "good"

# Test d'émission (rôle générique, ne consomme pas pki_int/issue en usage réel)
vault write pki_int/issue/infra-indio common_name=test.infra.indio ttl=1h

Points d'attention

Aucune valeur sensible dans cette page

Ni token root, ni clé de déblocage, ni mot de passe, ni clé privée TLS n'ont été lus ou reproduits pour documenter ce composant (group_vars/vault.yml, .vault_init.json et les fichiers *.key du dépôt Ansible sont volontairement exclus de cette analyse). Ces valeurs sont gérées séparément (coffre-fort de secrets Indio / fichiers non versionnés).

Historique : deux incidents de cluster scellé avant l'auto-unseal (07-12)

Avant l'activation de l'auto-unseal AWS KMS, le cluster a été retrouvé scellé sur les 3 nœuds à deux reprises (probable redémarrage VM sans reprise d'unseal manuel, cause racine du redémarrage non investiguée). Symptôme trompeur observé : la VIP HAProxy semblait en panne TLS (unexpected eof while reading dès le Client Hello) alors que le port TCP restait ouvert — en réalité HAProxy marquait les 3 backends DOWN (Vault renvoie 503 quand scellé) et coupait la connexion immédiatement. Réflexe de diagnostic : toujours tester .../v1/sys/health directement sur un nœud Vault avant de suspecter HAProxy. C'est cette récurrence qui a motivé l'auto-unseal du 2026-07-15.

  • Auto-unseal AWS KMS actif depuis le 2026-07-15 : a mis fin aux incidents ci-dessus. Vérifié end-to-end (redémarrage d'un nœud standby sans fournir aucune clé Shamir → Sealed: false en moins de 5s).
  • TCP passthrough HAProxy : toujours utiliser le nom DNS vault.infra.indio (jamais l'IP de la VIP) pour toute connexion cliente — l'IP de la VIP n'apparaît dans aucun SAN de certificat de nœud, la validation TLS échoue si on s'y connecte directement.
  • Deux CA à ne pas confondre : la CA bootstrap (TLS du cluster Vault lui-même) et la PKI applicative pki/pki_int (ce que Vault émet pour le reste de l'infra). Un déploiement de confiance côté client (ex. AD) doit distribuer les deux chaînes, pas seulement l'une des deux.
  • Principe sign-only pour les consommateurs externes : les intégrations (ex. AD) envoient un CSR déjà généré localement à pki_int/sign/<role> ; Vault ne génère et ne transmet jamais de clé privée pour ces usages, et aucun endpoint pki_int/issue/* ne doit être exposé à ces intégrations.
  • Les URLs AIA/CDP/OCSP (pki_int/config/urls) sont embarquées dans chaque certificat au moment de l'émission, pas relues dynamiquement — un changement de cette config (ex. passage d'une IP brute à vault.infra.indio, cf. incident documenté dans Durcissement Active Directory) n'a aucun effet sur les certificats déjà émis ; seul un réenrôlement forcé les met à jour.
  • Les fichiers ansible/files/tls/*.key et la CA bootstrap constituent du matériel sensible présent dans le dépôt Ansible — à exclure de toute republication ou export de ce dépôt en dehors de son usage opérationnel normal.
  • La clé KMS et l'IAM user d'auto-unseal appartiennent au même compte AWS que le bucket de tfstate/archivage — voir Cloud AWS pour le contexte complet (deux IAM users distincts par usage, jamais partagés).