/payments/secrets.key (chmod 600, nur der Site-User) * Ein DB-Dump ohne die Datei ist damit wertlos, und die Datei ohne DB auch. * * Im Backend werden Secrets nie angezeigt, nur "hinterlegt seit ..." und ein Feld * zum Ueberschreiben. */ final class Secrets { private static ?string $key = null; public static function keyPath(): string { $docroot = $_SERVER['DOCUMENT_ROOT'] ?? ''; if ($docroot === '' || !\is_dir($docroot)) { $docroot = \dirname(__DIR__, 2); } return \dirname(\rtrim($docroot, '/')) . '/payments/secrets.key'; } /** Legt den Schluessel an, falls er fehlt (nur per CLI/Installation gedacht). */ public static function ensureKey(): string { $path = self::keyPath(); if (\is_file($path)) { return $path; } $dir = \dirname($path); if (!\is_dir($dir) && !@\mkdir($dir, 0700, true)) { throw new \RuntimeException('Secrets: Verzeichnis nicht anlegbar: ' . $dir); } $key = \sodium_crypto_secretbox_keygen(); if (@\file_put_contents($path, \sodium_bin2hex($key), LOCK_EX) === false) { throw new \RuntimeException('Secrets: Schluessel nicht schreibbar: ' . $path); } @\chmod($path, 0600); return $path; } private static function key(): string { if (self::$key !== null) { return self::$key; } $path = self::keyPath(); if (!\is_readable($path)) { throw new \RuntimeException('Secrets: Schluesseldatei fehlt oder nicht lesbar: ' . $path); } $hex = \trim((string) \file_get_contents($path)); $key = \sodium_hex2bin($hex); if (\strlen($key) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) { throw new \RuntimeException('Secrets: Schluesseldatei hat falsche Laenge.'); } return self::$key = $key; } public static function keyAvailable(): bool { return \is_readable(self::keyPath()); } public static function encrypt(string $plain): string { $nonce = \random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); $box = \sodium_crypto_secretbox($plain, $nonce, self::key()); return 'v1:' . \sodium_bin2base64($nonce . $box, SODIUM_BASE64_VARIANT_ORIGINAL); } public static function decrypt(string $stored): string { if (\strncmp($stored, 'v1:', 3) !== 0) { throw new \RuntimeException('Secrets: unbekanntes Format.'); } $raw = \sodium_base642bin(\substr($stored, 3), SODIUM_BASE64_VARIANT_ORIGINAL); $nonce = \substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); $box = \substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); $plain = \sodium_crypto_secretbox_open($box, $nonce, self::key()); if ($plain === false) { throw new \RuntimeException('Secrets: Entschluesselung fehlgeschlagen (falscher Schluessel?).'); } return $plain; } // ------------------------------------------------------------------ Ablage /** @return string|null Klartext oder null, wenn nicht hinterlegt */ public static function get(\mysqli $db, string $provider, string $name): ?string { $stmt = $db->prepare('SELECT value_enc FROM payment_provider_secrets WHERE provider=? AND name=?'); if (!$stmt) { return null; } $stmt->bind_param('ss', $provider, $name); $stmt->execute(); $row = $stmt->get_result()->fetch_assoc(); $stmt->close(); if (!$row || $row['value_enc'] === '') { return null; } return self::decrypt((string) $row['value_enc']); } public static function set(\mysqli $db, string $provider, string $name, string $plain): void { $enc = self::encrypt($plain); $stmt = $db->prepare( 'INSERT INTO payment_provider_secrets (provider, name, value_enc, updated_at) VALUES (?, ?, ?, NOW()) ON DUPLICATE KEY UPDATE value_enc = VALUES(value_enc), updated_at = NOW()' ); if (!$stmt) { throw new \RuntimeException('Secrets: ' . $db->error); } $stmt->bind_param('sss', $provider, $name, $enc); $stmt->execute(); $stmt->close(); } public static function delete(\mysqli $db, string $provider, string $name): void { $stmt = $db->prepare('DELETE FROM payment_provider_secrets WHERE provider=? AND name=?'); if ($stmt) { $stmt->bind_param('ss', $provider, $name); $stmt->execute(); $stmt->close(); } } /** * Fuers Backend: welche Secrets liegen vor und seit wann (ohne Klartext). * @return array name => updated_at */ public static function status(\mysqli $db, string $provider): array { $out = []; $stmt = $db->prepare('SELECT name, updated_at FROM payment_provider_secrets WHERE provider=? AND value_enc<>""'); if (!$stmt) { return $out; } $stmt->bind_param('s', $provider); $stmt->execute(); $res = $stmt->get_result(); while ($row = $res->fetch_assoc()) { $out[(string) $row['name']] = (string) $row['updated_at']; } $stmt->close(); return $out; } }