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:
5
.env
5
.env
@@ -127,3 +127,8 @@ OVH_KEY=
|
||||
OVH_SECRET=
|
||||
OVH_CUSTOMER=
|
||||
###< ovh ###
|
||||
|
||||
###> vault ###
|
||||
VAULT_URL=
|
||||
VAULT_TOKEN=
|
||||
###< vault ###
|
||||
|
||||
@@ -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
|
||||
|
||||
226
src/Service/VaultService.php
Normal file
226
src/Service/VaultService.php
Normal 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) ?? [] : [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user