feat: VaultService pour chiffrement Transit Hashicorp Vault

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) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-04-04 21:45:27 +02:00
parent e03233d922
commit 3870713412
3 changed files with 233 additions and 0 deletions

5
.env
View File

@@ -127,3 +127,8 @@ OVH_KEY=
OVH_SECRET=
OVH_CUSTOMER=
###< ovh ###
###> vault ###
VAULT_URL=
VAULT_TOKEN=
###< vault ###

View File

@@ -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

View File

@@ -0,0 +1,226 @@
<?php
namespace App\Service;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class VaultService
{
public function __construct(
private HttpClientInterface $httpClient,
private LoggerInterface $logger,
#[Autowire(env: 'VAULT_URL')] private string $vaultUrl = '',
#[Autowire(env: 'VAULT_TOKEN')] private string $vaultToken = '',
) {
}
public function isAvailable(): bool
{
return '' !== $this->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<string, mixed> $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<string>
*/
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<string, mixed>|null $body
*
* @return array<string, mixed>
*/
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) ?? [] : [];
}
}