Skip to content

Commit cb93736

Browse files
authored
feat: add support for verifying webhooks signatures (#7)
1 parent 03ee30b commit cb93736

5 files changed

Lines changed: 211 additions & 7 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
namespace Convoy\Exceptions;
4+
5+
use Exception;
6+
7+
class WebhookVerificationException extends Exception
8+
{
9+
public function __construct(string $message)
10+
{
11+
parent::__construct($message);
12+
}
13+
}

src/HttpClient/Config.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
use Http\Discovery\Psr17FactoryDiscovery;
66
use Http\Message\Authentication;
7-
use Http\Message\Authentication\BasicAuth;
87
use Http\Message\Authentication\Bearer;
98
use Psr\Http\Message\UriFactoryInterface;
109
use Psr\Http\Message\UriInterface;
@@ -61,7 +60,7 @@ public function getUriFactory(): UriFactoryInterface
6160
public function getUri(): UriInterface
6261
{
6362
$uri = sprintf('%s/projects/%s', $this->config['uri'], $this->config['project_id']);
64-
63+
6564
return $this->getUriFactory()->createUri($uri);
6665
}
6766

src/Webhook.php

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
<?php
2+
3+
namespace Convoy;
4+
5+
use Convoy\Exceptions\WebhookVerificationException;
6+
7+
class Webhook
8+
{
9+
public const DEFAULT_TOLERANCE = 300;
10+
public const DEFAULT_ENCODING = 'hex';
11+
public const DEFAULT_HASH = 'sha256';
12+
13+
public string $secret;
14+
15+
public int $tolerance;
16+
17+
public string $hash;
18+
19+
public string $encoding;
20+
21+
public function __construct($secret, $hash = self::DEFAULT_HASH, $encoding = self::DEFAULT_ENCODING, $tolerance = self::DEFAULT_TOLERANCE)
22+
{
23+
$this->secret = $secret;
24+
25+
$this->hash = $hash;
26+
27+
$this->encoding = $encoding;
28+
29+
$this->tolerance = $tolerance;
30+
}
31+
32+
public function verify(string $payload, string $header): bool
33+
{
34+
$parts = explode(',', $header);
35+
36+
return count($parts) > 1 ? $this->verifyAdvancedSignature($payload, $header) : $this->verifySimpleSignature($payload, $header);
37+
}
38+
39+
private function verifyAdvancedSignature(string $payload, string $header): bool
40+
{
41+
$timestamp = $this->getTimestamp($header);
42+
43+
$signatures = $this->getSignatures($header);
44+
45+
if ($timestamp === -1) {
46+
throw new WebhookVerificationException('Webhook has invalid header');
47+
}
48+
49+
$tolerance = $this->tolerance;
50+
51+
if (($tolerance > 0) && (abs(time()) - $timestamp) > $tolerance) {
52+
throw new WebhookVerificationException('Timestamp has expired');
53+
}
54+
55+
$signedPayload = "{$timestamp},{$payload}";
56+
$expectedSignature = $this->computeSignature($signedPayload);
57+
$signatureFound = false;
58+
59+
foreach ($signatures as $signature) {
60+
if ($this->secureCompare($expectedSignature, $signature)) {
61+
$signatureFound = true;
62+
63+
break;
64+
}
65+
}
66+
67+
if (! $signatureFound) {
68+
throw new WebhookVerificationException('Webhook has no valid signature');
69+
}
70+
71+
return true;
72+
}
73+
74+
private function getTimestamp(string $header): int
75+
{
76+
$items = explode(',', $header);
77+
78+
foreach ($items as $item) {
79+
$itemParts = explode('=', $item, 2);
80+
if ('t' === $itemParts[0]) {
81+
if (! is_numeric($itemParts[1])) {
82+
return -1;
83+
}
84+
85+
return (int) ($itemParts[1]);
86+
}
87+
}
88+
89+
return -1;
90+
}
91+
92+
private function getSignatures(string $header): array
93+
{
94+
$items = explode(',', $header);
95+
96+
$signatures = [];
97+
98+
foreach ($items as $item) {
99+
$itemParts = explode('=', $item, 2);
100+
if (str_contains(trim($itemParts[0]), "v")) {
101+
$signatures[] = $itemParts[1];
102+
}
103+
}
104+
105+
return $signatures;
106+
}
107+
108+
private function verifySimpleSignature(string $payload, string $header): bool
109+
{
110+
$signature = $this->computeSignature($payload, $header);
111+
112+
return $this->secureCompare($this->computeSignature($payload), $header);
113+
}
114+
115+
private function computeSignature(string $payload)
116+
{
117+
switch ($this->encoding) {
118+
case 'hex':
119+
return hash_hmac($this->hash, $payload, $this->secret);
120+
case 'base64':
121+
return base64_encode(hash_hmac($this->hash, $payload, $this->secret, true));
122+
default:
123+
throw new WebhookVerificationException('Invalid Encoding');
124+
}
125+
}
126+
127+
private function secureCompare(string $a, string $b): bool
128+
{
129+
return hash_equals($a, $b);
130+
}
131+
}

tests/ExampleTest.php

Lines changed: 0 additions & 5 deletions
This file was deleted.

tests/WebhookTest.php

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php
2+
3+
use Convoy\Exceptions\WebhookVerificationException;
4+
use Convoy\Webhook;
5+
6+
test('no valid signature', function () {
7+
$webhook = new Webhook('random');
8+
9+
$payload = '{"email":"test@gmail.com"}';
10+
11+
expect($webhook->verify($payload, ''))->toBeFalse();
12+
});
13+
14+
test('verify simple hex signature', function () {
15+
$webhook = new Webhook('8IX9njirDG', 'sha512');
16+
17+
$payload = '{"email":"test@gmail.com","first_name":"test","last_name":"test"}';
18+
$header = '666060cbe1348bbc7ec98f4e93dda8eaaf11bbf283d6a2dd56e841b2ef12fcd465c846903f709942473e1442604798186746f04848702c44a773f80672de7b21';
19+
20+
expect($webhook->verify($payload, $header))->toBeTrue();
21+
});
22+
23+
test('verify simple base64 signature', function () {
24+
$webhook = new Webhook('8IX9njirDG', 'sha512', 'base64');
25+
26+
$payload = '{"email":"test@gmail.com","first_name":"test","last_name":"test"}';
27+
$header = 'ZmBgy+E0i7x+yY9Ok92o6q8Ru/KD1qLdVuhBsu8S/NRlyEaQP3CZQkc+FEJgR5gYZ0bwSEhwLESnc/gGct57IQ==';
28+
29+
expect($webhook->verify($payload, $header))->toBeTrue();
30+
});
31+
32+
test('invalid signature header', function () {
33+
$webhook = new Webhook('8IX9njirDG', 'sha512', 'base64');
34+
35+
$payload = '{"email":"test@gmail.com","first_name":"test","last_name":"test"}';
36+
$header = 'd33C9sJXVO4CnE1hisHHQzUf0inr5KWJH7T8+zvgATTWEgAq5vErZR/xihDXqtok5ubv77xGP/RE++NphZnWLg==';
37+
38+
expect($webhook->verify($payload, $header))->toBeFalse();
39+
});
40+
41+
test('verify advanced hex signature', function () {
42+
$webhook = new Webhook('Convoy');
43+
44+
$payload = '{"email":"test@gmail.com"}';
45+
$header = 't=2048976161,v1=c6c39e1bd410fc1dc4db90e97039f006d088c950a275296767595d330195088f,v1=6594ee0713f1cc1f54c3f713d06a60718cd10949c7684412f159034d49621e07';
46+
47+
expect($webhook->verify($payload, $header))->toBeTrue();
48+
});
49+
50+
test('verify advanced base64 signature', function () {
51+
$webhook = new Webhook('8IX9njirDG', 'sha256', 'base64');
52+
53+
$payload = '{"email":"test@gmail.com"}';
54+
$header = 't=2048976161,v1=afdb90313acfa15a3fc425755ae651a204947710315bb2a90bccaa87fce88998,v1=fLBDCBUiX5iIs0L5zfNq45h23EkX1HAMpFF+2lHrnes=';
55+
56+
expect($webhook->verify($payload, $header))->toBeTrue();
57+
});
58+
59+
it('invalid timestamp header', function () {
60+
$webhook = new Webhook('8IX9njirDG', 'sha256', 'base64');
61+
62+
$payload = '{"email":"test@gmail.com"}';
63+
$header = 't=2202-1-1,v1=U5yBiZmFYHiom0A5hEnfLPCoQzndno4ocR45W/zkO+w=';
64+
65+
$webhook->verify($payload, $header);
66+
})->throws(WebhookVerificationException::class, 'Webhook has invalid header');

0 commit comments

Comments
 (0)