diff --git a/class-gf-paystack-api.php b/class-gf-paystack-api.php index 42d1f1b..7613037 100644 --- a/class-gf-paystack-api.php +++ b/class-gf-paystack-api.php @@ -11,10 +11,13 @@ class GFPaystackApi protected $secret_key; + protected $webhook_secret; + public function __construct($config) { $this->plugin_name = 'Paystack Addon for Gravity Forms'; $this->secret_key = $config->secret_key ?? ''; + $this->webhook_secret = $config->webhook_secret ?? ''; $this->public_key = $config->public_key ?? ''; } @@ -113,23 +116,74 @@ public function verify_transaction($reference) /** * Validate Webhook Signature * - * @param $input - * @param string|null $signature Defaults to the request's signature header. + * Uses the Paystack-Signature header when a webhook signing secret is set, + * otherwise the X-Paystack-Signature header, which only a secret key (sk_) can verify. + * + * @param $input Raw request body + * @param string|null $signature X-Paystack-Signature. Defaults to the request's header. + * @param string|null $paystack_signature Paystack-Signature. Defaults to the request's header. * @return boolean */ - public function validate_webhook($input, $signature = null) + public function validate_webhook($input, $signature = null, $paystack_signature = null) { + if ($paystack_signature === null) { + $paystack_signature = isset($_SERVER['HTTP_PAYSTACK_SIGNATURE']) ? $_SERVER['HTTP_PAYSTACK_SIGNATURE'] : ''; + } + + if (is_string($this->webhook_secret) && $this->webhook_secret !== '' && is_string($paystack_signature) && $paystack_signature !== '') { + return self::verify_signature($paystack_signature, (string) $input, $this->webhook_secret, time()); + } + if ($signature === null) { $signature = isset($_SERVER['HTTP_X_PAYSTACK_SIGNATURE']) ? $_SERVER['HTTP_X_PAYSTACK_SIGNATURE'] : ''; } - if (!is_string($signature) || $signature === '' || !is_string($this->secret_key) || trim($this->secret_key) === '') { + if (!is_string($signature) || $signature === '' || !is_string($this->secret_key) || strpos($this->secret_key, 'sk_') !== 0) { return false; } return hash_equals(hash_hmac('sha512', (string) $input, $this->secret_key), $signature); } + /** + * Verify a Paystack-Signature header: t=,v1=:[,v1=...] + * + * @param string $header + * @param string $body Raw request body + * @param string $secret Webhook signing secret (whsec_...) + * @param int $now + * @return boolean + */ + public static function verify_signature($header, $body, $secret, $now) + { + $timestamp = ''; + $signatures = array(); + foreach (explode(',', $header) as $part) { + $pair = explode('=', trim($part), 2); + if (count($pair) !== 2) { + continue; + } + if ($pair[0] === 't') { + $timestamp = $pair[1]; + } elseif ($pair[0] === 'v1' && strpos($pair[1], ':') !== false) { + $signatures[] = substr($pair[1], strpos($pair[1], ':') + 1); + } + } + + if (!preg_match('/^\d+$/', $timestamp) || abs($now - (int) $timestamp) > 300) { + return false; + } + + $expected = hash_hmac('sha512', $timestamp . '.' . $body, $secret); + foreach ($signatures as $signature) { + if (hash_equals($expected, $signature)) { + return true; + } + } + + return false; + } + /** * Generates the headers to pass to API request. */ diff --git a/class-gf-paystack.php b/class-gf-paystack.php index 9a74d63..1e92171 100644 --- a/class-gf-paystack.php +++ b/class-gf-paystack.php @@ -326,6 +326,13 @@ public function api_settings_fields() 'class' => 'medium', ), array( + 'name' => 'test_webhook_secret', + 'label' => esc_html__('Test Webhook Signing Secret', 'gravityformspaystack'), + 'type' => 'text', + 'class' => 'medium', + 'description' => esc_html__('Find it in your Paystack Dashboard under Settings > API Keys & Webhooks. Required if you use a restricted key (rk_).', 'gravityformspaystack'), + ), + array( 'name' => 'live_public_key', 'label' => esc_html__('Live Public Key', 'gravityformspaystack'), 'type' => 'text', @@ -337,6 +344,13 @@ public function api_settings_fields() 'type' => 'text', 'class' => 'medium', ), + array( + 'name' => 'live_webhook_secret', + 'label' => esc_html__('Live Webhook Signing Secret', 'gravityformspaystack'), + 'type' => 'text', + 'class' => 'medium', + 'description' => esc_html__('Find it in your Paystack Dashboard under Settings > API Keys & Webhooks. Required if you use a restricted key (rk_).', 'gravityformspaystack'), + ), ); $fields = array_merge($fields, $credentials_fields); @@ -1943,7 +1957,8 @@ public function paystack_api($mode = null, $settings = null) $config = (object) array( 'secret_key' => $this->get_secret_api_key($mode, $settings), - 'public_key' => $this->get_public_api_key($mode, $settings) + 'public_key' => $this->get_public_api_key($mode, $settings), + 'webhook_secret' => trim($this->get_setting("{$mode}_webhook_secret", '', $settings)) ); $this->log_debug(sprintf('%s(): Initializing Paystack API for %s mode.', __METHOD__, $mode)); diff --git a/tests/test-gateway.php b/tests/test-gateway.php index 1e91272..d23527a 100644 --- a/tests/test-gateway.php +++ b/tests/test-gateway.php @@ -70,6 +70,22 @@ gf_paystack_test_check($event_addon->webhook_mode_value() === 'live' && $event_result !== false, 'get_webhook_event case 3'); +$event_addon->settings = array('api_mode' => 'live', 'live_secret_key' => 'rk_live_a', 'live_webhook_secret' => 'whsec_live', 'test_webhook_secret' => 'whsec_test'); +$whsec_t = time(); +$_SERVER['HTTP_X_PAYSTACK_SIGNATURE'] = hash_hmac('sha512', '', 'rk_live_a'); +$event_result = $event_addon->get_webhook_event(); +gf_paystack_test_check($event_result === false, 'get_webhook_event signing secret case 1'); + +$_SERVER['HTTP_PAYSTACK_SIGNATURE'] = "t=$whsec_t,v1=whk_1:" . hash_hmac('sha512', "$whsec_t.", 'whsec_live'); +$event_result = $event_addon->get_webhook_event(); +gf_paystack_test_check($event_addon->webhook_mode_value() === 'live' && $event_result !== false && !is_wp_error($event_result), 'get_webhook_event signing secret case 2'); + +$_SERVER['HTTP_PAYSTACK_SIGNATURE'] = "t=$whsec_t,v1=whk_1:" . hash_hmac('sha512', "$whsec_t.", 'whsec_test'); +$event_result = $event_addon->get_webhook_event(); +gf_paystack_test_check($event_result === false, 'get_webhook_event signing secret case 3'); +unset($_SERVER['HTTP_PAYSTACK_SIGNATURE']); +$event_addon->settings = $event_settings; + foreach (array('foo', array('live')) as $domain) { $seam_body = json_encode(array('event' => 'charge.success', 'data' => array('domain' => $domain))); $_SERVER['HTTP_X_PAYSTACK_SIGNATURE'] = hash_hmac('sha512', $seam_body, 'sk_test_b'); diff --git a/tests/test-webhook.php b/tests/test-webhook.php index 13df754..400ba4f 100644 --- a/tests/test-webhook.php +++ b/tests/test-webhook.php @@ -22,6 +22,38 @@ $blank_secret_api = new GFPaystackApi((object) array('secret_key' => ' ', 'public_key' => 'pk')); gf_paystack_test_check($blank_secret_api->validate_webhook($webhook_body, hash_hmac('sha512', $webhook_body, ' ')) === false, 'validate_webhook case 8'); +$whsec = 'whsec_' . str_repeat('a', 64); +$vector_body = '{"event":"charge.success","data":{"reference":"ref_123"}}'; +$vector_hex = 'f44df3eae202c0d271567505d775c4cbbd91b4b67f95f4d2a9ad7073e6766775fce92507aa592e249072db62799e79e515289259e8d72693a3e3dbd2e264741c'; +$vector_header = 't=1750000000,v1=whk_ab12cd34:' . $vector_hex; + +gf_paystack_test_check(GFPaystackApi::verify_signature($vector_header, $vector_body, $whsec, 1750000000) === true, 'verify_signature case 1'); +gf_paystack_test_check(GFPaystackApi::verify_signature($vector_header, $vector_body, $whsec, 1750000300) === true, 'verify_signature case 2'); +gf_paystack_test_check(GFPaystackApi::verify_signature($vector_header, $vector_body, $whsec, 1750000301) === false, 'verify_signature case 3'); +gf_paystack_test_check(GFPaystackApi::verify_signature($vector_header, $vector_body, $whsec, 1749999699) === false, 'verify_signature case 4'); +gf_paystack_test_check(GFPaystackApi::verify_signature($vector_header, $vector_body . ' ', $whsec, 1750000000) === false, 'verify_signature case 5'); +gf_paystack_test_check(GFPaystackApi::verify_signature($vector_header, $vector_body, 'whsec_' . str_repeat('b', 64), 1750000000) === false, 'verify_signature case 6'); +gf_paystack_test_check(GFPaystackApi::verify_signature('t=1750000000,v1=whk_old:00,v1=whk_ab12cd34:' . $vector_hex, $vector_body, $whsec, 1750000000) === true, 'verify_signature case 7'); +gf_paystack_test_check(GFPaystackApi::verify_signature('v1=whk_ab12cd34:' . $vector_hex, $vector_body, $whsec, 1750000000) === false, 'verify_signature case 8'); +gf_paystack_test_check(GFPaystackApi::verify_signature('t=1750000000,v1=' . $vector_hex, $vector_body, $whsec, 1750000000) === false, 'verify_signature case 9'); + +$now = time(); +$fresh_header = "t=$now,v1=whk_ab12cd34:" . hash_hmac('sha512', "$now.$webhook_body", $whsec); +$rk_api = new GFPaystackApi((object) array('secret_key' => 'rk_test_restricted', 'public_key' => 'pk', 'webhook_secret' => $whsec)); +$sk_whsec_api = new GFPaystackApi((object) array('secret_key' => 'sk_test_secret', 'public_key' => 'pk', 'webhook_secret' => $whsec)); +$rk_no_whsec_api = new GFPaystackApi((object) array('secret_key' => 'rk_test_restricted', 'public_key' => 'pk')); + +gf_paystack_test_check($rk_api->validate_webhook($webhook_body, '', $fresh_header) === true, 'validate_webhook signing secret case 1'); +gf_paystack_test_check($rk_api->validate_webhook($webhook_body, '', "t=$now,v1=whk_ab12cd34:00") === false, 'validate_webhook signing secret case 2'); +gf_paystack_test_check($sk_whsec_api->validate_webhook($webhook_body, $webhook_sig, "t=$now,v1=whk_ab12cd34:00") === false, 'validate_webhook signing secret case 3'); +gf_paystack_test_check($sk_whsec_api->validate_webhook($webhook_body, $webhook_sig, '') === true, 'validate_webhook signing secret case 4'); +gf_paystack_test_check($rk_api->validate_webhook($webhook_body, hash_hmac('sha512', $webhook_body, 'rk_test_restricted'), '') === false, 'validate_webhook signing secret case 5'); +gf_paystack_test_check($rk_no_whsec_api->validate_webhook($webhook_body, hash_hmac('sha512', $webhook_body, 'rk_test_restricted'), $fresh_header) === false, 'validate_webhook signing secret case 6'); + +$_SERVER['HTTP_PAYSTACK_SIGNATURE'] = $fresh_header; +gf_paystack_test_check($rk_api->validate_webhook($webhook_body) === true, 'validate_webhook signing secret case 7'); +unset($_SERVER['HTTP_PAYSTACK_SIGNATURE']); + gf_paystack_test_check(GFPaystack::resolve_webhook_mode('live') === 'live', 'resolve_webhook_mode case 1'); gf_paystack_test_check(GFPaystack::resolve_webhook_mode('test') === 'test', 'resolve_webhook_mode case 2'); gf_paystack_test_check(GFPaystack::resolve_webhook_mode('LIVE') === null, 'resolve_webhook_mode case 3');