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); } }