Skip to content

Commit

Permalink
Support for deserializing webhook events and verifying signatures
Browse files Browse the repository at this point in the history
  • Loading branch information
ob-stripe committed Apr 24, 2017
1 parent ad96723 commit 8858a48
Show file tree
Hide file tree
Showing 6 changed files with 334 additions and 0 deletions.
3 changes: 3 additions & 0 deletions init.php
Expand Up @@ -22,6 +22,7 @@
require(dirname(__FILE__) . '/lib/Error/InvalidRequest.php');
require(dirname(__FILE__) . '/lib/Error/Permission.php');
require(dirname(__FILE__) . '/lib/Error/RateLimit.php');
require(dirname(__FILE__) . '/lib/Error/SignatureVerification.php');

// Plumbing
require(dirname(__FILE__) . '/lib/ApiResponse.php');
Expand Down Expand Up @@ -71,3 +72,5 @@
require(dirname(__FILE__) . '/lib/Token.php');
require(dirname(__FILE__) . '/lib/Transfer.php');
require(dirname(__FILE__) . '/lib/TransferReversal.php');
require(dirname(__FILE__) . '/lib/Webhook.php');
require(dirname(__FILE__) . '/lib/WebhookSignature.php');
20 changes: 20 additions & 0 deletions lib/Error/SignatureVerification.php
@@ -0,0 +1,20 @@
<?php

namespace Stripe\Error;

class SignatureVerification extends Base
{
public function __construct(
$message,
$sigHeader,
$httpBody = null
) {
parent::__construct($message, null, $httpBody, null, null);
$this->sigHeader = $sigHeader;
}

public function getSigHeader()
{
return $this->sigHeader;
}
}
30 changes: 30 additions & 0 deletions lib/Util/Util.php
Expand Up @@ -7,6 +7,7 @@
abstract class Util
{
private static $isMbstringAvailable = null;
private static $isHashEqualsAvailable = null;

/**
* Whether the provided array (or other) is a list rather than a dictionary.
Expand Down Expand Up @@ -143,4 +144,33 @@ public static function utf8($value)
return $value;
}
}

/**
* Compares two strings for equality. The time taken is independent of the
* number of characters that match.
*
* @param string $a one of the strings to compare.
* @param string $b the other string to compare.
* @return bool true if the strings are equal, false otherwise.
*/
public static function secureCompare($a, $b)
{
if (self::$isHashEqualsAvailable === null) {
self::$isHashEqualsAvailable = function_exists('hash_equals');
}

if (self::$isHashEqualsAvailable) {
return hash_equals($a, $b);
} else {
if (strlen($a) != strlen($b)) {
return false;
}

$result = 0;
for ($i = 0; $i < strlen($a); $i++) {
$result |= ord($a[i]) ^ ord($b[i]);
}
return ($result == 0);
}
}
}
39 changes: 39 additions & 0 deletions lib/Webhook.php
@@ -0,0 +1,39 @@
<?php

namespace Stripe;

abstract class Webhook
{
const DEFAULT_TOLERANCE = 300;

/**
* Returns an Event instance using the provided JSON payload. Throws a
* JsonSyntaxException if the payload is not valid JSON, and a
* SignatureVerificationException if the signature verification fails for
* any reason.
*
* @param string $payload the payload sent by Stripe.
* @param string $sigHeader the contents of the signature header sent by
* Stripe.
* @param string $secret secret used to generate the signature.
* @param int $tolerance maximum difference allowed between the header's
* timestamp and the current time
* @return \Stripe\Event the Event instance
* @throws SignatureVerification if the verification fails.
*/
public static function createEventFromPayload($payload, $sigHeader, $secret, $tolerance = self::DEFAULT_TOLERANCE)
{
$data = json_decode($payload, true);
$jsonError = json_last_error();
if ($data === null && $jsonError !== JSON_ERROR_NONE) {
$msg = "Invalid payload: $payload "
. "(json_last_error() was $jsonError)";
throw new \UnexpectedValueException($msg);
}
$event = Event::constructFrom($data, null);

WebhookSignature::verifyHeader($payload, $sigHeader, $secret, $tolerance);

return $event;
}
}
132 changes: 132 additions & 0 deletions lib/WebhookSignature.php
@@ -0,0 +1,132 @@
<?php

namespace Stripe;

abstract class WebhookSignature
{
const EXPECTED_SCHEME = "v1";

/**
* Verifies the signature header sent by Stripe. Throws a
* SignatureVerification exception if the verification fails for any
* reason.
*
* @param string $payload the payload sent by Stripe.
* @param string $header the contents of the signature header sent by
* Stripe.
* @param string $secret secret used to generate the signature.
* @param int $tolerance maximum difference allowed between the header's
* timestamp and the current time
* @throws SignatureVerification if the verification fails.
*/
public static function verifyHeader($payload, $header, $secret, $tolerance = null)
{
// Extract timestamp and signatures from header
$timestamp = self::getTimestamp($header);
$signatures = self::getSignatures($header, self::EXPECTED_SCHEME);
if ($timestamp == -1) {
throw new Error\SignatureVerification(
"Unable to extract timestamp and signatures from header",
$header,
$payload
);
}
if (empty($signatures)) {
throw new Error\SignatureVerification(
"No signatures found with expected scheme",
$header,
$payload
);
}

// Check if expected signature is found in list of signatures from
// header
$signedPayload = "$timestamp.$payload";
$expectedSignature = self::computeSignature($signedPayload, $secret);
$signatureFound = false;
foreach ($signatures as $signature) {
if (Util\Util::secureCompare($expectedSignature, $signature)) {
$signatureFound = true;
break;
}
}
if (!$signatureFound) {
throw new Error\SignatureVerification(
"No signatures found matching the expected signature for payload",
$header,
$payload
);
}

// Check if timestamp is within tolerance
if (($tolerance > 0) && ((time() - $timestamp) > $tolerance)) {
throw new Error\SignatureVerification(
"Timestamp outside the tolerance zone",
$header,
$payload
);
}

return true;
}

/**
* Extracts the timestamp in a signature header.
*
* @param string $header the signature header
* @return int the timestamp contained in the header, or -1 if no valid
* timestamp is found
*/
private static function getTimestamp($header)
{
$items = explode(",", $header);

foreach ($items as $item) {
$itemParts = explode("=", $item, 2);
if ($itemParts[0] == "t") {
if (!is_numeric($itemParts[1])) {
return -1;
}
return intval($itemParts[1]);
}
}

return -1;
}

/**
* Extracts the signatures matching a given scheme in a signature header.
*
* @param string $header the signature header
* @param string $scheme the signature scheme to look for.
* @return array the list of signatures matching the provided scheme.
*/
private static function getSignatures($header, $scheme)
{
$signatures = array();
$items = explode(",", $header);

foreach ($items as $item) {
$itemParts = explode("=", $item, 2);
if ($itemParts[0] == $scheme) {
array_push($signatures, $itemParts[1]);
}
}

return $signatures;
}

/**
* Computes the signature for a given payload and secret.
*
* The current scheme used by Stripe ("v1") is HMAC/SHA-256.
*
* @param string $payload the payload to sign.
* @param string $secret the secret used to generate the signature.
* @return string the signature as a string.
*/
private static function computeSignature($payload, $secret)
{
return hash_hmac("sha256", $payload, $secret);
}
}
110 changes: 110 additions & 0 deletions tests/WebhookTest.php
@@ -0,0 +1,110 @@
<?php

namespace Stripe;

class WebhookTest extends TestCase
{
const EVENT_PAYLOAD = "{
\"id\": \"evt_test_webhook\",
\"object\": \"event\"
}";
const SECRET = "whsec_test_secret";

private function generateHeader($opts = array())
{
$timestamp = array_key_exists('timestamp', $opts) ? $opts['timestamp'] : time();
$payload = array_key_exists('payload', $opts) ? $opts['payload'] : self::EVENT_PAYLOAD;
$secret = array_key_exists('secret', $opts) ? $opts['secret'] : self::SECRET;
$scheme = array_key_exists('scheme', $opts) ? $opts['scheme'] : WebhookSignature::EXPECTED_SCHEME;
$signature = array_key_exists('signature', $opts) ? $opts['signature'] : null;
if ($signature === null) {
$signedPayload = "$timestamp.$payload";
$signature = hash_hmac("sha256", $signedPayload, $secret);
}
return "t=$timestamp,$scheme=$signature";
}

public function testValidJsonAndHeader()
{
$sigHeader = $this->generateHeader();
$event = Webhook::createEventFromPayload(self::EVENT_PAYLOAD, $sigHeader, self::SECRET);
$this->assertEquals("evt_test_webhook", $event->id);
}

/**
* @expectedException \UnexpectedValueException
*/
public function testInvalidJson()
{
$payload = "this is not valid JSON";
$sigHeader = $this->generateHeader(array("payload" => $payload));
Webhook::createEventFromPayload($payload, $sigHeader, self::SECRET);
}

/**
* @expectedException \Stripe\Error\SignatureVerification
*/
public function testValidJsonAndInvalidHeader()
{
$sigHeader = "bad_header";
Webhook::createEventFromPayload(self::EVENT_PAYLOAD, $sigHeader, self::SECRET);
}

/**
* @expectedException \Stripe\Error\SignatureVerification
* @expectedExceptionMessage Unable to extract timestamp and signatures from header
*/
public function testMalformedHeader()
{
$sigHeader = "i'm not even a real signature header";
WebhookSignature::verifyHeader(self::EVENT_PAYLOAD, $sigHeader, self::SECRET);
}

/**
* @expectedException \Stripe\Error\SignatureVerification
* @expectedExceptionMessage No signatures found with expected scheme
*/
public function testNoSignaturesWithExpectedScheme()
{
$sigHeader = $this->generateHeader(array("scheme" => "v0"));
WebhookSignature::verifyHeader(self::EVENT_PAYLOAD, $sigHeader, self::SECRET);
}

/**
* @expectedException \Stripe\Error\SignatureVerification
* @expectedExceptionMessage No signatures found matching the expected signature for payload
*/
public function testNoValidSignatureForPayload()
{
$sigHeader = $this->generateHeader(array("signature" => "bad_signature"));
WebhookSignature::verifyHeader(self::EVENT_PAYLOAD, $sigHeader, self::SECRET);
}

/**
* @expectedException \Stripe\Error\SignatureVerification
* @expectedExceptionMessage Timestamp outside the tolerance zone
*/
public function testTimestampOutsideTolerance()
{
$sigHeader = $this->generateHeader(array("timestamp" => time() - 15));
WebhookSignature::verifyHeader(self::EVENT_PAYLOAD, $sigHeader, self::SECRET, 10);
}

public function testValidHeaderAndSignature()
{
$sigHeader = $this->generateHeader();
$this->assertTrue(WebhookSignature::verifyHeader(self::EVENT_PAYLOAD, $sigHeader, self::SECRET, 10));
}

public function testHeaderContainsValidSignature()
{
$sigHeader = $this->generateHeader() . ",v1=bad_signature";
$this->assertTrue(WebhookSignature::verifyHeader(self::EVENT_PAYLOAD, $sigHeader, self::SECRET, 10));
}

public function testTimestampOffButNoTolerance()
{
$sigHeader = $this->generateHeader(array("timestamp" => 12345));
$this->assertTrue(WebhookSignature::verifyHeader(self::EVENT_PAYLOAD, $sigHeader, self::SECRET));
}
}

0 comments on commit 8858a48

Please sign in to comment.