Files
shop-old/core/payments/Secrets.php
T
Thomas Bartelt 397fb5944a feat(payments): Anbieter-Gerüst core/payments mit PayPal Orders v2
- ProviderInterface/AbstractProvider: Vorgang mit Uhrzeit+Session (payment_transactions),
  Mail und Warenkorb erst nach Zahlung, idempotentes markPaid, Betragsabgleich,
  shop_event + PG payment_log
- Secrets verschluesselt (libsodium) in MySQL, Schluessel ausserhalb Docroot
- Cashdesk: ?payment_return/?payment_cancel, Pending-Riegel, zahlung_erst_spaeter()
- Backend Zahlart-Editor: Einstellungen speichern (war ohne Empfaenger), Secret-Felder
- payment_webhook.php (Standalone), cli.php (init-key, secret, reconcile, status)
- PayPal-v2-Blaupause vom Juli entfernt
2026-09-13 19:51:52 +02:00

159 lines
5.5 KiB
PHP

<?php
declare(strict_types=1);
namespace Payments;
/**
* Verschluesselte Zugangsdaten der Zahlungsanbieter.
*
* Werte liegen in MySQL `payment_provider_secrets` (libsodium secretbox), der
* Schluessel liegt als Datei AUSSERHALB des Docroots:
* <vhost>/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<string,string> 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;
}
}