fix(paypal_v2): Webhook-Signatur lokal pruefen (Zertifikat, CRC32, SHA256withRSA); API-Verify nur als Rueckfall

This commit is contained in:
Thomas Bartelt
2026-09-13 21:32:17 +02:00
parent 4248f8c148
commit 4ce09ac1b6
2 changed files with 64 additions and 2 deletions
+59 -2
View File
@@ -282,15 +282,72 @@ final class Provider extends AbstractProvider
return 'Header fehlt: ' . $k;
}
}
// 1. Lokale Pruefung (PayPal "self-verification"): Signatur ueber
// transmission_id|transmission_time|webhook_id|crc32(body), SHA256withRSA,
// mit dem Zertifikat von cert_url (nur *.paypal.com, https).
// Am 13.09.2026 in der Sandbox belegt: lokal GUELTIG, waehrend der
// API-Endpunkt verify-webhook-signature dieselbe Zustellung mit FAILURE beantwortet.
$local = $this->verifyLocally($fields, $rawBody);
if ($local === 'SUCCESS') {
return 'SUCCESS';
}
// 2. Rueckfall: PayPal fragen (roher Body eingebettet).
$json = \json_encode($fields, JSON_UNESCAPED_SLASHES);
$json = \substr($json, 0, -1) . ',"webhook_event":' . \trim($rawBody) . '}';
[$status, $body] = $this->client()->request('POST', '/v1/notifications/verify-webhook-signature', $json);
$vs = (string) ($body['verification_status'] ?? '');
if ($status === 200 && $vs === 'SUCCESS') {
return 'SUCCESS';
}
return 'HTTP ' . $status . ' ' . ($vs !== '' ? $vs : \json_encode($body));
return 'lokal: ' . $local . '; API: HTTP ' . $status . ' ' . ($vs !== '' ? $vs : \json_encode($body));
}
/** @param array<string,string> $f */
private function verifyLocally(array $f, string $rawBody): string
{
if (\strtoupper($f['auth_algo']) !== 'SHA256WITHRSA') {
return 'Algorithmus ' . $f['auth_algo'];
}
$u = \parse_url($f['cert_url']);
$host = \strtolower((string) ($u['host'] ?? ''));
if (($u['scheme'] ?? '') !== 'https' || !($host === 'paypal.com' || \str_ends_with($host, '.paypal.com'))) {
return 'cert_url nicht von paypal.com';
}
$cert = $this->certificate($f['cert_url']);
if ($cert === '') {
return 'Zertifikat nicht ladbar';
}
$pub = \openssl_pkey_get_public($cert);
if ($pub === false) {
return 'Zertifikat unlesbar';
}
$msg = $f['transmission_id'] . '|' . $f['transmission_time'] . '|' . $f['webhook_id'] . '|' . \sprintf('%u', \crc32($rawBody));
$sig = \base64_decode($f['transmission_sig'], true);
if ($sig === false) {
return 'Signatur kein Base64';
}
return \openssl_verify($msg, $sig, $pub, OPENSSL_ALGO_SHA256) === 1 ? 'SUCCESS' : 'Signatur passt nicht';
}
/** Zertifikat von PayPal, 24 h neben dem Secrets-Schluessel zwischengespeichert. */
private function certificate(string $url): string
{
$file = \dirname(Secrets::keyPath()) . '/paypal_cert_' . \md5($url) . '.pem';
if (\is_file($file) && \filemtime($file) > \time() - 86400) {
return (string) \file_get_contents($file);
}
$ch = \curl_init($url);
\curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_TIMEOUT => 10]);
$pem = (string) \curl_exec($ch);
$code = (int) \curl_getinfo($ch, CURLINFO_HTTP_CODE);
\curl_close($ch);
if ($code !== 200 || !\str_contains($pem, 'BEGIN CERTIFICATE')) {
return \is_file($file) ? (string) \file_get_contents($file) : '';
}
@\file_put_contents($file, $pem, LOCK_EX);
@\chmod($file, 0600);
return $pem;
}
// -------------------------------------------------------------- reconcile
+5
View File
@@ -26,6 +26,11 @@ foreach ($_SERVER as $key => $value) {
}
}
// Letzte Zustellung roh ablegen (logs/ ist per .htaccess gesperrt) -- fuer die Fehlersuche
// an der Signaturpruefung. Enthaelt keine Zugangsdaten.
@\file_put_contents(__DIR__ . '/logs/webhook_last_' . \preg_replace('/[^a-z0-9_]/', '', $module) . '.json',
\json_encode(['time' => \date('c'), 'headers' => $headers, 'body' => $rawBody], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
try {
$status = $provider->onWebhook($headers, $rawBody);
} catch (\Throwable $e) {