Skip to content

Commit

Permalink
Merge pull request #6 from aws/move-string-to-sign
Browse files Browse the repository at this point in the history
Moved getStringToSign from the Message to the MessageValidator
  • Loading branch information
jeremeamia committed Jun 30, 2015
2 parents 1711c5b + 69aeb2e commit 926cf23
Show file tree
Hide file tree
Showing 4 changed files with 124 additions and 139 deletions.
85 changes: 22 additions & 63 deletions src/Message.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,34 +7,14 @@
class Message implements \ArrayAccess, \IteratorAggregate
{
private static $requiredKeys = [
'__default' => [
'Message',
'MessageId',
'Timestamp',
'TopicArn',
'Type',
'Signature',
'SigningCertURL',
],
'SubscriptionConfirmation' => [
'SubscribeURL',
'Token',
],
'UnsubscribeConfirmation' => [
'SubscribeURL',
'Token',
],
];

private static $signableKeys = [
'Message',
'MessageId',
'Subject',
'SubscribeURL',
'Timestamp',
'Token',
'TopicArn',
'Type',
'Signature',
'SigningCertURL',
'SignatureVersion',
];

/** @var array The message data */
Expand All @@ -48,10 +28,12 @@ class Message implements \ArrayAccess, \IteratorAggregate
*/
public static function fromRawPostData()
{
// Make sure the SNS-provided header exists.
if (!isset($_SERVER['HTTP_X_AMZ_SNS_MESSAGE_TYPE'])) {
throw new \RuntimeException('SNS message type header not provided.');
}

// Read the raw POST data and JSON-decode it.
$data = json_decode(file_get_contents('php://input'), true);
if (JSON_ERROR_NONE !== json_last_error() || !is_array($data)) {
throw new \RuntimeException('Invalid POST data.');
Expand All @@ -70,28 +52,12 @@ public static function fromRawPostData()
*/
public function __construct(array $data)
{
// Make sure the type key is set
if (!isset($data['Type'])) {
throw new \InvalidArgumentException(
'The "Type" must be provided to instantiate a Message object.'
);
}

// Determine the required keys for this message type.
$requiredKeys = array_merge(
self::$requiredKeys['__default'],
isset(self::$requiredKeys[$data['Type']]) ?
self::$requiredKeys[$data['Type']]
: []
);

// Ensure that all the required keys are provided.
foreach ($requiredKeys as $key) {
if (!isset($data[$key])) {
throw new \InvalidArgumentException(
"Missing key {$key} in the provided data."
);
}
// Ensure that all the required keys for the message's type are present.
$this->validateRequiredKeys($data, self::$requiredKeys);
if ($data['Type'] === 'SubscriptionConfirmation'
|| $data['Type'] === 'UnsubscribeConfirmation'
) {
$this->validateRequiredKeys($data, ['SubscribeURL', 'Token']);
}

$this->data = $data;
Expand All @@ -102,24 +68,6 @@ public function getIterator()
return new \ArrayIterator($this->data);
}

/**
* Builds a newline delimited string-to-sign according to the specs.
*
* @return string
* @link http://docs.aws.amazon.com/sns/latest/gsg/SendMessageToHttp.verify.signature.html
*/
public function getStringToSign()
{
$stringToSign = '';
foreach (self::$signableKeys as $key) {
if (isset($this[$key])) {
$stringToSign .= "{$key}\n{$this[$key]}\n";
}
}

return $stringToSign;
}

public function offsetExists($key)
{
return isset($this->data[$key]);
Expand Down Expand Up @@ -149,4 +97,15 @@ public function toArray()
{
return $this->data;
}

private function validateRequiredKeys(array $data, array $keys)
{
foreach ($keys as $key) {
if (!isset($data[$key])) {
throw new \InvalidArgumentException(
"\"{$key}\" is required to verify the SNS Message."
);
}
}
}
}
96 changes: 60 additions & 36 deletions src/MessageValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,55 +8,51 @@
*/
class MessageValidator
{
const SUPPORTED_SIGNATURE_VERSION = '1';
const SIGNATURE_VERSION_1 = '1';

/**
* @var callable
* @var callable Callable used to download the certificate content.
*/
private $remoteFileReader;
private $certClient;

/**
* Constructs the Message Validator object and ensures that openssl is
* installed.
*
* @param callable $remoteFileReader
* @param callable $certClient Callable used to download the certificate.
* Should have the following function signature:
* `function (string $certUrl) : string $certContent`
*
* @throws \RuntimeException If openssl is not installed
*/
public function __construct(callable $remoteFileReader = null)
public function __construct(callable $certClient = null)
{
$this->remoteFileReader = $remoteFileReader ?: 'file_get_contents';
$this->certClient = $certClient ?: 'file_get_contents';
}

/**
* Validates a message from SNS to ensure that it was delivered by AWS
* Validates a message from SNS to ensure that it was delivered by AWS.
*
* @param Message $message The message to validate
* @param Message $message Message to validate.
*
* @throws InvalidSnsMessageException If the certificate cannot be
* retrieved, if the certificate's source cannot be verified, or if the
* message's signature is invalid.
* @throws InvalidSnsMessageException If the cert cannot be retrieved or its
* source verified, or the message
* signature is invalid.
*/
public function validate(Message $message)
{
$this->validateSignatureVersion($message['SignatureVersion']);

$certUrl = $message['SigningCertURL'];
$this->validateUrl($certUrl);
// Get the certificate.
$this->validateUrl($message['SigningCertURL']);
$certificate = call_user_func($this->certClient, $message['SigningCertURL']);

// Get the cert itself and extract the public key
$certificate = call_user_func($this->remoteFileReader, $certUrl);
// Extract the public key.
$key = openssl_get_publickey($certificate);
if (!$key) {
throw new InvalidSnsMessageException(
'Cannot get the public key from the certificate.'
);
}

// Verify the signature of the message
$content = $message->getStringToSign();
// Verify the signature of the message.
$content = $this->getStringToSign($message);
$signature = base64_decode($message['Signature']);

if (!openssl_verify($content, $signature, $key, OPENSSL_ALGO_SHA1)) {
throw new InvalidSnsMessageException(
'The message signature is invalid.'
Expand All @@ -83,12 +79,49 @@ public function isValid(Message $message)
}

/**
* Ensures that the url of the certificate is one belonging to AWS, and not
* just something from the amazonaws domain, which includes S3 buckets.
* Builds string-to-sign according to the SNS message spec.
*
* @param Message $message Message for which to build the string-to-sign.
*
* @return string
* @link http://docs.aws.amazon.com/sns/latest/gsg/SendMessageToHttp.verify.signature.html
*/
public function getStringToSign(Message $message)
{
static $signableKeys = [
'Message',
'MessageId',
'Subject',
'SubscribeURL',
'Timestamp',
'Token',
'TopicArn',
'Type',
];

if ($message['SignatureVersion'] !== self::SIGNATURE_VERSION_1) {
throw new InvalidSnsMessageException(
"The SignatureVersion \"{$message['SignatureVersion']}\" is not supported."
);
}

$stringToSign = '';
foreach ($signableKeys as $key) {
if (isset($message[$key])) {
$stringToSign .= "{$key}\n{$message[$key]}\n";
}
}

return $stringToSign;
}

/**
* Ensures that the URL of the certificate is one belonging to AWS, and not
* just something from the amazonaws domain, which could include S3 buckets.
*
* @param string $url
* @param string $url Certificate URL
*
* @throws InvalidSnsMessageException if the cert url is invalid
* @throws InvalidSnsMessageException if the cert url is invalid.
*/
private function validateUrl($url)
{
Expand All @@ -106,13 +139,4 @@ private function validateUrl($url)
);
}
}

private function validateSignatureVersion($version)
{
if ($version !== self::SUPPORTED_SIGNATURE_VERSION) {
throw new InvalidSnsMessageException(
"Only v1 signatures can be validated; v{$version} provided"
);
}
}
}
46 changes: 10 additions & 36 deletions tests/MessageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,17 @@
class MessageTest extends \PHPUnit_Framework_TestCase
{
public $messageData = array(
'Message' => 'a',
'MessageId' => 'b',
'Timestamp' => 'c',
'TopicArn' => 'd',
'Type' => 'e',
'Subject' => 'f',
'Signature' => 'g',
'Message' => 'a',
'MessageId' => 'b',
'Timestamp' => 'c',
'TopicArn' => 'd',
'Type' => 'e',
'Subject' => 'f',
'Signature' => 'g',
'SignatureVersion' => '1',
'SigningCertURL' => 'h',
'SubscribeURL' => 'i',
'Token' => 'j',
'SubscribeURL' => 'i',
'Token' => 'j',
);

public function testGetters()
Expand Down Expand Up @@ -86,31 +87,4 @@ public function testCreateFromRawPostFailsWithMissingData()
Message::fromRawPostData();
unset($_SERVER['HTTP_X_AMZ_SNS_MESSAGE_TYPE']);
}

public function testBuildsStringToSignCorrectly( ) {
$message = new Message([
'TopicArn' => 'd',
'Message' => 'a',
'Timestamp' => 'c',
'Type' => 'e',
'MessageId' => 'b',
'FooBar' => 'f',
'Signature' => true,
'SigningCertURL' => true,
]);
$stringToSign = <<< STRINGTOSIGN
Message
a
MessageId
b
Timestamp
c
TopicArn
d
Type
e
STRINGTOSIGN;
$this->assertEquals($stringToSign, $message->getStringToSign());
}
}
36 changes: 32 additions & 4 deletions tests/MessageValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public static function tearDownAfterClass()

public function testIsValidReturnsFalseOnFailedValidation()
{
$validator = new MessageValidator();
$validator = new MessageValidator($this->getMockHttpClient());
$message = $this->getTestMessage([
'SignatureVersion' => '2',
]);
Expand All @@ -36,11 +36,11 @@ public function testIsValidReturnsFalseOnFailedValidation()

/**
* @expectedException \Aws\Sns\Exception\InvalidSnsMessageException
* @expectedExceptionMessage Only v1 signatures can be validated; v2 provided
* @expectedExceptionMessage The SignatureVersion "2" is not supported.
*/
public function testValidateFailsWhenSignatureVersionIsInvalid()
{
$validator = new MessageValidator();
$validator = new MessageValidator($this->getMockCertServerClient());
$message = $this->getTestMessage([
'SignatureVersion' => '2',
]);
Expand Down Expand Up @@ -90,12 +90,35 @@ public function testValidateSucceedsWhenMessageIsValid()
$message = $this->getTestMessage();

// Get the signature for a real message
$message['Signature'] = $this->getSignature($message->getStringToSign());
$message['Signature'] = $this->getSignature($validator->getStringToSign($message));

// The message should validate
$this->assertTrue($validator->isValid($message));
}

public function testBuildsStringToSignCorrectly()
{
$validator = new MessageValidator();
$stringToSign = <<< STRINGTOSIGN
Message
foo
MessageId
bar
Timestamp
1435697129
TopicArn
baz
Type
Notification
STRINGTOSIGN;

$this->assertEquals(
$stringToSign,
$validator->getStringToSign($this->getTestMessage())
);
}

/**
* @param array $customData
*
Expand Down Expand Up @@ -140,3 +163,8 @@ private function getSignature($stringToSign)
return base64_encode($signature);
}
}

function time()
{
return 1435697129;
}

0 comments on commit 926cf23

Please sign in to comment.