- 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
159 lines
5.5 KiB
PHP
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;
|
|
}
|
|
}
|