├── .gitignore ├── .travis.yml ├── LICENCE ├── MANIFEST.in ├── README.rst ├── requirements.txt ├── setup.py ├── src └── validatesns │ └── __init__.py ├── tests ├── __init__.py └── test_validate.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.egg-info 3 | *.pyc 4 | 5 | /.coverage 6 | /.eggs 7 | /.tox 8 | /dist 9 | /htmlcov 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 2.7 3 | env: 4 | - TOX_ENV=py27 5 | - TOX_ENV=py34 6 | install: 7 | - pip install tox 8 | script: 9 | - tox -e $TOX_ENV 10 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Nathan Reynolds 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include requirements.txt 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | validatesns 3 | =========== 4 | 5 | Validate integrity of Amazon SNS messages. 6 | 7 | * Verifies cryptographic signature. 8 | * Checks signing certificate is hosted on an Amazon-controlled URL. 9 | * Requires message be no older than one hour, the maximum lifetime of an SNS message. 10 | 11 | |CILink|_ 12 | 13 | Licence: MIT_. 14 | 15 | 16 | *********** 17 | Quick start 18 | *********** 19 | 20 | .. code-block:: shell 21 | 22 | $ pip install validatesns 23 | 24 | .. code-block:: python 25 | 26 | import validatesns 27 | 28 | # Raise validatesns.ValidationError if message is invalid. 29 | validatesns.validate(decoded_json_message_from_sns) 30 | 31 | 32 | ******* 33 | Gotchas 34 | ******* 35 | 36 | The ``validate`` function downloads the signing certificate on every call. For performance reasons, it's worth caching certificates - you can do this by passing in a ``get_certificate`` function. 37 | 38 | This takes a ``url``, and returns the certificate content. Your function could cache to the filesystem, a database, or wherever makes sense. 39 | 40 | 41 | ********** 42 | Contribute 43 | ********** 44 | 45 | Github: https://github.com/nathforge/validatesns 46 | 47 | .. |CILink| image:: https://travis-ci.org/nathforge/validatesns.svg?branch=master 48 | .. _CILink: https://travis-ci.org/nathforge/validatesns 49 | .. _MIT: https://opensource.org/licenses/MIT 50 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | oscrypto 2 | six 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os.path 4 | 5 | from setuptools import setup 6 | 7 | ROOT = os.path.dirname(__file__) 8 | 9 | setup( 10 | version="0.1.1", 11 | url="https://github.com/nathforge/validatesns", 12 | name="validatesns", 13 | description="Validate integrity of Amazon SNS messages", 14 | long_description=open(os.path.join(ROOT, "README.rst")).read(), 15 | author="Nathan Reynolds", 16 | author_email="email@nreynolds.co.uk", 17 | packages=["validatesns"], 18 | package_dir={"": os.path.join(ROOT, "src")}, 19 | test_suite="tests", 20 | install_requires=[ 21 | line.strip() 22 | for line in open(os.path.join(ROOT, "requirements.txt")) 23 | if not line.startswith("#") 24 | and line.strip() != "" 25 | ], 26 | tests_require=[ 27 | "mock", 28 | "oscrypto", 29 | "six" 30 | ], 31 | classifiers=[ 32 | "Development Status :: 4 - Beta", 33 | "Intended Audience :: Developers", 34 | "Topic :: Software Development", 35 | "License :: OSI Approved :: MIT License", 36 | "Programming Language :: Python :: 2", 37 | "Programming Language :: Python :: 2.7", 38 | "Programming Language :: Python :: 3", 39 | "Programming Language :: Python :: 3.3", 40 | "Programming Language :: Python :: 3.4" 41 | ] 42 | ) 43 | -------------------------------------------------------------------------------- /src/validatesns/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Validate integrity of AWS SNS messages. 3 | 4 | * Verifies cryptographic signature. 5 | * Checks signing certificate is hosted on an AWS-controlled URL. 6 | * Requires message be no older than one hour, the maximum lifetime of an SNS message. 7 | """ 8 | 9 | from __future__ import print_function 10 | 11 | import base64 12 | import datetime 13 | import re 14 | 15 | import oscrypto.asymmetric 16 | import oscrypto.errors 17 | import six 18 | from six.moves.urllib.request import urlopen 19 | 20 | DEFAULT_CERTIFICATE_URL_REGEX = r"^https://sns\.[-a-z0-9]+\.amazonaws\.com(?:\.cn)?/" 21 | DEFAULT_MAX_AGE = datetime.timedelta(hours=1) 22 | 23 | class ValidationError(Exception): 24 | """ 25 | ValidationError. Raised when a message fails integrity checks. 26 | """ 27 | 28 | def validate( 29 | message, 30 | get_certificate=lambda url: urlopen(url).read(), 31 | certificate_url_regex=DEFAULT_CERTIFICATE_URL_REGEX, 32 | max_age=DEFAULT_MAX_AGE 33 | ): 34 | """ 35 | Validate a decoded SNS message. 36 | 37 | Parameters: 38 | message: 39 | Decoded SNS message. 40 | 41 | get_certificate: 42 | Function that receives a URL, and returns the certificate from that 43 | URL as a string. The default doesn't implement caching. 44 | 45 | certificate_url_regex: 46 | Regex that validates the signing certificate URL. Default value 47 | checks it's hosted on an AWS-controlled domain, in the format 48 | "https://sns..amazonaws.com/" 49 | 50 | max_age: 51 | Maximum age of an SNS message before it fails validation, expressed 52 | as a `datetime.timedelta`. Defaults to one hour, the max. lifetime 53 | of an SNS message. 54 | """ 55 | 56 | # Check the signing certicate URL. 57 | SigningCertURLValidator(certificate_url_regex).validate(message) 58 | 59 | # Check the message age. 60 | if not isinstance(max_age, datetime.timedelta): 61 | raise ValueError("max_age must be None or a timedelta object") 62 | MessageAgeValidator(max_age).validate(message) 63 | 64 | # Passed the basic checks, let's download the cert. 65 | # We've validated the URL, so aren't worried about a malicious server. 66 | certificate = get_certificate(message["SigningCertURL"]) 67 | 68 | # Check the cryptographic signature. 69 | SignatureValidator(certificate).validate(message) 70 | 71 | class SigningCertURLValidator(object): 72 | """ 73 | Validate a message's SigningCertURL is in the expected format. 74 | """ 75 | 76 | def __init__(self, regex=DEFAULT_CERTIFICATE_URL_REGEX): 77 | self.regex = regex 78 | 79 | def validate(self, message): 80 | if not isinstance(message, dict): 81 | raise ValidationError("Unexpected message type {!r}".format(type(message).__name__)) 82 | 83 | url = message.get("SigningCertURL") 84 | 85 | if isinstance(url, six.string_types) and re.search(self.regex, url): 86 | return 87 | 88 | raise ValidationError("SigningCertURL {!r} doesn't match required format {!r}".format(url, self.regex)) 89 | 90 | class MessageAgeValidator(object): 91 | """ 92 | Validate a message is not too old. 93 | """ 94 | 95 | def __init__(self, max_age=DEFAULT_MAX_AGE): 96 | self.max_age = max_age 97 | 98 | def validate(self, message): 99 | if not isinstance(message, dict): 100 | raise ValidationError("Unexpected message type {!r}".format(type(message).__name__)) 101 | 102 | utc_now = datetime.datetime.utcnow() 103 | 104 | utc_timestamp = self._get_utc_timestamp(message) 105 | 106 | age = utc_now - utc_timestamp 107 | if age <= self.max_age: 108 | return 109 | 110 | raise ValidationError("Message is too old: {}".format(age)) 111 | 112 | def _get_utc_timestamp(self, message): 113 | utc_timestamp_str = message.get("Timestamp") 114 | if not isinstance(utc_timestamp_str, six.string_types): 115 | raise ValidationError("Expected Timestamp to be a string, but received {!r}".format(utc_timestamp_str)) 116 | 117 | try: 118 | utc_timestamp = datetime.datetime.strptime(utc_timestamp_str, "%Y-%m-%dT%H:%M:%S.%fZ") 119 | except ValueError: 120 | raise ValidationError("Unexpected Timestamp format {!r}".format(utc_timestamp_str)) 121 | 122 | return utc_timestamp 123 | 124 | class SignatureValidator(object): 125 | """ 126 | Validate a message's cryptographic signature. 127 | 128 | AWS docs: http://docs.aws.amazon.com/sns/latest/dg/SendMessageToHttp.verify.signature.html 129 | """ 130 | 131 | def __init__(self, certificate): 132 | self.certificate = certificate 133 | 134 | def validate(self, message): 135 | if not isinstance(message, dict): 136 | raise ValidationError("Unexpected message type {!r}".format(type(message).__name__)) 137 | 138 | signature_version = message.get("SignatureVersion") 139 | if signature_version != "1": 140 | raise ValidationError("Unexpected SignatureVersion {!r}".format(signature_version)) 141 | 142 | base64_signature = message.get("Signature") 143 | if not isinstance(base64_signature, six.string_types): 144 | raise ValidationError("Expected Signature to be a string, but received {!r}".format(base64_signature)) 145 | 146 | signature = base64.b64decode(base64_signature) 147 | 148 | signing_content = self._get_signing_content(message) 149 | 150 | self._validate_signature(signature, signing_content) 151 | 152 | def _validate_signature(self, signature, content): 153 | certificate = self.certificate 154 | if isinstance(certificate, six.text_type): 155 | certificate = certificate.encode() 156 | 157 | if isinstance(content, six.text_type): 158 | content = content.encode() 159 | 160 | try: 161 | oscrypto.asymmetric.rsa_pkcs1v15_verify( 162 | oscrypto.asymmetric.load_certificate(certificate), 163 | signature, 164 | content, 165 | "sha1" 166 | ) 167 | except oscrypto.errors.SignatureError: 168 | raise ValidationError("Invalid signature") 169 | 170 | def _get_signing_content(self, message): 171 | lines = [] 172 | for key in self._get_signing_keys(message): 173 | if key not in message: 174 | raise ValidationError("Missing {!r} key".format(key)) 175 | 176 | lines.append(key) 177 | lines.append(message[key]) 178 | 179 | return "\n".join(lines) + "\n" 180 | 181 | def _get_signing_keys(self, message): 182 | message_type = message.get("Type") 183 | 184 | if message_type == "Notification": 185 | if "Subject" in message: 186 | return ("Message", "MessageId", "Subject", "Timestamp", "TopicArn", "Type") 187 | else: 188 | return ("Message", "MessageId", "Timestamp", "TopicArn", "Type",) 189 | 190 | if message_type in ("SubscriptionConfirmation", "UnsubscribeConfirmation"): 191 | return ("Message", "MessageId", "SubscribeURL", "Timestamp", "Token", "TopicArn", "Type") 192 | 193 | raise ValidationError("Unknown message type {!r}".format(message_type)) 194 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nathforge/validatesns/39f7f7d1fae215746bb9763856045b501fae05f4/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_validate.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import datetime 3 | import unittest 4 | 5 | import mock 6 | import oscrypto.asymmetric 7 | import six 8 | 9 | from validatesns import MessageAgeValidator, SignatureValidator, ValidationError, validate as _validate_fn 10 | 11 | PRIVATE_KEY = six.b(""" 12 | -----BEGIN PRIVATE KEY----- 13 | MIICeAIBADANBgkqhkiG9w0BAQEFAASCAmIwggJeAgEAAoGBAK+LkCvEsnMUws8s 14 | G5iQ8eDwCCtYBRaED8DmCOrZASJhUcijlZl2vFwx1Vo551ZJVw06zHjy04psRQ1r 15 | 0wctmU2330u4OofwduLh2jpt4sH5EUzJkEkAuJdHIv0M3fvsHOZbZPogc2ptwhTL 16 | kSTtT75w9+A+HYRHiqIE5KIbugBlAgMBAAECgYEAjDGGYx4EcdnLts5//3kKYtzv 17 | eUYjUhcHycMsrfm+aSmVugnCqLvltC9sN1F1CjkqF3u03ob3IF5VS2GoN9xXyCDR 18 | KTsTMS5Duqg4Fq/Yj79lVqUbU9ukZRqQ/ijDulLT6Su4JP74354qYPuljQ3Amh8S 19 | 45Thscii8iA/gdFl5wUCQQDm4cNS2V3e8KnYMZCG4MLrENeiMmsuwQsbU0d8TbSA 20 | 7Tpw8gTDPksYzpEjFHs0P2WvaQeY/cmcnTuopIqLzqG3AkEAwqShWsc8wWWzoDnA 21 | YzLkAHFnoKESVQezEiqYAig6b0xuhz6QvzGAAYqyAweyT3M3xD+DSjwH/uuRGn/4 22 | fSm+wwJAbEi8RBIgXZw//F6aqzelE3xtteuxq1bsr58qatlC7CjW/Pv1UeDYdcUD 23 | +xDzC7kkJtW6s31r3mE8BsdNF28NFwJBAIDxv1L8GmukjFLg72rIE/OXLSdkjVh3 24 | OVIXlYwYSl3hLHe8IvgGOt7KmxMWzjGECrWfvcI38rQWKpJ7pIqGVTECQQDFhYhp 25 | 4+lb7f70593XHzBlasvoO6RJQODxsHSLnD1z3uMiMMbw1PdDG389vgS+eqs4eAi3 26 | WxsygMAWNvSfb6/R 27 | -----END PRIVATE KEY----- 28 | """.strip()) 29 | 30 | CERT = six.b(""" 31 | -----BEGIN CERTIFICATE----- 32 | MIIB6DCCAVGgAwIBAgIJAJvkgfs3SdauMA0GCSqGSIb3DQEBCwUAMA0xCzAJBgNV 33 | BAYTAlVLMB4XDTE1MTAzMDA5NDYzNloXDTM1MTIzMTA5NDYzNlowDTELMAkGA1UE 34 | BhMCVUswgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAK+LkCvEsnMUws8sG5iQ 35 | 8eDwCCtYBRaED8DmCOrZASJhUcijlZl2vFwx1Vo551ZJVw06zHjy04psRQ1r0wct 36 | mU2330u4OofwduLh2jpt4sH5EUzJkEkAuJdHIv0M3fvsHOZbZPogc2ptwhTLkSTt 37 | T75w9+A+HYRHiqIE5KIbugBlAgMBAAGjUDBOMB0GA1UdDgQWBBQcJiFgx5ilZpSB 38 | 9WdN0nIs2O4EnzAfBgNVHSMEGDAWgBQcJiFgx5ilZpSB9WdN0nIs2O4EnzAMBgNV 39 | HRMEBTADAQH/MA0GCSqGSIb3DQEBCwUAA4GBADquYDn5XJIi0Gp8Y2MwQ0F4O9N2 40 | l0d9QZihTLI9yEUYgGqmKOy1UYLDnUAqFsw0hkxCncjk6RinKKX9zZl/AqHVQrNI 41 | co1fh3Vc+3Fxj/hr5Ky5Bcl2IRaDhnaM34TmlSMaCyu33LRJytlGb/aZRdCCFEIB 42 | ke2qiRJ8R2fTxwtO 43 | -----END CERTIFICATE----- 44 | """.strip()) 45 | 46 | class TestMixin(object): 47 | def setUp(self): 48 | self.utc_now = datetime.datetime(2015, 1, 1, 0, 0, 0, 0) 49 | 50 | self.validate_kwargs = {} 51 | 52 | self.message = { 53 | "SigningCertURL": "https://sns.us-east-1.amazonaws.com/cert.pem", 54 | "Timestamp": self.serialize_datetime(self.utc_now), 55 | "Signature": "", 56 | "SignatureVersion": "1", 57 | "Type": "Notification", 58 | "Message": "Hi", 59 | "MessageId": "123", 60 | "TopicArn": "arn:aws:sn:us-east-1:345:MyTopic", 61 | } 62 | 63 | self.signing_certs = { 64 | "https://sns.us-east-1.amazonaws.com/cert.pem": CERT, 65 | "https://sns.us-east-1.amazonaws.com.cn/cert.pem": CERT 66 | } 67 | 68 | def validate(self): 69 | if isinstance(self.message, dict): 70 | certificate = self.signing_certs.get(self.message.get("SigningCertURL")) 71 | else: 72 | certificate = None 73 | 74 | with mock.patch("validatesns.urlopen") as mock_urlopen: 75 | if certificate: 76 | if isinstance(certificate, six.text_type): 77 | certificate = certificate.encode() 78 | mock_urlopen.return_value = six.BytesIO(certificate) 79 | else: 80 | mock_urlopen.side_effect = NotImplementedError("Shouldn't happen") 81 | 82 | real_datetime = datetime.datetime 83 | with mock.patch("datetime.datetime") as mock_datetime: 84 | mock_datetime.strptime.side_effect = real_datetime.strptime 85 | mock_datetime.utcnow.return_value = self.utc_now 86 | 87 | _validate_fn(self.message, **self.validate_kwargs) 88 | 89 | def sign_message(self): 90 | self.message["Signature"] = self.get_message_signature() 91 | 92 | def get_message_signature(self): 93 | if not isinstance(self.message, dict): 94 | raise ValueError("Can't sign non-dict messages") 95 | 96 | private_key = oscrypto.asymmetric.load_private_key(PRIVATE_KEY) 97 | signing_content = SignatureValidator("")._get_signing_content(self.message) 98 | signature = oscrypto.asymmetric.rsa_pkcs1v15_sign(private_key, signing_content.encode(), "sha1") 99 | 100 | return base64.b64encode(signature) 101 | 102 | def serialize_datetime(self, dt): 103 | return dt.strftime("%Y-%m-%dT%H:%M:%S.{}Z".format(dt.strftime("%f")[:3])) 104 | 105 | class ValidateTestCase(TestMixin, unittest.TestCase): 106 | def test_invalid_max_age_parameter(self): 107 | self.validate_kwargs["max_age"] = 5 108 | with self.assertRaisesRegexp(ValueError, r"^max_age must be None or a timedelta object$"): 109 | self.validate() 110 | 111 | def test_non_dict_message(self): 112 | self.message = [] 113 | with self.assertRaisesRegexp(ValidationError, r"^Unexpected message type .*$"): 114 | self.validate() 115 | 116 | class SigningCertURLValidatorTestCase(TestMixin, unittest.TestCase): 117 | def test_valid_com_signing_cert_url(self): 118 | self.message["SigningCertURL"] = "https://sns.us-east-1.amazonaws.com/cert.pem" 119 | self.sign_message() 120 | self.validate() 121 | 122 | def test_valid_com_cn_signing_cert_url(self): 123 | self.message["SigningCertURL"] = "https://sns.us-east-1.amazonaws.com.cn/cert.pem" 124 | self.sign_message() 125 | self.validate() 126 | 127 | def test_non_aws_signing_cert_url(self): 128 | self.message["SigningCertURL"] = "https://example.com/cert.pem" 129 | with self.assertRaisesRegexp(ValidationError, r"^SigningCertURL .* doesn't match required format .*"): 130 | self.validate() 131 | 132 | def test_non_aws_controlled_signing_cert_url(self): 133 | self.message["SigningCertURL"] = "https://s3.us-east-1.amazonaws.com/evil/cert.pem" 134 | with self.assertRaisesRegexp(ValidationError, r"^SigningCertURL .* doesn't match required format .*"): 135 | self.validate() 136 | 137 | def test_missing_signing_cert_url(self): 138 | del self.message["SigningCertURL"] 139 | with self.assertRaisesRegexp(ValidationError, r"^SigningCertURL .* doesn't match required format .*"): 140 | self.validate() 141 | 142 | def test_non_string_signing_cert_url(self): 143 | self.message["SigningCertURL"] = 123 144 | with self.assertRaisesRegexp(ValidationError, r"^SigningCertURL .* doesn't match required format .*"): 145 | self.validate() 146 | 147 | class MessageAgeValidatorTestCase(TestMixin, unittest.TestCase): 148 | def test_valid_age(self): 149 | self.message["Timestamp"] = self.serialize_datetime(self.utc_now) 150 | self.sign_message() 151 | self.validate() 152 | 153 | def test_nearly_too_old(self): 154 | self.message["Timestamp"] = self.serialize_datetime(self.utc_now - datetime.timedelta(hours=1)) 155 | self.sign_message() 156 | self.validate() 157 | 158 | def test_too_old(self): 159 | self.message["Timestamp"] = self.serialize_datetime(self.utc_now - datetime.timedelta(hours=1, seconds=1)) 160 | with self.assertRaisesRegexp(ValidationError, r"^Message is too old: .*$"): 161 | self.validate() 162 | 163 | def test_missing_timestamp(self): 164 | del self.message["Timestamp"] 165 | with self.assertRaisesRegexp(ValidationError, r"^Expected Timestamp to be a string, but received .*$"): 166 | self.validate() 167 | 168 | def test_non_string_timestamp(self): 169 | self.message["Timestamp"] = 123 170 | with self.assertRaisesRegexp(ValidationError, r"^Expected Timestamp to be a string, but received .*$"): 171 | self.validate() 172 | 173 | def test_unexpected_timestamp_format(self): 174 | self.message["Timestamp"] = "January 5 2015" 175 | with self.assertRaisesRegexp(ValidationError, r"^Unexpected Timestamp format .*$"): 176 | self.validate() 177 | 178 | class SignatureValidatorTestCase(TestMixin, unittest.TestCase): 179 | def test_missing_signature_version(self): 180 | del self.message["SignatureVersion"] 181 | with self.assertRaisesRegexp(ValidationError, r"^Unexpected SignatureVersion .*$"): 182 | self.validate() 183 | 184 | def test_unexpected_signature_version(self): 185 | self.message["SignatureVersion"] = "99" 186 | with self.assertRaisesRegexp(ValidationError, r"^Unexpected SignatureVersion .*$"): 187 | self.validate() 188 | 189 | def test_valid_signature(self): 190 | self.sign_message() 191 | self.validate() 192 | 193 | def test_missing_signature(self): 194 | del self.message["Signature"] 195 | with self.assertRaisesRegexp(ValidationError, r"^Expected Signature to be a string, but received .*$"): 196 | self.validate() 197 | 198 | def test_non_string_signature(self): 199 | self.message["Signature"] = 99 200 | with self.assertRaisesRegexp(ValidationError, r"^Expected Signature to be a string, but received .*$"): 201 | self.validate() 202 | 203 | def test_non_base64_signature(self): 204 | self.message["Signature"] = "!!!???" 205 | with self.assertRaisesRegexp(ValidationError, r"^Invalid signature$"): 206 | self.validate() 207 | 208 | def test_unsigned_message(self): 209 | with self.assertRaisesRegexp(ValidationError, r"^Invalid signature$"): 210 | self.validate() 211 | 212 | def test_invalid_signature(self): 213 | self.sign_message() 214 | self.message["Signature"] = self.message["Signature"].swapcase() 215 | with self.assertRaisesRegexp(ValidationError, r"^Invalid signature$"): 216 | self.validate() 217 | 218 | def test_valid_signature_with_unicode_certificate(self): 219 | self.signing_certs = { 220 | "https://sns.us-east-1.amazonaws.com/cert.pem": CERT.decode(), 221 | "https://sns.us-east-1.amazonaws.com.cn/cert.pem": CERT.decode() 222 | } 223 | 224 | self.sign_message() 225 | self.validate() 226 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist=py27,py34,py35 3 | 4 | [testenv] 5 | commands=python setup.py test 6 | --------------------------------------------------------------------------------