From 387071341226e4792e1cf2cbab45d139ba8d5520 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Sat, 4 Apr 2026 21:45:27 +0200 Subject: [PATCH] feat: VaultService pour chiffrement Transit Hashicorp Vault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VaultService — chiffrement/déchiffrement via Vault Transit engine : Gestion des clés : - createKey(keyName, type) : crée une clé Transit (défaut aes256-gcm96) - deleteKey(keyName) : marque deletable + supprime - updateKey(keyName, config) : met à jour la config (rotation, export...) - listKeys() : liste toutes les clés Transit - keyExists(keyName) : vérifie l'existence d'une clé - checkOrCreateKey(keyName) : crée la clé si elle n'existe pas Chiffrement : - encrypt(keyName, plaintext) : chiffre avec Transit, retourne vault:v1:... Auto-crée la clé si inexistante - decrypt(keyName, ciphertext) : déchiffre le ciphertext Transit Communication HTTP avec X-Vault-Token, gestion erreurs 4xx/5xx. Configuration : - .env : VAULT_URL, VAULT_TOKEN (vides par défaut) - .env.local : VAULT_URL=http://vault:8200, VAULT_TOKEN=crm_siteconseil - ansible/vault.yml : vault_url=https://kms.esy-web.dev pour la prod - Transit engine activé sur le container Vault dev Co-Authored-By: Claude Opus 4.6 (1M context) --- .env | 5 + ansible/vault.yml | 2 + src/Service/VaultService.php | 226 +++++++++++++++++++++++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 src/Service/VaultService.php diff --git a/.env b/.env index 1531b54..8873429 100644 --- a/.env +++ b/.env @@ -127,3 +127,8 @@ OVH_KEY= OVH_SECRET= OVH_CUSTOMER= ###< ovh ### + +###> vault ### +VAULT_URL= +VAULT_TOKEN= +###< vault ### diff --git a/ansible/vault.yml b/ansible/vault.yml index fd08c4b..9b8f181 100644 --- a/ansible/vault.yml +++ b/ansible/vault.yml @@ -21,6 +21,8 @@ docuseal_api: pgAU116mCFmeF7WQSezHqxtZW8V1fgo31u5d2FXoaKe docuseal_webhooks_secret: CRM_COSLAY discord_webhook: https://discord.com/api/webhooks/1419573620602044518/ikAdxWxsrrTqMTb5Gh_8ylcoJHlOnq7aJZvR5udoS_fCK56Jk3qpEnJHVKdD8fwuNJF3 esymail_hostname: mail.esy-web.dev +vault_url: https://kms.esy-web.dev +vault_token: CHANGE_ME_IN_PROD ovh_key: 34bc2c2eb416b67d ovh_secret: 12239d273975b5ab53318907fb66d355 ovh_customer: 56c387eb9ca4b9a2de4d4d97fd3d7f22 diff --git a/src/Service/VaultService.php b/src/Service/VaultService.php new file mode 100644 index 0000000..9da65d6 --- /dev/null +++ b/src/Service/VaultService.php @@ -0,0 +1,226 @@ +vaultUrl && '' !== $this->vaultToken; + } + + // ──── Gestion des clés Transit ───────────────────── + + /** + * Crée une clé de chiffrement Transit. + */ + public function createKey(string $keyName, string $type = 'aes256-gcm96'): bool + { + if (!$this->isAvailable()) { + return false; + } + + try { + $this->request('POST', '/v1/transit/keys/'.$keyName, ['type' => $type]); + + $this->logger->info('Vault: cle creee: '.$keyName); + + return true; + } catch (\Throwable $e) { + $this->logger->error('Vault: erreur creation cle '.$keyName.': '.$e->getMessage()); + + return false; + } + } + + /** + * Supprime une clé Transit (doit être marquée deletable d'abord). + */ + public function deleteKey(string $keyName): bool + { + if (!$this->isAvailable()) { + return false; + } + + try { + // Marquer comme supprimable + $this->request('POST', '/v1/transit/keys/'.$keyName.'/config', ['deletion_allowed' => true]); + // Supprimer + $this->request('DELETE', '/v1/transit/keys/'.$keyName); + + $this->logger->info('Vault: cle supprimee: '.$keyName); + + return true; + } catch (\Throwable $e) { + $this->logger->error('Vault: erreur suppression cle '.$keyName.': '.$e->getMessage()); + + return false; + } + } + + /** + * Met à jour la configuration d'une clé (rotation, export, etc.). + * + * @param array $config + */ + public function updateKey(string $keyName, array $config): bool + { + if (!$this->isAvailable()) { + return false; + } + + try { + $this->request('POST', '/v1/transit/keys/'.$keyName.'/config', $config); + + return true; + } catch (\Throwable $e) { + $this->logger->error('Vault: erreur update cle '.$keyName.': '.$e->getMessage()); + + return false; + } + } + + /** + * Liste toutes les clés Transit. + * + * @return list + */ + public function listKeys(): array + { + if (!$this->isAvailable()) { + return []; + } + + try { + $data = $this->request('LIST', '/v1/transit/keys'); + + return $data['data']['keys'] ?? []; + } catch (\Throwable) { + return []; + } + } + + /** + * Vérifie si une clé existe. + */ + public function keyExists(string $keyName): bool + { + if (!$this->isAvailable()) { + return false; + } + + try { + $this->request('GET', '/v1/transit/keys/'.$keyName); + + return true; + } catch (\Throwable) { + return false; + } + } + + /** + * Vérifie si la clé existe, sinon la crée. + */ + public function checkOrCreateKey(string $keyName, string $type = 'aes256-gcm96'): bool + { + if ($this->keyExists($keyName)) { + return true; + } + + return $this->createKey($keyName, $type); + } + + // ──── Chiffrement / Déchiffrement ────────────────── + + /** + * Chiffre des données avec une clé Transit. + * Retourne le ciphertext (vault:v1:...). + */ + public function encrypt(string $keyName, string $plaintext): ?string + { + if (!$this->isAvailable()) { + return null; + } + + $this->checkOrCreateKey($keyName); + + try { + $data = $this->request('POST', '/v1/transit/encrypt/'.$keyName, [ + 'plaintext' => base64_encode($plaintext), + ]); + + return $data['data']['ciphertext'] ?? null; + } catch (\Throwable $e) { + $this->logger->error('Vault: erreur encrypt ('.$keyName.'): '.$e->getMessage()); + + return null; + } + } + + /** + * Déchiffre des données avec une clé Transit. + */ + public function decrypt(string $keyName, string $ciphertext): ?string + { + if (!$this->isAvailable()) { + return null; + } + + try { + $data = $this->request('POST', '/v1/transit/decrypt/'.$keyName, [ + 'ciphertext' => $ciphertext, + ]); + + $b64 = $data['data']['plaintext'] ?? null; + + return null !== $b64 ? base64_decode($b64) : null; + } catch (\Throwable $e) { + $this->logger->error('Vault: erreur decrypt ('.$keyName.'): '.$e->getMessage()); + + return null; + } + } + + // ──── HTTP ───────────────────────────────────────── + + /** + * @param array|null $body + * + * @return array + */ + private function request(string $method, string $path, ?array $body = null): array + { + $options = [ + 'headers' => [ + 'X-Vault-Token' => $this->vaultToken, + 'Content-Type' => 'application/json', + ], + ]; + + if (null !== $body) { + $options['json'] = $body; + } + + $response = $this->httpClient->request($method, rtrim($this->vaultUrl, '/').$path, $options); + + if ($response->getStatusCode() >= 400) { + throw new \RuntimeException('Vault HTTP '.$response->getStatusCode().': '.$response->getContent(false)); + } + + $content = $response->getContent(false); + + return '' !== $content ? json_decode($content, true) ?? [] : []; + } +}