343 lines
11 KiB
PHP
343 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Framework\Vault;
|
|
|
|
use App\Framework\Database\ConnectionInterface;
|
|
use App\Framework\Database\ValueObjects\SqlQuery;
|
|
use App\Framework\DateTime\Clock;
|
|
use App\Framework\Http\IpAddress;
|
|
use App\Framework\Id\Ulid\Ulid;
|
|
use App\Framework\UserAgent\UserAgent;
|
|
use App\Framework\Vault\Exceptions\VaultException;
|
|
use App\Framework\Vault\Exceptions\VaultKeyNotFoundException;
|
|
use App\Framework\Vault\ValueObjects\SecretKey;
|
|
use App\Framework\Vault\ValueObjects\SecretValue;
|
|
use App\Framework\Vault\ValueObjects\VaultAction;
|
|
use App\Framework\Vault\ValueObjects\VaultAuditEntry;
|
|
use App\Framework\Vault\ValueObjects\VaultMetadata;
|
|
use DateTimeImmutable;
|
|
|
|
/**
|
|
* Database-backed Vault Implementation
|
|
*
|
|
* Features:
|
|
* - Libsodium encryption (authenticated encryption)
|
|
* - Transactional safety
|
|
* - Audit logging
|
|
* - Metadata tracking
|
|
* - Key rotation support
|
|
*/
|
|
final readonly class DatabaseVault implements Vault
|
|
{
|
|
private const ENCRYPTION_VERSION = 1;
|
|
|
|
public function __construct(
|
|
private ConnectionInterface $connection,
|
|
private string $encryptionKey,
|
|
private VaultAuditLogger $auditLogger,
|
|
private Clock $clock,
|
|
private ?IpAddress $clientIp = null,
|
|
private ?UserAgent $userAgent = null
|
|
) {
|
|
if (! extension_loaded('sodium')) {
|
|
throw new \RuntimeException('Sodium extension required for DatabaseVault');
|
|
}
|
|
|
|
if (strlen($this->encryptionKey) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
|
|
throw new \InvalidArgumentException(
|
|
'Encryption key must be ' . SODIUM_CRYPTO_SECRETBOX_KEYBYTES . ' bytes'
|
|
);
|
|
}
|
|
}
|
|
|
|
public function get(SecretKey $key): SecretValue
|
|
{
|
|
$query = SqlQuery::create(
|
|
'SELECT encrypted_value, encryption_nonce FROM vault_secrets WHERE secret_key = ?',
|
|
[$key->value]
|
|
);
|
|
$row = $this->connection->queryOne($query);
|
|
|
|
if ($row === null) {
|
|
throw VaultKeyNotFoundException::forKey($key);
|
|
}
|
|
|
|
try {
|
|
$decrypted = $this->decrypt(
|
|
base64_decode($row['encrypted_value']),
|
|
base64_decode($row['encryption_nonce'])
|
|
);
|
|
|
|
// Update access metadata
|
|
$this->updateAccessMetadata($key);
|
|
|
|
// Audit Log
|
|
$this->auditLogger->log(
|
|
VaultAuditEntry::forRead($key, null, $this->clientIp, $this->userAgent)
|
|
);
|
|
|
|
return new SecretValue($decrypted);
|
|
} catch (\Throwable $e) {
|
|
$this->auditLogger->log(
|
|
new VaultAuditEntry(
|
|
key: $key,
|
|
action: VaultAction::READ,
|
|
timestamp: new DateTimeImmutable(),
|
|
ipAddress: $this->clientIp,
|
|
userAgent: $this->userAgent,
|
|
success: false,
|
|
errorMessage: $e->getMessage()
|
|
)
|
|
);
|
|
|
|
throw VaultException::decryptionFailed($e->getMessage());
|
|
}
|
|
}
|
|
|
|
public function set(SecretKey $key, SecretValue $value): void
|
|
{
|
|
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
|
|
|
|
try {
|
|
$encrypted = $this->encrypt($value->reveal(), $nonce);
|
|
|
|
$data = [
|
|
'id' => (string) new Ulid($this->clock),
|
|
'secret_key' => $key->value,
|
|
'encrypted_value' => base64_encode($encrypted),
|
|
'encryption_nonce' => base64_encode($nonce),
|
|
'encryption_version' => self::ENCRYPTION_VERSION,
|
|
'created_at' => (new DateTimeImmutable())->format('Y-m-d H:i:s'),
|
|
'updated_at' => (new DateTimeImmutable())->format('Y-m-d H:i:s'),
|
|
];
|
|
|
|
// Upsert: Insert oder Update wenn bereits existiert
|
|
if ($this->has($key)) {
|
|
$updateQuery = SqlQuery::update(
|
|
'vault_secrets',
|
|
[
|
|
'encrypted_value' => $data['encrypted_value'],
|
|
'encryption_nonce' => $data['encryption_nonce'],
|
|
'updated_at' => $data['updated_at'],
|
|
],
|
|
'secret_key = :secret_key'
|
|
)->withParameter('secret_key', $key->value);
|
|
$this->connection->execute($updateQuery);
|
|
} else {
|
|
$insertQuery = SqlQuery::insert('vault_secrets', $data);
|
|
$this->connection->execute($insertQuery);
|
|
}
|
|
|
|
// Audit Log
|
|
$this->auditLogger->log(
|
|
VaultAuditEntry::forWrite($key, null, $this->clientIp, $this->userAgent)
|
|
);
|
|
} catch (\Throwable $e) {
|
|
$this->auditLogger->log(
|
|
new VaultAuditEntry(
|
|
key: $key,
|
|
action: VaultAction::WRITE,
|
|
timestamp: new DateTimeImmutable(),
|
|
ipAddress: $this->clientIp,
|
|
userAgent: $this->userAgent,
|
|
success: false,
|
|
errorMessage: $e->getMessage()
|
|
)
|
|
);
|
|
|
|
throw VaultException::encryptionFailed($e->getMessage());
|
|
}
|
|
}
|
|
|
|
public function has(SecretKey $key): bool
|
|
{
|
|
$query = SqlQuery::create(
|
|
'SELECT COUNT(*) as count FROM vault_secrets WHERE secret_key = ?',
|
|
[$key->value]
|
|
);
|
|
$result = $this->connection->queryOne($query);
|
|
|
|
return ($result['count'] ?? 0) > 0;
|
|
}
|
|
|
|
public function delete(SecretKey $key): void
|
|
{
|
|
if (! $this->has($key)) {
|
|
throw VaultKeyNotFoundException::forKey($key);
|
|
}
|
|
|
|
$deleteQuery = SqlQuery::delete('vault_secrets', 'secret_key = :secret_key')
|
|
->withParameter('secret_key', $key->value);
|
|
$this->connection->execute($deleteQuery);
|
|
|
|
// Audit Log
|
|
$this->auditLogger->log(
|
|
new VaultAuditEntry(
|
|
key: $key,
|
|
action: VaultAction::DELETE,
|
|
timestamp: new DateTimeImmutable(),
|
|
ipAddress: $this->clientIp,
|
|
userAgent: $this->userAgent
|
|
)
|
|
);
|
|
}
|
|
|
|
public function all(): array
|
|
{
|
|
$query = SqlQuery::create('SELECT secret_key FROM vault_secrets ORDER BY secret_key ASC');
|
|
$result = $this->connection->query($query);
|
|
|
|
$keys = [];
|
|
foreach ($result as $row) {
|
|
$keys[] = $row['secret_key'];
|
|
}
|
|
|
|
return $keys;
|
|
}
|
|
|
|
public function getMetadata(SecretKey $key): VaultMetadata
|
|
{
|
|
$query = SqlQuery::create(
|
|
'SELECT * FROM vault_secrets WHERE secret_key = ?',
|
|
[$key->value]
|
|
);
|
|
$row = $this->connection->queryOne($query);
|
|
|
|
if ($row === null) {
|
|
throw VaultKeyNotFoundException::forKey($key);
|
|
}
|
|
|
|
return new VaultMetadata(
|
|
key: $key,
|
|
createdAt: new DateTimeImmutable($row['created_at']),
|
|
updatedAt: new DateTimeImmutable($row['updated_at']),
|
|
createdBy: $row['created_by'] ?? null,
|
|
updatedBy: $row['updated_by'] ?? null,
|
|
accessCount: (int) $row['access_count'],
|
|
lastAccessedAt: $row['last_accessed_at']
|
|
? new DateTimeImmutable($row['last_accessed_at'])
|
|
: null
|
|
);
|
|
}
|
|
|
|
public function rotateEncryptionKey(string $newKey): int
|
|
{
|
|
if (strlen($newKey) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
|
|
throw new \InvalidArgumentException(
|
|
'New encryption key must be ' . SODIUM_CRYPTO_SECRETBOX_KEYBYTES . ' bytes'
|
|
);
|
|
}
|
|
|
|
$query = SqlQuery::create('SELECT secret_key, encrypted_value, encryption_nonce FROM vault_secrets');
|
|
$allSecrets = $this->connection->query($query);
|
|
|
|
$rotatedCount = 0;
|
|
|
|
foreach ($allSecrets as $row) {
|
|
try {
|
|
// Decrypt mit altem Key
|
|
$decrypted = $this->decrypt(
|
|
base64_decode($row['encrypted_value']),
|
|
base64_decode($row['encryption_nonce'])
|
|
);
|
|
|
|
// Encrypt mit neuem Key
|
|
$newNonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
|
|
$newEncrypted = sodium_crypto_secretbox(
|
|
$decrypted,
|
|
$newNonce,
|
|
$newKey
|
|
);
|
|
|
|
// Update in Database
|
|
$updateQuery = SqlQuery::update(
|
|
'vault_secrets',
|
|
[
|
|
'encrypted_value' => base64_encode($newEncrypted),
|
|
'encryption_nonce' => base64_encode($newNonce),
|
|
'updated_at' => (new DateTimeImmutable())->format('Y-m-d H:i:s'),
|
|
],
|
|
'secret_key = :secret_key'
|
|
)->withParameter('secret_key', $row['secret_key']);
|
|
$this->connection->execute($updateQuery);
|
|
|
|
$rotatedCount++;
|
|
|
|
// Audit Log
|
|
$this->auditLogger->log(
|
|
new VaultAuditEntry(
|
|
key: new SecretKey($row['secret_key']),
|
|
action: VaultAction::ROTATE,
|
|
timestamp: new DateTimeImmutable(),
|
|
ipAddress: $this->clientIp,
|
|
userAgent: $this->userAgent
|
|
)
|
|
);
|
|
} catch (\Throwable $e) {
|
|
// Log Error aber continue mit anderen Secrets
|
|
error_log("Failed to rotate secret '{$row['secret_key']}': {$e->getMessage()}");
|
|
}
|
|
}
|
|
|
|
return $rotatedCount;
|
|
}
|
|
|
|
private function encrypt(string $plaintext, string $nonce): string
|
|
{
|
|
return sodium_crypto_secretbox($plaintext, $nonce, $this->encryptionKey);
|
|
}
|
|
|
|
private function decrypt(string $encrypted, string $nonce): string
|
|
{
|
|
$decrypted = sodium_crypto_secretbox_open(
|
|
$encrypted,
|
|
$nonce,
|
|
$this->encryptionKey
|
|
);
|
|
|
|
if ($decrypted === false) {
|
|
throw VaultException::decryptionFailed('Invalid ciphertext or key');
|
|
}
|
|
|
|
return $decrypted;
|
|
}
|
|
|
|
private function updateAccessMetadata(SecretKey $key): void
|
|
{
|
|
$query = SqlQuery::create(
|
|
'UPDATE vault_secrets
|
|
SET access_count = access_count + 1,
|
|
last_accessed_at = ?
|
|
WHERE secret_key = ?',
|
|
[(new DateTimeImmutable())->format('Y-m-d H:i:s'), $key->value]
|
|
);
|
|
$this->connection->execute($query);
|
|
}
|
|
|
|
/**
|
|
* Generate a new encryption key for Vault
|
|
*/
|
|
public static function generateEncryptionKey(): string
|
|
{
|
|
return sodium_crypto_secretbox_keygen();
|
|
}
|
|
|
|
/**
|
|
* Encode encryption key für .env Storage
|
|
*/
|
|
public static function encodeKey(string $key): string
|
|
{
|
|
return base64_encode($key);
|
|
}
|
|
|
|
/**
|
|
* Decode encryption key von .env
|
|
*/
|
|
public static function decodeKey(string $encodedKey): string
|
|
{
|
|
return base64_decode($encodedKey);
|
|
}
|
|
}
|