pax_global_header00006660000000000000000000000064133775750250014530gustar00rootroot0000000000000052 comment=af73c646573eefda4621dfae5d2912b8d26eef38 httpsig-1.3.0/000077500000000000000000000000001337757502500132135ustar00rootroot00000000000000httpsig-1.3.0/.gitattributes000066400000000000000000000000411337757502500161010ustar00rootroot00000000000000httpsig/_version.py export-subst httpsig-1.3.0/.gitignore000066400000000000000000000001651337757502500152050ustar00rootroot00000000000000*.egg *.egg-info *.pyc *~ .noseids .tox build/ dist/ doc/__build/* *_rsa *_rsa.pub locale/ pip-log.txt /.idea /.eggs httpsig-1.3.0/.travis.yml000066400000000000000000000002161337757502500153230ustar00rootroot00000000000000language: python python: - "2.7" - "3.3" - "3.4" - "3.5" - "3.6" install: - pip install . - pip install nose script: nosetests httpsig-1.3.0/CHANGELOG.rst000066400000000000000000000063341337757502500152420ustar00rootroot00000000000000httpsig Changes --------------- 1.3.0 (2019-Nov-28) ------------------- * Relax pycryptodome requirements (PR#14 by cveilleux) * Ability to supply another signature header like Signature (PR#15 by rbignon) * Fixed #2; made Signer.sign() public * Dropped Python 3.3, added Python 3.7. 1.2.0 (2018-Mar-28) ------------------- * Switched to pycryptodome instead of PyCrypto (PR#11 by iandouglas) * Updated tests with the test data from Draft 8 and verified it still passes. * Dropped official Python 3.2 support (pip dropped it so it can't be properly tested) * Cleaned up the code to be more PEP8-like. 1.1.2 (2015-Feb-11) ------------------- * HMAC verification is now constant-time. 1.1.1 (2015-Feb-11) ------------------- * (pulled) 1.1.0 (2014-Jul-24) ------------------- * Changed "(request-line)" to "(request-target)" to comply with Draft 3. 1.0.3 (2014-Jul-09) ------------------- * Unified the default signing algo under one setting. Setting httpsig.sign.DEFAULT_SIGN_ALGORITHM changes it for all future instances. * Handle invalid params a little better. 1.0.2 (2014-Jul-02) ------------------- * Ensure we treat headers as ASCII strings. * Handle a case in the authorization header where there's garbage (non-keypairs) after the method name. 1.0.1 (2014-Jul-02) ~~~~~~~~~~~~~~~~~~~ * Python 3 support (2.7 + 3.2-3.4) * Updated tox and Travis CI configs to test the supported Python versions. * Updated README. 1.0.0 (2014-Jul-01) ~~~~~~~~~~~~~~~~~~~ * Written against http://tools.ietf.org/html/draft-cavage-http-signatures-02 * Added "setup.py test" and tox support. * Added sign/verify unit tests for all currently-supported algorithms. * HeaderSigner and HeaderVerifier now share the same message-building logic. * The HTTP method in the message is now properly lower-case. * Resolved unit test failures. * Updated Verifier and HeaderVerifier to handle verifying both RSA and HMAC sigs. * Updated versioneer. * Updated contact/author info. * Removed stray keypair in test dir. * Removed SSH agent support. * Removed suport for reading keyfiles from disk as this is a huge security hole if this is used in a server framework like drf-httpsig. 1.0b1 (2014-Jun-23) ~~~~~~~~~~~~~~~~~~~~~~ * Removed HTTP version from request-line, per spec (breaks backwards compatability). * Removed auto-generation of missing Date header (ensures client compatability). http-signature (previous) ------------------------- 0.2.0 (unreleased) ~~~~~~~~~~~~~~~~~~ * Update to newer spec (incompatible with prior version). * Handle `request-line` meta-header. * Allow secret to be a PEM encoded string. * Add test cases from spec. 0.1.4 (2012-10-03) ~~~~~~~~~~~~~~~~~~ * Account for ssh now being re-merged into paramiko: either package is acceptable (but paramiko should ideally be >= 1.8.0) 0.1.3 (2012-10-02) ~~~~~~~~~~~~~~~~~~ * Stop enabling `allow_agent` by default * Stop requiring `ssh` package by default -- it is imported only when `allow_agent=True` * Changed logic around ssh-agent: if one key is available, don't bother with any other authentication method * Changed logic around key file usage: if decryption fails, prompt for password * Bug fix: ssh-agent resulted in a nonsensical error if it found no correct keys (thanks, petervolpe) * Introduce versioneer.py httpsig-1.3.0/LICENSE.txt000066400000000000000000000021241337757502500150350ustar00rootroot00000000000000Copyright (c) 2014 Adam Knight Copyright (c) 2012 Adam T. Lindsay (original author) 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: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 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. httpsig-1.3.0/MANIFEST000066400000000000000000000006021337757502500143420ustar00rootroot00000000000000# file GENERATED by distutils, do NOT edit CHANGELOG.rst LICENSE.txt README.rst requirements.txt setup.cfg setup.py httpsig/__init__.py httpsig/requests_auth.py httpsig/sign.py httpsig/utils.py httpsig/verify.py httpsig/tests/__init__.py httpsig/tests/rsa_private.pem httpsig/tests/rsa_public.pem httpsig/tests/test_signature.py httpsig/tests/test_utils.py httpsig/tests/test_verify.py httpsig-1.3.0/MANIFEST.in000066400000000000000000000000701337757502500147460ustar00rootroot00000000000000include *.rst include *.txt include httpsig/tests/*.pem httpsig-1.3.0/README.rst000066400000000000000000000102671337757502500147100ustar00rootroot00000000000000httpsig ======= .. image:: https://travis-ci.org/ahknight/httpsig.svg?branch=master :target: https://travis-ci.org/ahknight/httpsig .. image:: https://travis-ci.org/ahknight/httpsig.svg?branch=develop :target: https://travis-ci.org/ahknight/httpsig Sign HTTP requests with secure signatures according to the IETF HTTP Signatures specification (`Draft 8`_). This is a fork of the original module_ to fully support both RSA and HMAC schemes as well as unit test both schemes to prove they work. It's being used in production and is actively-developed. See the original project_, original Python module_, original spec_, and `current IETF draft`_ for more details on the signing scheme. .. _project: https://github.com/joyent/node-http-signature .. _module: https://github.com/zzsnzmn/py-http-signature .. _spec: https://github.com/joyent/node-http-signature/blob/master/http_signing.md .. _`current IETF draft`: https://datatracker.ietf.org/doc/draft-cavage-http-signatures/ .. _`Draft 8`: http://tools.ietf.org/html/draft-cavage-http-signatures-08 Requirements ------------ * Python 2.7, 3.4-3.7 * PyCryptodome_ Optional: * requests_ .. _PyCryptodome: https://pypi.python.org/pypi/pycryptodome .. _requests: https://pypi.python.org/pypi/requests For testing: * tox * pyenv (optional, handy way to access multiple versions) $ for VERS in 2.7.15 3.4.9 3.5.6 3.6.7 3.7.1; do pyenv install -s $VERS; done Usage ----- Real documentation is forthcoming, but for now this should get you started. For simple raw signing: .. code:: python import httpsig secret = open('rsa_private.pem', 'rb').read() sig_maker = httpsig.Signer(secret=secret, algorithm='rsa-sha256') sig_maker.sign('hello world!') For general use with web frameworks: .. code:: python import httpsig key_id = "Some Key ID" secret = b'some big secret' hs = httpsig.HeaderSigner(key_id, secret, algorithm="hmac-sha256", headers=['(request-target)', 'host', 'date']) signed_headers_dict = hs.sign({"Date": "Tue, 01 Jan 2014 01:01:01 GMT", "Host": "example.com"}, method="GET", path="/api/1/object/1") For use with requests: .. code:: python import json import requests from httpsig.requests_auth import HTTPSignatureAuth secret = open('rsa_private.pem', 'rb').read() auth = HTTPSignatureAuth(key_id='Test', secret=secret) z = requests.get('https://api.example.com/path/to/endpoint', auth=auth, headers={'X-Api-Version': '~6.5'}) Class initialization parameters ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Note that keys and secrets should be bytes objects. At attempt will be made to convert them, but if that fails then exceptions will be thrown. .. code:: python httpsig.Signer(secret, algorithm='rsa-sha256') ``secret``, in the case of an RSA signature, is a string containing private RSA pem. In the case of HMAC, it is a secret password. ``algorithm`` is one of the six allowed signatures: ``rsa-sha1``, ``rsa-sha256``, ``rsa-sha512``, ``hmac-sha1``, ``hmac-sha256``, ``hmac-sha512``. .. code:: python httpsig.requests_auth.HTTPSignatureAuth(key_id, secret, algorithm='rsa-sha256', headers=None) ``key_id`` is the label by which the server system knows your RSA signature or password. ``headers`` is the list of HTTP headers that are concatenated and used as signing objects. By default it is the specification's minimum, the ``Date`` HTTP header. ``secret`` and ``algorithm`` are as above. Tests ----- To run tests:: python setup.py test or:: tox Known Limitations ----------------- 1. Multiple values for the same header are not supported. New headers with the same name will overwrite the previous header. It might be possible to replace the CaseInsensitiveDict with the collection that the email package uses for headers to overcome this limitation. 2. Keyfiles with passwords are not supported. There has been zero vocal demand for this so if you would like it, a PR would be a good way to get it in. 3. Draft 2 added support for ecdsa-sha256. This is available in PyCryptodome but has not been added to httpsig. PRs welcome. License ------- Both this module and the original module_ are licensed under the MIT license. httpsig-1.3.0/httpsig/000077500000000000000000000000001337757502500146755ustar00rootroot00000000000000httpsig-1.3.0/httpsig/__init__.py000066400000000000000000000005221337757502500170050ustar00rootroot00000000000000from pkg_resources import get_distribution, DistributionNotFound from .sign import Signer, HeaderSigner from .verify import Verifier, HeaderVerifier try: __version__ = get_distribution(__name__).version except DistributionNotFound: # package is not installed pass __all__ = (Signer, HeaderSigner, Verifier, HeaderVerifier) httpsig-1.3.0/httpsig/requests_auth.py000066400000000000000000000027671337757502500201570ustar00rootroot00000000000000import requests.auth try: # Python 3 from urllib.parse import urlparse except ImportError: # Python 2 from urlparse import urlparse from .sign import HeaderSigner class HTTPSignatureAuth(requests.auth.AuthBase): """ Sign a request using the http-signature scheme. https://github.com/joyent/node-http-signature/blob/master/http_signing.md `key_id` is the mandatory label indicating to the server which secret to use secret is the filename of a pem file in the case of rsa, a password string in the case of an hmac algorithm `algorithm` is one of the six specified algorithms headers is a list of http headers to be included in the signing string, defaulting to "Date" alone. """ def __init__(self, key_id='', secret='', algorithm=None, headers=None): headers = headers or [] self.header_signer = HeaderSigner( key_id=key_id, secret=secret, algorithm=algorithm, headers=headers) self.uses_host = 'host' in [h.lower() for h in headers] def __call__(self, r): headers = self.header_signer.sign( r.headers, # 'Host' header unavailable in request object at this point # if 'host' header is needed, extract it from the url host=urlparse(r.url).netloc if self.uses_host else None, method=r.method, path=r.path_url) r.headers.update(headers) return r httpsig-1.3.0/httpsig/sign.py000066400000000000000000000101601337757502500162050ustar00rootroot00000000000000import base64 import six from Crypto.Hash import HMAC from Crypto.PublicKey import RSA from Crypto.Signature import PKCS1_v1_5 from .utils import * DEFAULT_SIGN_ALGORITHM = "hmac-sha256" class Signer(object): """ When using an RSA algo, the secret is a PEM-encoded private key. When using an HMAC algo, the secret is the HMAC signing secret. Password-protected keyfiles are not supported. """ def __init__(self, secret, algorithm=None): if algorithm is None: algorithm = DEFAULT_SIGN_ALGORITHM assert algorithm in ALGORITHMS, "Unknown algorithm" if isinstance(secret, six.string_types): secret = secret.encode("ascii") self._rsa = None self._hash = None self.sign_algorithm, self.hash_algorithm = algorithm.split('-') if self.sign_algorithm == 'rsa': try: rsa_key = RSA.importKey(secret) self._rsa = PKCS1_v1_5.new(rsa_key) self._hash = HASHES[self.hash_algorithm] except ValueError: raise HttpSigException("Invalid key.") elif self.sign_algorithm == 'hmac': self._hash = HMAC.new(secret, digestmod=HASHES[self.hash_algorithm]) @property def algorithm(self): return '%s-%s' % (self.sign_algorithm, self.hash_algorithm) def _sign_rsa(self, data): if isinstance(data, six.string_types): data = data.encode("ascii") h = self._hash.new() h.update(data) return self._rsa.sign(h) def _sign_hmac(self, data): if isinstance(data, six.string_types): data = data.encode("ascii") hmac = self._hash.copy() hmac.update(data) return hmac.digest() def sign(self, data): if isinstance(data, six.string_types): data = data.encode("ascii") signed = None if self._rsa: signed = self._sign_rsa(data) elif self._hash: signed = self._sign_hmac(data) if not signed: raise SystemError('No valid encryptor found.') return base64.b64encode(signed).decode("ascii") class HeaderSigner(Signer): """ Generic object that will sign headers as a dictionary using the http-signature scheme. https://github.com/joyent/node-http-signature/blob/master/http_signing.md :arg key_id: the mandatory label indicating to the server which secret to use :arg secret: a PEM-encoded RSA private key or an HMAC secret (must match the algorithm) :arg algorithm: one of the six specified algorithms :arg headers: a list of http headers to be included in the signing string, defaulting to ['date']. :arg sign_header: header used to include signature, defaulting to 'authorization'. """ def __init__(self, key_id, secret, algorithm=None, headers=None, sign_header='authorization'): if algorithm is None: algorithm = DEFAULT_SIGN_ALGORITHM super(HeaderSigner, self).__init__(secret=secret, algorithm=algorithm) self.headers = headers or ['date'] self.signature_template = build_signature_template( key_id, algorithm, headers, sign_header) self.sign_header = sign_header def sign(self, headers, host=None, method=None, path=None): """ Add Signature Authorization header to case-insensitive header dict. `headers` is a case-insensitive dict of mutable headers. `host` is a override for the 'host' header (defaults to value in headers). `method` is the HTTP method (required when using '(request-target)'). `path` is the HTTP path (required when using '(request-target)'). """ headers = CaseInsensitiveDict(headers) required_headers = self.headers or ['date'] signable = generate_message( required_headers, headers, host, method, path) signature = super(HeaderSigner, self).sign(signable) headers[self.sign_header] = self.signature_template % signature return headers httpsig-1.3.0/httpsig/tests/000077500000000000000000000000001337757502500160375ustar00rootroot00000000000000httpsig-1.3.0/httpsig/tests/__init__.py000066400000000000000000000001231337757502500201440ustar00rootroot00000000000000from .test_signature import * from .test_utils import * from .test_verify import * httpsig-1.3.0/httpsig/tests/rsa_private.pem000066400000000000000000000015731337757502500210670ustar00rootroot00000000000000-----BEGIN RSA PRIVATE KEY----- MIICXgIBAAKBgQDCFENGw33yGihy92pDjZQhl0C36rPJj+CvfSC8+q28hxA161QF NUd13wuCTUcq0Qd2qsBe/2hFyc2DCJJg0h1L78+6Z4UMR7EOcpfdUE9Hf3m/hs+F UR45uBJeDK1HSFHD8bHKD6kv8FPGfJTotc+2xjJwoYi+1hqp1fIekaxsyQIDAQAB AoGBAJR8ZkCUvx5kzv+utdl7T5MnordT1TvoXXJGXK7ZZ+UuvMNUCdN2QPc4sBiA QWvLw1cSKt5DsKZ8UETpYPy8pPYnnDEz2dDYiaew9+xEpubyeW2oH4Zx71wqBtOK kqwrXa/pzdpiucRRjk6vE6YY7EBBs/g7uanVpGibOVAEsqH1AkEA7DkjVH28WDUg f1nqvfn2Kj6CT7nIcE3jGJsZZ7zlZmBmHFDONMLUrXR/Zm3pR5m0tCmBqa5RK95u 412jt1dPIwJBANJT3v8pnkth48bQo/fKel6uEYyboRtA5/uHuHkZ6FQF7OUkGogc mSJluOdc5t6hI1VsLn0QZEjQZMEOWr+wKSMCQQCC4kXJEsHAve77oP6HtG/IiEn7 kpyUXRNvFsDE0czpJJBvL/aRFUJxuRK91jhjC68sA7NsKMGg5OXb5I5Jj36xAkEA gIT7aFOYBFwGgQAQkWNKLvySgKbAZRTeLBacpHMuQdl1DfdntvAyqpAZ0lY0RKmW G6aFKaqQfOXKCyWoUiVknQJAXrlgySFci/2ueKlIE1QqIiLSZ8V8OlpFLRnb1pzI 7U1yQXnTAEFYM560yJlzUpOb1V4cScGd365tiSMvxLOvTA== -----END RSA PRIVATE KEY----- httpsig-1.3.0/httpsig/tests/rsa_public.pem000066400000000000000000000004201337757502500206610ustar00rootroot00000000000000-----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDCFENGw33yGihy92pDjZQhl0C3 6rPJj+CvfSC8+q28hxA161QFNUd13wuCTUcq0Qd2qsBe/2hFyc2DCJJg0h1L78+6 Z4UMR7EOcpfdUE9Hf3m/hs+FUR45uBJeDK1HSFHD8bHKD6kv8FPGfJTotc+2xjJw oYi+1hqp1fIekaxsyQIDAQAB -----END PUBLIC KEY----- httpsig-1.3.0/httpsig/tests/test_signature.py000077500000000000000000000103471337757502500214610ustar00rootroot00000000000000#!/usr/bin/env python import sys import os import unittest import httpsig.sign as sign from httpsig.utils import parse_authorization_header sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) sign.DEFAULT_SIGN_ALGORITHM = "rsa-sha256" class TestSign(unittest.TestCase): test_method = 'POST' test_path = '/foo?param=value&pet=dog' header_host = 'example.com' header_date = 'Thu, 05 Jan 2014 21:31:40 GMT' header_content_type = 'application/json' header_digest = 'SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=' header_content_length = '18' def setUp(self): self.key_path = os.path.join( os.path.dirname(__file__), 'rsa_private.pem') with open(self.key_path, 'rb') as f: self.key = f.read() def test_default(self): hs = sign.HeaderSigner(key_id='Test', secret=self.key) unsigned = { 'Date': self.header_date } signed = hs.sign(unsigned) self.assertIn('Date', signed) self.assertEqual(unsigned['Date'], signed['Date']) self.assertIn('Authorization', signed) auth = parse_authorization_header(signed['authorization']) params = auth[1] self.assertIn('keyId', params) self.assertIn('algorithm', params) self.assertIn('signature', params) self.assertEqual(params['keyId'], 'Test') self.assertEqual(params['algorithm'], 'rsa-sha256') self.assertEqual(params['signature'], 'jKyvPcxB4JbmYY4mByyBY7cZfNl4OW9HpFQlG7N4YcJPteKTu4MWCLyk+gIr0wDgqtLWf9NLpMAMimdfsH7FSWGfbMFSrsVTHNTk0rK3usrfFnti1dxsM4jl0kYJCKTGI/UWkqiaxwNiKqGcdlEDrTcUhhsFsOIo8VhddmZTZ8w=') # noqa: E501 def test_basic(self): hs = sign.HeaderSigner(key_id='Test', secret=self.key, headers=[ '(request-target)', 'host', 'date', ]) unsigned = { 'Host': self.header_host, 'Date': self.header_date, } signed = hs.sign( unsigned, method=self.test_method, path=self.test_path) self.assertIn('Date', signed) self.assertEqual(unsigned['Date'], signed['Date']) self.assertIn('Authorization', signed) auth = parse_authorization_header(signed['authorization']) params = auth[1] self.assertIn('keyId', params) self.assertIn('algorithm', params) self.assertIn('signature', params) self.assertEqual(params['keyId'], 'Test') self.assertEqual(params['algorithm'], 'rsa-sha256') self.assertEqual( params['headers'], '(request-target) host date') self.assertEqual(params['signature'], 'HUxc9BS3P/kPhSmJo+0pQ4IsCo007vkv6bUm4Qehrx+B1Eo4Mq5/6KylET72ZpMUS80XvjlOPjKzxfeTQj4DiKbAzwJAb4HX3qX6obQTa00/qPDXlMepD2JtTw33yNnm/0xV7fQuvILN/ys+378Ysi082+4xBQFwvhNvSoVsGv4=') # noqa: E501 def test_all(self): hs = sign.HeaderSigner(key_id='Test', secret=self.key, headers=[ '(request-target)', 'host', 'date', 'content-type', 'digest', 'content-length' ]) unsigned = { 'Host': self.header_host, 'Date': self.header_date, 'Content-Type': self.header_content_type, 'Digest': self.header_digest, 'Content-Length': self.header_content_length, } signed = hs.sign( unsigned, method=self.test_method, path=self.test_path) self.assertIn('Date', signed) self.assertEqual(unsigned['Date'], signed['Date']) self.assertIn('Authorization', signed) auth = parse_authorization_header(signed['authorization']) params = auth[1] self.assertIn('keyId', params) self.assertIn('algorithm', params) self.assertIn('signature', params) self.assertEqual(params['keyId'], 'Test') self.assertEqual(params['algorithm'], 'rsa-sha256') self.assertEqual( params['headers'], '(request-target) host date content-type digest content-length') self.assertEqual(params['signature'], 'Ef7MlxLXoBovhil3AlyjtBwAL9g4TN3tibLj7uuNB3CROat/9KaeQ4hW2NiJ+pZ6HQEOx9vYZAyi+7cmIkmJszJCut5kQLAwuX+Ms/mUFvpKlSo9StS2bMXDBNjOh4Auj774GFj4gwjS+3NhFeoqyr/MuN6HsEnkvn6zdgfE2i0=') # noqa: E501 httpsig-1.3.0/httpsig/tests/test_utils.py000077500000000000000000000010101337757502500206030ustar00rootroot00000000000000#!/usr/bin/env python import os import sys import unittest from httpsig.utils import get_fingerprint sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) class TestUtils(unittest.TestCase): def test_get_fingerprint(self): with open(os.path.join( os.path.dirname(__file__), 'rsa_public.pem'), 'r') as k: key = k.read() fingerprint = get_fingerprint(key) self.assertEqual( fingerprint, "73:61:a2:21:67:e0:df:be:7e:4b:93:1e:15:98:a5:b7") httpsig-1.3.0/httpsig/tests/test_verify.py000077500000000000000000000161341337757502500207640ustar00rootroot00000000000000#!/usr/bin/env python import sys import os import unittest from httpsig.sign import HeaderSigner, Signer from httpsig.verify import HeaderVerifier, Verifier sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) class BaseTestCase(unittest.TestCase): def _parse_auth(self, auth): """Basic Authorization header parsing.""" # split 'Signature kvpairs' s, param_str = auth.split(' ', 1) self.assertEqual(s, 'Signature') # split k1="v1",k2="v2",... param_list = param_str.split(',') # convert into [(k1,"v1"), (k2, "v2"), ...] param_pairs = [p.split('=', 1) for p in param_list] # convert into {k1:v1, k2:v2, ...} param_dict = {k: v.strip('"') for k, v in param_pairs} return param_dict class TestVerifyHMACSHA1(BaseTestCase): test_method = 'POST' test_path = '/foo?param=value&pet=dog' header_host = 'example.com' header_date = 'Thu, 05 Jan 2014 21:31:40 GMT' header_content_type = 'application/json' header_digest = 'SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=' header_content_length = '18' sign_header = 'authorization' def setUp(self): secret = b"something special goes here" self.keyId = "Test" self.algorithm = "hmac-sha1" self.sign_secret = secret self.verify_secret = secret def test_basic_sign(self): signer = Signer(secret=self.sign_secret, algorithm=self.algorithm) verifier = Verifier( secret=self.verify_secret, algorithm=self.algorithm) GOOD = b"this is a test" BAD = b"this is not the signature you were looking for..." # generate signed string signature = signer.sign(GOOD) self.assertTrue(verifier._verify(data=GOOD, signature=signature)) self.assertFalse(verifier._verify(data=BAD, signature=signature)) def test_default(self): unsigned = { 'Date': self.header_date } hs = HeaderSigner( key_id="Test", secret=self.sign_secret, algorithm=self.algorithm, sign_header=self.sign_header) signed = hs.sign(unsigned) hv = HeaderVerifier( headers=signed, secret=self.verify_secret, sign_header=self.sign_header) self.assertTrue(hv.verify()) def test_signed_headers(self): HOST = self.header_host METHOD = self.test_method PATH = self.test_path hs = HeaderSigner( key_id="Test", secret=self.sign_secret, algorithm=self.algorithm, sign_header=self.sign_header, headers=[ '(request-target)', 'host', 'date', 'content-type', 'digest', 'content-length' ]) unsigned = { 'Host': HOST, 'Date': self.header_date, 'Content-Type': self.header_content_type, 'Digest': self.header_digest, 'Content-Length': self.header_content_length, } signed = hs.sign(unsigned, method=METHOD, path=PATH) hv = HeaderVerifier( headers=signed, secret=self.verify_secret, host=HOST, method=METHOD, path=PATH, sign_header=self.sign_header) self.assertTrue(hv.verify()) def test_incorrect_headers(self): HOST = self.header_host METHOD = self.test_method PATH = self.test_path hs = HeaderSigner(secret=self.sign_secret, key_id="Test", algorithm=self.algorithm, sign_header=self.sign_header, headers=[ '(request-target)', 'host', 'date', 'content-type', 'digest', 'content-length']) unsigned = { 'Host': HOST, 'Date': self.header_date, 'Content-Type': self.header_content_type, 'Digest': self.header_digest, 'Content-Length': self.header_content_length, } signed = hs.sign(unsigned, method=METHOD, path=PATH) hv = HeaderVerifier(headers=signed, secret=self.verify_secret, required_headers=["some-other-header"], host=HOST, method=METHOD, path=PATH, sign_header=self.sign_header) with self.assertRaises(Exception): hv.verify() def test_extra_auth_headers(self): HOST = "example.com" METHOD = "POST" PATH = '/foo?param=value&pet=dog' hs = HeaderSigner( key_id="Test", secret=self.sign_secret, sign_header=self.sign_header, algorithm=self.algorithm, headers=[ '(request-target)', 'host', 'date', 'content-type', 'digest', 'content-length' ]) unsigned = { 'Host': HOST, 'Date': self.header_date, 'Content-Type': self.header_content_type, 'Digest': self.header_digest, 'Content-Length': self.header_content_length, } signed = hs.sign(unsigned, method=METHOD, path=PATH) hv = HeaderVerifier( headers=signed, secret=self.verify_secret, method=METHOD, path=PATH, sign_header=self.sign_header, required_headers=['date', '(request-target)']) self.assertTrue(hv.verify()) class TestVerifyHMACSHA256(TestVerifyHMACSHA1): def setUp(self): super(TestVerifyHMACSHA256, self).setUp() self.algorithm = "hmac-sha256" class TestVerifyHMACSHA512(TestVerifyHMACSHA1): def setUp(self): super(TestVerifyHMACSHA512, self).setUp() self.algorithm = "hmac-sha512" class TestVerifyRSASHA1(TestVerifyHMACSHA1): def setUp(self): private_key_path = os.path.join( os.path.dirname(__file__), 'rsa_private.pem') with open(private_key_path, 'rb') as f: private_key = f.read() public_key_path = os.path.join( os.path.dirname(__file__), 'rsa_public.pem') with open(public_key_path, 'rb') as f: public_key = f.read() self.keyId = "Test" self.algorithm = "rsa-sha1" self.sign_secret = private_key self.verify_secret = public_key class TestVerifyRSASHA256(TestVerifyRSASHA1): def setUp(self): super(TestVerifyRSASHA256, self).setUp() self.algorithm = "rsa-sha256" class TestVerifyRSASHA512(TestVerifyRSASHA1): def setUp(self): super(TestVerifyRSASHA512, self).setUp() self.algorithm = "rsa-sha512" class TestVerifyRSASHA512ChangeHeader(TestVerifyRSASHA1): sign_header = 'Signature' httpsig-1.3.0/httpsig/utils.py000066400000000000000000000142101337757502500164050ustar00rootroot00000000000000import base64 import six import re import struct import hashlib try: # Python 3 from urllib.request import parse_http_list except ImportError: # Python 2 from urllib2 import parse_http_list from Crypto.Hash import SHA, SHA256, SHA512 ALGORITHMS = frozenset([ 'rsa-sha1', 'rsa-sha256', 'rsa-sha512', 'hmac-sha1', 'hmac-sha256', 'hmac-sha512']) HASHES = {'sha1': SHA, 'sha256': SHA256, 'sha512': SHA512} class HttpSigException(Exception): pass def ct_bytes_compare(a, b): """ Constant-time string compare. http://codahale.com/a-lesson-in-timing-attacks/ """ if not isinstance(a, six.binary_type): a = a.decode('utf8') if not isinstance(b, six.binary_type): b = b.decode('utf8') if len(a) != len(b): return False result = 0 for x, y in zip(a, b): if six.PY2: result |= ord(x) ^ ord(y) else: result |= x ^ y return (result == 0) def generate_message(required_headers, headers, host=None, method=None, path=None): headers = CaseInsensitiveDict(headers) if not required_headers: required_headers = ['date'] signable_list = [] for h in required_headers: h = h.lower() if h == '(request-target)': if not method or not path: raise Exception('method and path arguments required when ' + 'using "(request-target)"') signable_list.append('%s: %s %s' % (h, method.lower(), path)) elif h == 'host': # 'host' special case due to requests lib restrictions # 'host' is not available when adding auth so must use a param # if no param used, defaults back to the 'host' header if not host: if 'host' in headers: host = headers[h] else: raise Exception('missing required header "%s"' % h) signable_list.append('%s: %s' % (h, host)) else: if h not in headers: raise Exception('missing required header "%s"' % h) signable_list.append('%s: %s' % (h, headers[h])) signable = '\n'.join(signable_list).encode("ascii") return signable def parse_signature_header(sign_value): values = {} if sign_value: # This is tricky string magic. Let urllib do it. fields = parse_http_list(sign_value) for item in fields: # Only include keypairs. if '=' in item: # Split on the first '=' only. key, value = item.split('=', 1) if not (len(key) and len(value)): continue # Unquote values, if quoted. if value[0] == '"': value = value[1:-1] values[key] = value return CaseInsensitiveDict(values) def parse_authorization_header(header): if not isinstance(header, six.string_types): header = header.decode("ascii") # HTTP headers cannot be Unicode. auth = header.split(" ", 1) if len(auth) > 2: raise ValueError('Invalid authorization header. (eg. Method ' + 'key1=value1,key2="value, \"2\"")') # Split up any args into a dictionary. values = {} if len(auth) == 2: values = parse_signature_header(auth[1]) # ("Signature", {"headers": "date", "algorithm": "hmac-sha256", ... }) return (auth[0], values) def build_signature_template(key_id, algorithm, headers, sign_header='authorization'): """ Build the Signature template for use with the Authorization header. key_id is the mandatory label indicating to the server which secret to use algorithm is one of the six specified algorithms headers is a list of http headers to be included in the signing string. The signature must be interpolated into the template to get the final Authorization header value. """ param_map = {'keyId': key_id, 'algorithm': algorithm, 'signature': '%s'} if headers: headers = [h.lower() for h in headers] param_map['headers'] = ' '.join(headers) kv = map('{0[0]}="{0[1]}"'.format, param_map.items()) kv_string = ','.join(kv) if sign_header.lower() == 'authorization': return 'Signature {0}'.format(kv_string) return kv_string def lkv(d): parts = [] while d: length = struct.unpack('>I', d[:4])[0] bits = d[4:length+4] parts.append(bits) d = d[length+4:] return parts def sig(d): return lkv(d)[1] def is_rsa(keyobj): return lkv(keyobj.blob)[0] == "ssh-rsa" # based on http://stackoverflow.com/a/2082169/151401 class CaseInsensitiveDict(dict): """ A case-insensitive dictionary for header storage. A limitation of this approach is the inability to store multiple instances of the same header. If that is changed then we suddenly care about the assembly rules in sec 2.3. """ def __init__(self, d=None, **kwargs): super(CaseInsensitiveDict, self).__init__(**kwargs) if d: self.update((k.lower(), v) for k, v in six.iteritems(d)) def __setitem__(self, key, value): super(CaseInsensitiveDict, self).__setitem__(key.lower(), value) def __getitem__(self, key): return super(CaseInsensitiveDict, self).__getitem__(key.lower()) def __contains__(self, key): return super(CaseInsensitiveDict, self).__contains__(key.lower()) # currently busted... def get_fingerprint(key): """ Takes an ssh public key and generates the fingerprint. See: http://tools.ietf.org/html/rfc4716 for more info """ if key.startswith('ssh-rsa'): key = key.split(' ')[1] else: regex = r'\-{4,5}[\w|| ]+\-{4,5}' key = re.split(regex, key)[1] key = key.replace('\n', '') key = key.strip().encode('ascii') key = base64.b64decode(key) fp_plain = hashlib.md5(key).hexdigest() return ':'.join(a+b for a, b in zip(fp_plain[::2], fp_plain[1::2])) httpsig-1.3.0/httpsig/verify.py000066400000000000000000000077001337757502500165570ustar00rootroot00000000000000""" Module to assist in verifying a signed header. """ import base64 import six from .sign import Signer from .utils import * class Verifier(Signer): """ Verifies signed text against a secret. For HMAC, the secret is the shared secret. For RSA, the secret is the PUBLIC key. """ def _verify(self, data, signature): """ Verifies the data matches a signed version with the given signature. `data` is the message to verify `signature` is a base64-encoded signature to verify against `data` """ if isinstance(data, six.string_types): data = data.encode("ascii") if isinstance(signature, six.string_types): signature = signature.encode("ascii") if self.sign_algorithm == 'rsa': h = self._hash.new() h.update(data) return self._rsa.verify(h, base64.b64decode(signature)) elif self.sign_algorithm == 'hmac': h = self._sign_hmac(data) s = base64.b64decode(signature) return ct_bytes_compare(h, s) else: raise HttpSigException("Unsupported algorithm.") class HeaderVerifier(Verifier): """ Verifies an HTTP signature from given headers. """ def __init__(self, headers, secret, required_headers=None, method=None, path=None, host=None, sign_header='authorization'): """ Instantiate a HeaderVerifier object. :param headers: A dictionary of headers from the HTTP request. :param secret: The HMAC secret or RSA *public* key. :param required_headers: Optional. A list of headers required to be present to validate, even if the signature is otherwise valid. Defaults to ['date']. :param method: Optional. The HTTP method used in the request (eg. "GET"). Required for the '(request-target)' header. :param path: Optional. The HTTP path requested, exactly as sent (including query arguments and fragments). Required for the '(request-target)' header. :param host: Optional. The value to use for the Host header, if not supplied in :param:headers. :param sign_header: Optional. The header where the signature is. Default is 'authorization'. """ required_headers = required_headers or ['date'] self.headers = CaseInsensitiveDict(headers) if sign_header.lower() == 'authorization': auth = parse_authorization_header(self.headers['authorization']) if len(auth) == 2: self.auth_dict = auth[1] else: raise HttpSigException("Invalid authorization header.") else: self.auth_dict = parse_signature_header(self.headers[sign_header]) self.required_headers = [s.lower() for s in required_headers] self.method = method self.path = path self.host = host super(HeaderVerifier, self).__init__( secret, algorithm=self.auth_dict['algorithm']) def verify(self): """ Verify the headers based on the arguments passed at creation and current properties. Raises an Exception if a required header (:param:required_headers) is not found in the signature. Returns True or False. """ auth_headers = self.auth_dict.get('headers', 'date').split(' ') if len(set(self.required_headers) - set(auth_headers)) > 0: error_headers = ', '.join( set(self.required_headers) - set(auth_headers)) raise Exception( '{} is a required header(s)'.format(error_headers)) signing_str = generate_message( auth_headers, self.headers, self.host, self.method, self.path) return self._verify(signing_str, self.auth_dict['signature']) httpsig-1.3.0/requirements.txt000066400000000000000000000000301337757502500164700ustar00rootroot00000000000000pycryptodome==3.6.1 six httpsig-1.3.0/requirements_dev.txt000066400000000000000000000000211337757502500173260ustar00rootroot00000000000000setuptools wheel httpsig-1.3.0/setup.cfg000066400000000000000000000000371337757502500150340ustar00rootroot00000000000000[bdist_wheel] universal = True httpsig-1.3.0/setup.py000077500000000000000000000026651337757502500147410ustar00rootroot00000000000000#!/usr/bin/env python from setuptools import setup, find_packages # create long description with open('README.rst') as file: long_description = file.read() with open('CHANGELOG.rst') as file: long_description += '\n\n' + file.read() setup( name='httpsig', description="Secure HTTP request signing using the HTTP Signature draft specification", long_description=long_description, classifiers=[ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Topic :: Internet :: WWW/HTTP", "Topic :: Software Development :: Libraries :: Python Modules", ], keywords='http,authorization,api,web', author='Adam Knight', author_email='adam@movq.us', url='https://github.com/ahknight/httpsig', license='MIT', packages=find_packages(), include_package_data=True, zip_safe=True, use_scm_version=True, setup_requires=['setuptools_scm'], install_requires=['pycryptodome>=3,<4', 'six'], test_suite="httpsig.tests", ) httpsig-1.3.0/tox.ini000066400000000000000000000001301337757502500145200ustar00rootroot00000000000000[tox] envlist = py27, py34, py35, py36, py37 [testenv] commands = python setup.py test