Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 58 additions & 4 deletions class-gf-paystack-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? '';
}

Expand Down Expand Up @@ -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=<unix seconds>,v1=<key id>:<hex>[,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.
*/
Expand Down
17 changes: 16 additions & 1 deletion class-gf-paystack.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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);
Expand Down Expand Up @@ -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));
Expand Down
16 changes: 16 additions & 0 deletions tests/test-gateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
32 changes: 32 additions & 0 deletions tests/test-webhook.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down