docs: consolidate documentation into organized structure

- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
This commit is contained in:
2025-10-05 11:05:04 +02:00
parent 887847dde6
commit 5050c7d73a
36686 changed files with 196456 additions and 12398919 deletions

View File

@@ -0,0 +1,348 @@
<?php
declare(strict_types=1);
namespace App\Framework\Vault\Console;
use App\Framework\Attributes\ConsoleCommand;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutput;
use App\Framework\Console\ExitCode;
use App\Framework\Vault\DatabaseVault;
use App\Framework\Vault\Exceptions\VaultKeyNotFoundException;
use App\Framework\Vault\Vault;
use App\Framework\Vault\VaultAuditLogger;
use App\Framework\Vault\ValueObjects\SecretKey;
use App\Framework\Vault\ValueObjects\SecretValue;
/**
* CLI Commands für Vault Management
*/
final readonly class VaultCommands
{
public function __construct(
private ?Vault $vault = null,
private ?VaultAuditLogger $auditLogger = null
) {
}
#[ConsoleCommand('vault:generate-key', 'Generate a new Vault encryption key')]
public function generateKey(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
$output->writeLine('🔐 Generating Vault encryption key...', ConsoleColor::CYAN);
$output->newLine();
try {
$key = DatabaseVault::generateEncryptionKey();
$encodedKey = DatabaseVault::encodeKey($key);
$output->writeSuccess('✅ Encryption key generated successfully!');
$output->newLine();
$output->writeLine('VAULT_ENCRYPTION_KEY=' . $encodedKey, ConsoleColor::YELLOW);
$output->newLine();
$output->writeWarning('🚨 SECURITY WARNINGS:');
$output->writeLine('• Store this key securely - it cannot be recovered if lost');
$output->writeLine('• Add this to your .env file (never commit to version control)');
$output->writeLine('• Use different keys for different environments');
$output->newLine();
$output->writeInfo('📋 Next steps:');
$output->writeLine('1. Add the key to your .env file');
$output->writeLine('2. Run migrations: php console.php db:migrate');
$output->writeLine('3. Start using vault: php console.php vault:set my-secret "secret-value"');
return ExitCode::SUCCESS;
} catch (\Throwable $e) {
$output->writeError('❌ Failed to generate encryption key: ' . $e->getMessage());
return ExitCode::GENERAL_ERROR;
}
}
#[ConsoleCommand('vault:set', 'Store a secret in the vault')]
public function set(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
if ($this->vault === null) {
$output->writeError('❌ Vault not available. Make sure VAULT_ENCRYPTION_KEY is set in .env');
return ExitCode::CONFIG_ERROR;
}
$keyArg = $input->getArgument(0);
$valueArg = $input->getArgument(1);
if (!$keyArg) {
$output->writeError('❌ Usage: vault:set <key> <value>');
return ExitCode::INVALID_INPUT;
}
if (!$valueArg) {
$valueArg = $output->askPassword('Enter secret value:');
}
if (empty($valueArg)) {
$output->writeError('❌ No value provided');
return ExitCode::INVALID_INPUT;
}
try {
$key = SecretKey::from($keyArg);
$value = new SecretValue($valueArg);
$this->vault->set($key, $value);
$output->writeSuccess("✅ Secret '{$keyArg}' stored successfully in vault");
return ExitCode::SUCCESS;
} catch (\Throwable $e) {
$output->writeError('❌ Failed to store secret: ' . $e->getMessage());
return ExitCode::SOFTWARE_ERROR;
}
}
#[ConsoleCommand('vault:get', 'Retrieve a secret from the vault')]
public function get(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
if ($this->vault === null) {
$output->writeError('❌ Vault not available. Make sure VAULT_ENCRYPTION_KEY is set in .env');
return ExitCode::CONFIG_ERROR;
}
$keyArg = $input->getArgument(0);
if (!$keyArg) {
$output->writeError('❌ Usage: vault:get <key>');
return ExitCode::INVALID_INPUT;
}
try {
$key = SecretKey::from($keyArg);
$value = $this->vault->get($key);
$output->writeSuccess("✅ Secret '{$keyArg}' retrieved:");
$output->newLine();
$output->writeLine($value->reveal(), ConsoleColor::YELLOW);
$output->newLine();
$output->writeWarning('🔒 This value should be kept secure!');
return ExitCode::SUCCESS;
} catch (VaultKeyNotFoundException $e) {
$output->writeError("❌ Secret '{$keyArg}' not found in vault");
return ExitCode::ENTITY_NOT_FOUND;
} catch (\Throwable $e) {
$output->writeError('❌ Failed to retrieve secret: ' . $e->getMessage());
return ExitCode::SOFTWARE_ERROR;
}
}
#[ConsoleCommand('vault:delete', 'Delete a secret from the vault')]
public function delete(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
if ($this->vault === null) {
$output->writeError('❌ Vault not available. Make sure VAULT_ENCRYPTION_KEY is set in .env');
return ExitCode::CONFIG_ERROR;
}
$keyArg = $input->getArgument(0);
if (!$keyArg) {
$output->writeError('❌ Usage: vault:delete <key>');
return ExitCode::INVALID_INPUT;
}
if (!$output->confirm("⚠️ Delete secret '{$keyArg}'?", false)) {
$output->writeInfo('Operation cancelled');
return ExitCode::SUCCESS;
}
try {
$key = SecretKey::from($keyArg);
$this->vault->delete($key);
$output->writeSuccess("✅ Secret '{$keyArg}' deleted successfully");
return ExitCode::SUCCESS;
} catch (VaultKeyNotFoundException $e) {
$output->writeError("❌ Secret '{$keyArg}' not found in vault");
return ExitCode::ENTITY_NOT_FOUND;
} catch (\Throwable $e) {
$output->writeError('❌ Failed to delete secret: ' . $e->getMessage());
return ExitCode::SOFTWARE_ERROR;
}
}
#[ConsoleCommand('vault:list', 'List all secrets in the vault')]
public function listSecrets(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
if ($this->vault === null) {
$output->writeError('❌ Vault not available. Make sure VAULT_ENCRYPTION_KEY is set in .env');
return ExitCode::CONFIG_ERROR;
}
try {
$keys = $this->vault->all();
if (empty($keys)) {
$output->writeInfo(' No secrets found in vault');
return ExitCode::SUCCESS;
}
$output->writeLine('🔐 Vault Secrets:', ConsoleColor::CYAN);
$output->newLine();
foreach ($keys as $key) {
// Metadata abrufen
try {
$metadata = $this->vault->getMetadata(SecretKey::from($key));
$lastAccessed = $metadata->lastAccessedAt
? $metadata->lastAccessedAt->format('Y-m-d H:i:s')
: 'Never';
$output->writeLine("{$key}", ConsoleColor::WHITE);
$output->writeLine(" Accessed: {$metadata->accessCount} times, Last: {$lastAccessed}", ConsoleColor::GRAY);
} catch (\Throwable) {
$output->writeLine("{$key}", ConsoleColor::WHITE);
}
}
$output->newLine();
$output->writeLine('Total: ' . count($keys) . ' secrets', ConsoleColor::GRAY);
return ExitCode::SUCCESS;
} catch (\Throwable $e) {
$output->writeError('❌ Failed to list secrets: ' . $e->getMessage());
return ExitCode::SOFTWARE_ERROR;
}
}
#[ConsoleCommand('vault:audit', 'Show vault audit log')]
public function audit(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
if ($this->auditLogger === null) {
$output->writeError('❌ Audit logger not available');
return ExitCode::CONFIG_ERROR;
}
$limit = (int) ($input->getArgument(0) ?? 50);
$secretKey = $input->getArgument(1);
try {
$entries = $secretKey
? $this->auditLogger->getAuditLog($secretKey, $limit)
: $this->auditLogger->getRecentAuditLog($limit);
if (empty($entries)) {
$output->writeInfo(' No audit log entries found');
return ExitCode::SUCCESS;
}
$output->writeLine('📊 Vault Audit Log:', ConsoleColor::CYAN);
$output->newLine();
foreach ($entries as $entry) {
$color = $entry->success ? ConsoleColor::GREEN : ConsoleColor::RED;
$status = $entry->success ? '✓' : '✗';
$output->writeLine(
"{$status} [{$entry->timestamp->format('Y-m-d H:i:s')}] {$entry->action->value} - {$entry->key->value}",
$color
);
if ($entry->ipAddress || $entry->userId) {
$details = [];
if ($entry->userId) {
$details[] = "User: {$entry->userId}";
}
if ($entry->ipAddress) {
$details[] = "IP: {$entry->ipAddress->toString()}";
}
$output->writeLine(' ' . implode(', ', $details), ConsoleColor::GRAY);
}
if (!$entry->success && $entry->errorMessage) {
$output->writeLine(" Error: {$entry->errorMessage}", ConsoleColor::RED);
}
}
$output->newLine();
$output->writeLine('Showing ' . count($entries) . ' entries', ConsoleColor::GRAY);
return ExitCode::SUCCESS;
} catch (\Throwable $e) {
$output->writeError('❌ Failed to retrieve audit log: ' . $e->getMessage());
return ExitCode::SOFTWARE_ERROR;
}
}
#[ConsoleCommand('vault:rotate-key', 'Rotate vault encryption key')]
public function rotateKey(ConsoleInput $input, ConsoleOutput $output): ExitCode
{
if ($this->vault === null) {
$output->writeError('❌ Vault not available. Make sure VAULT_ENCRYPTION_KEY is set in .env');
return ExitCode::CONFIG_ERROR;
}
$output->writeWarning('⚠️ KEY ROTATION - CRITICAL OPERATION');
$output->newLine();
$output->writeLine('This will re-encrypt all secrets with a new key.');
$output->writeLine('Make sure you have a backup before proceeding!');
$output->newLine();
if (!$output->confirm('Continue with key rotation?', false)) {
$output->writeInfo('Operation cancelled');
return ExitCode::SUCCESS;
}
try {
// Generate new key
$newKey = DatabaseVault::generateEncryptionKey();
$encodedNewKey = DatabaseVault::encodeKey($newKey);
$output->writeLine('🔄 Rotating encryption key...', ConsoleColor::CYAN);
// Rotate
$count = $this->vault->rotateEncryptionKey($newKey);
$output->newLine();
$output->writeSuccess("✅ Successfully rotated {$count} secrets");
$output->newLine();
$output->writeLine('New encryption key:', ConsoleColor::CYAN);
$output->writeLine('VAULT_ENCRYPTION_KEY=' . $encodedNewKey, ConsoleColor::YELLOW);
$output->newLine();
$output->writeWarning('🚨 IMPORTANT:');
$output->writeLine('• Update VAULT_ENCRYPTION_KEY in your .env file immediately');
$output->writeLine('• Restart your application to use the new key');
$output->writeLine('• Keep the old key safe until rotation is verified');
return ExitCode::SUCCESS;
} catch (\Throwable $e) {
$output->writeError('❌ Key rotation failed: ' . $e->getMessage());
return ExitCode::SOFTWARE_ERROR;
}
}
}

View File

@@ -0,0 +1,333 @@
<?php
declare(strict_types=1);
namespace App\Framework\Vault;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Http\IpAddress;
use App\Framework\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 ?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
{
$row = $this->connection->queryOne(
'SELECT encrypted_value, encryption_nonce FROM vault_secrets WHERE secret_key = ?',
[$key->value]
);
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' => Ulid::generate(),
'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)) {
$this->connection->update(
'vault_secrets',
[
'encrypted_value' => $data['encrypted_value'],
'encryption_nonce' => $data['encryption_nonce'],
'updated_at' => $data['updated_at'],
],
['secret_key' => $key->value]
);
} else {
$this->connection->insert('vault_secrets', $data);
}
// 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
{
$result = $this->connection->queryOne(
'SELECT COUNT(*) as count FROM vault_secrets WHERE secret_key = ?',
[$key->value]
);
return ($result['count'] ?? 0) > 0;
}
public function delete(SecretKey $key): void
{
if (!$this->has($key)) {
throw VaultKeyNotFoundException::forKey($key);
}
$this->connection->delete(
'vault_secrets',
['secret_key' => $key->value]
);
// 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
{
$result = $this->connection->query(
'SELECT secret_key FROM vault_secrets ORDER BY secret_key ASC'
);
return array_map(
fn (array $row) => $row['secret_key'],
$result
);
}
public function getMetadata(SecretKey $key): VaultMetadata
{
$row = $this->connection->queryOne(
'SELECT * FROM vault_secrets WHERE secret_key = ?',
[$key->value]
);
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'],
updatedBy: $row['updated_by'],
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'
);
}
$allSecrets = $this->connection->query(
'SELECT secret_key, encrypted_value, encryption_nonce FROM vault_secrets'
);
$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
$this->connection->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' => $row['secret_key']]
);
$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
{
$this->connection->query(
'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]
);
}
/**
* 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);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Framework\Vault\Exceptions;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
/**
* Base Exception für alle Vault-bezogenen Fehler
*/
class VaultException extends FrameworkException
{
public static function encryptionFailed(string $reason = ''): self
{
return self::create(
ErrorCode::SEC_ENCRYPTION_FAILED,
'Failed to encrypt secret' . ($reason ? ": {$reason}" : '')
);
}
public static function decryptionFailed(string $reason = ''): self
{
return self::create(
ErrorCode::SEC_DECRYPTION_FAILED,
'Failed to decrypt secret' . ($reason ? ": {$reason}" : '')
);
}
public static function storageFailed(string $operation, string $reason = ''): self
{
return self::create(
ErrorCode::DB_QUERY_FAILED,
"Vault storage operation '{$operation}' failed" . ($reason ? ": {$reason}" : '')
);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Framework\Vault\Exceptions;
use App\Framework\Exception\ErrorCode;
use App\Framework\Vault\ValueObjects\SecretKey;
/**
* Exception wenn Secret nicht im Vault gefunden wird
*/
final class VaultKeyNotFoundException extends VaultException
{
public static function forKey(SecretKey $key): self
{
return self::create(
ErrorCode::ENTITY_NOT_FOUND,
"Secret key '{$key->value}' not found in vault"
)->withData(['secret_key' => $key->value]);
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Framework\Vault\ValueObjects;
use InvalidArgumentException;
/**
* Secret Key - Typsicherer Identifier für Vault Secrets
*
* Beispiele:
* - database.password
* - api.stripe.secret_key
* - oauth.spotify.client_secret
*/
final readonly class SecretKey
{
public function __construct(
public string $value
) {
if (empty($value)) {
throw new InvalidArgumentException('Secret key cannot be empty');
}
if (strlen($value) > 255) {
throw new InvalidArgumentException('Secret key too long (max 255 chars)');
}
// Nur alphanumerisch, dots, underscores, hyphens
if (!preg_match('/^[a-zA-Z0-9._-]+$/', $value)) {
throw new InvalidArgumentException(
'Secret key can only contain letters, numbers, dots, underscores and hyphens'
);
}
}
public static function from(string $key): self
{
return new self($key);
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Framework\Vault\ValueObjects;
/**
* Secret Value - Niemals geloggt, niemals gecacht, sensible Daten
*
* Implementiert Safeguards gegen accidental exposure:
* - __toString() gibt [SECRET] zurück
* - __debugInfo() redacted
* - reveal() muss explizit aufgerufen werden
*/
final readonly class SecretValue
{
public function __construct(
private string $value
) {
}
/**
* Explizit Secret enthüllen - nur wenn wirklich benötigt!
*/
public function reveal(): string
{
return $this->value;
}
/**
* Länge des Secrets (für Validierung ohne Exposure)
*/
public function length(): int
{
return strlen($this->value);
}
/**
* Secret ist leer?
*/
public function isEmpty(): bool
{
return empty($this->value);
}
/**
* Verhindert accidental Logging
*/
public function __toString(): string
{
return '[SECRET]';
}
/**
* Verhindert var_dump() Exposure
*/
public function __debugInfo(): array
{
return [
'value' => '[REDACTED]',
'length' => $this->length(),
];
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Framework\Vault\ValueObjects;
enum VaultAction: string
{
case READ = 'read';
case WRITE = 'write';
case DELETE = 'delete';
case ROTATE = 'rotate';
case EXPORT = 'export';
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Framework\Vault\ValueObjects;
use App\Framework\Http\IpAddress;
use App\Framework\UserAgent\UserAgent;
use DateTimeImmutable;
/**
* Audit Log Entry für Vault Operations
*/
final readonly class VaultAuditEntry
{
public function __construct(
public SecretKey $key,
public VaultAction $action,
public DateTimeImmutable $timestamp,
public ?string $userId = null,
public ?IpAddress $ipAddress = null,
public ?UserAgent $userAgent = null,
public bool $success = true,
public ?string $errorMessage = null
) {
}
public static function forRead(
SecretKey $key,
?string $userId = null,
?IpAddress $ipAddress = null,
?UserAgent $userAgent = null
): self {
return new self(
key: $key,
action: VaultAction::READ,
timestamp: new DateTimeImmutable(),
userId: $userId,
ipAddress: $ipAddress,
userAgent: $userAgent
);
}
public static function forWrite(
SecretKey $key,
?string $userId = null,
?IpAddress $ipAddress = null,
?UserAgent $userAgent = null
): self {
return new self(
key: $key,
action: VaultAction::WRITE,
timestamp: new DateTimeImmutable(),
userId: $userId,
ipAddress: $ipAddress,
userAgent: $userAgent
);
}
public function toArray(): array
{
return [
'secret_key' => $this->key->value,
'action' => $this->action->value,
'timestamp' => $this->timestamp->format('Y-m-d H:i:s'),
'user_id' => $this->userId,
'ip_address' => $this->ipAddress?->toString(),
'user_agent' => $this->userAgent?->value,
'success' => $this->success,
'error_message' => $this->errorMessage,
];
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Framework\Vault\ValueObjects;
use DateTimeImmutable;
/**
* Metadata für Vault Secrets (Audit Trail)
*/
final readonly class VaultMetadata
{
public function __construct(
public SecretKey $key,
public DateTimeImmutable $createdAt,
public DateTimeImmutable $updatedAt,
public ?string $createdBy = null,
public ?string $updatedBy = null,
public int $accessCount = 0,
public ?DateTimeImmutable $lastAccessedAt = null
) {
}
public function withAccess(DateTimeImmutable $accessedAt): self
{
return new self(
key: $this->key,
createdAt: $this->createdAt,
updatedAt: $this->updatedAt,
createdBy: $this->createdBy,
updatedBy: $this->updatedBy,
accessCount: $this->accessCount + 1,
lastAccessedAt: $accessedAt
);
}
public function withUpdate(DateTimeImmutable $updatedAt, ?string $updatedBy = null): self
{
return new self(
key: $this->key,
createdAt: $this->createdAt,
updatedAt: $updatedAt,
createdBy: $this->createdBy,
updatedBy: $updatedBy ?? $this->updatedBy,
accessCount: $this->accessCount,
lastAccessedAt: $this->lastAccessedAt
);
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Framework\Vault;
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\VaultMetadata;
/**
* Vault Interface - Secure Secrets Storage
*
* All implementations MUST:
* - Encrypt secrets at rest
* - Audit all access (read/write/delete)
* - Provide transactional safety
* - Support key rotation
*/
interface Vault
{
/**
* Get secret value
*
* @throws VaultKeyNotFoundException wenn Secret nicht existiert
* @throws VaultException bei Decryption oder anderen Fehlern
*/
public function get(SecretKey $key): SecretValue;
/**
* Set or update secret value
*
* @throws VaultException bei Encryption oder Storage Fehlern
*/
public function set(SecretKey $key, SecretValue $value): void;
/**
* Check if secret exists
*/
public function has(SecretKey $key): bool;
/**
* Delete secret
*
* @throws VaultKeyNotFoundException wenn Secret nicht existiert
*/
public function delete(SecretKey $key): void;
/**
* List all secret keys (nicht die Values!)
*
* @return array<string>
*/
public function all(): array;
/**
* Get metadata for a secret
*
* @throws VaultKeyNotFoundException
*/
public function getMetadata(SecretKey $key): VaultMetadata;
/**
* Rotate encryption key
*
* Re-encrypts all secrets with new key.
* CRITICAL: This is a potentially long-running operation!
*
* @return int Number of secrets rotated
*/
public function rotateEncryptionKey(string $newKey): int;
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Framework\Vault;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Vault\ValueObjects\VaultAction;
use App\Framework\Vault\ValueObjects\VaultAuditEntry;
use App\Framework\Vault\ValueObjects\SecretKey;
use App\Framework\Http\IpAddress;
use App\Framework\UserAgent\UserAgent;
use DateTimeImmutable;
/**
* Audit Logger für Vault Operations
*
* Schreibt alle Vault-Zugriffe in vault_audit_log Tabelle
*/
final readonly class VaultAuditLogger
{
public function __construct(
private ConnectionInterface $connection
) {
}
public function log(VaultAuditEntry $entry): void
{
try {
$this->connection->insert(
'vault_audit_log',
$entry->toArray()
);
} catch (\Throwable $e) {
// Audit Logging sollte niemals Operations blockieren
// Aber wir loggen den Fehler für Monitoring
error_log("Failed to write vault audit log: {$e->getMessage()}");
}
}
/**
* Audit Log für einen Secret Key abrufen
*
* @return array<VaultAuditEntry>
*/
public function getAuditLog(string $secretKey, int $limit = 100): array
{
$result = $this->connection->query(
'SELECT * FROM vault_audit_log
WHERE secret_key = ?
ORDER BY timestamp DESC
LIMIT ?',
[$secretKey, $limit]
);
$entries = [];
foreach ($result as $row) {
$entries[] = $this->hydrateEntry($row);
}
return $entries;
}
/**
* Gesamten Audit Log abrufen (mit Pagination)
*
* @return array<VaultAuditEntry>
*/
public function getRecentAuditLog(int $limit = 100, int $offset = 0): array
{
$result = $this->connection->query(
'SELECT * FROM vault_audit_log
ORDER BY timestamp DESC
LIMIT ? OFFSET ?',
[$limit, $offset]
);
$entries = [];
foreach ($result as $row) {
$entries[] = $this->hydrateEntry($row);
}
return $entries;
}
private function hydrateEntry(array $row): VaultAuditEntry
{
return new VaultAuditEntry(
key: new SecretKey($row['secret_key']),
action: VaultAction::from($row['action']),
timestamp: new DateTimeImmutable($row['timestamp']),
userId: $row['user_id'],
ipAddress: $row['ip_address'] ? IpAddress::fromString($row['ip_address']) : null,
userAgent: $row['user_agent'] ? new UserAgent($row['user_agent']) : null,
success: (bool) $row['success'],
errorMessage: $row['error_message']
);
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Framework\Vault;
use App\Framework\Attributes\Initializer;
use App\Framework\Config\EnvKey;
use App\Framework\Config\Environment;
use App\Framework\Database\ConnectionInterface;
use App\Framework\DI\Container;
use App\Framework\Http\ServerEnvironment;
use RuntimeException;
/**
* Vault Initializer für DI Container
*/
final readonly class VaultInitializer
{
public function __construct(
private Environment $environment,
private ConnectionInterface $connection,
private ServerEnvironment $serverEnvironment
) {
}
#[Initializer]
public function __invoke(Container $container): Vault
{
// Encryption Key aus Environment
$encodedKey = $this->environment->get(EnvKey::VAULT_ENCRYPTION_KEY);
if ($encodedKey === null) {
throw new RuntimeException(
'VAULT_ENCRYPTION_KEY not set in environment. ' .
'Generate one with: php console.php vault:generate-key'
);
}
// Decode base64-encoded key
$encryptionKey = DatabaseVault::decodeKey($encodedKey);
// Audit Logger
$auditLogger = new VaultAuditLogger($this->connection);
// Client IP und User Agent für Audit Logging
$clientIp = $this->serverEnvironment->getClientIp();
$userAgent = $this->serverEnvironment->getUserAgent();
// DatabaseVault instance
return new DatabaseVault(
connection: $this->connection,
encryptionKey: $encryptionKey,
auditLogger: $auditLogger,
clientIp: $clientIp,
userAgent: $userAgent
);
}
}