From b04b223c2105c7feb0d5a8769245abad0d722984 Mon Sep 17 00:00:00 2001 From: ian douglas Date: Wed, 6 Dec 2017 22:08:44 -0700 Subject: [PATCH 01/14] Switched from PyCrypto to pycryptodome --- README.rst | 4 ++-- requirements.txt | 2 +- setup.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index ca0674a..ae4041e 100644 --- a/README.rst +++ b/README.rst @@ -21,13 +21,13 @@ Requirements ------------ * Python 2.7, 3.2, 3.3, 3.4 -* PyCrypto_ +* pycryptodome_ Optional: * requests_ -.. _PyCrypto: https://pypi.python.org/pypi/pycrypto +.. _pycryptodome: https://pypi.python.org/pypi/pycryptodome .. _requests: https://pypi.python.org/pypi/requests Usage diff --git a/requirements.txt b/requirements.txt index 1741886..a486ab9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -PyCrypto +pycryptodome==3.4.7 six diff --git a/setup.py b/setup.py index 82cf121..bb0189e 100755 --- a/setup.py +++ b/setup.py @@ -42,6 +42,6 @@ packages=find_packages(), include_package_data=True, zip_safe=True, - install_requires=['pycrypto', 'six'], + install_requires=['pycryptodome==3.4.7', 'six'], test_suite="httpsig.tests", ) From e3480566afca25864b7aed361177ee5ae12697d7 Mon Sep 17 00:00:00 2001 From: Adam Knight Date: Tue, 27 Mar 2018 14:45:43 -0500 Subject: [PATCH 02/14] =?UTF-8?q?Dropped=20Python=203.2=20(pip=20dropped?= =?UTF-8?q?=20it;=20can=E2=80=99t=20test=20it)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.rst | 10 ++++++++-- setup.py | 3 ++- tox.ini | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index ae4041e..101aa35 100644 --- a/README.rst +++ b/README.rst @@ -20,8 +20,8 @@ See the original project_, original Python module_, original spec_, and `current Requirements ------------ -* Python 2.7, 3.2, 3.3, 3.4 -* pycryptodome_ +* Python 2.7, 3.3, 3.4, 3.5, 3.6 +* PyCrypto_ Optional: @@ -30,6 +30,12 @@ Optional: .. _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.14 3.3.7 3.4.8 3.5.5 3.6.4; do pyenv install -s $VERS; done + Usage ----- diff --git a/setup.py b/setup.py index bb0189e..0ebb130 100755 --- a/setup.py +++ b/setup.py @@ -28,9 +28,10 @@ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3.2", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", "Topic :: Internet :: WWW/HTTP", "Topic :: Software Development :: Libraries :: Python Modules", ], diff --git a/tox.ini b/tox.ini index 5add957..039921d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27, py32, py33, py34 +envlist = py27, py33, py34, py35, py36 [testenv] commands = python setup.py test From 959ae3d2a3641f3757c4d07a045add2db0e8ba69 Mon Sep 17 00:00:00 2001 From: Adam Knight Date: Tue, 27 Mar 2018 19:50:57 -0500 Subject: [PATCH 03/14] Updated tests with the test data from Draft 8. --- README.rst | 19 ++++++++--- httpsig/tests/test_signature.py | 57 ++++++++++++++++++++++++++------- httpsig/tests/test_verify.py | 52 +++++++++++++++++------------- httpsig/utils.py | 5 +++ 4 files changed, 94 insertions(+), 39 deletions(-) diff --git a/README.rst b/README.rst index 101aa35..f5d0565 100644 --- a/README.rst +++ b/README.rst @@ -7,7 +7,7 @@ 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 3`_). 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. +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. @@ -15,19 +15,19 @@ See the original project_, original Python module_, original spec_, and `current .. _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 3`: http://tools.ietf.org/html/draft-cavage-http-signatures-03 +.. _`Draft 8`: http://tools.ietf.org/html/draft-cavage-http-signatures-08 Requirements ------------ -* Python 2.7, 3.3, 3.4, 3.5, 3.6 -* PyCrypto_ +* Python 2.7, 3.3-3.6 +* PyCryptodome_ Optional: * requests_ -.. _pycryptodome: https://pypi.python.org/pypi/pycryptodome +.. _PyCryptodome: https://pypi.python.org/pypi/pycryptodome .. _requests: https://pypi.python.org/pypi/requests For testing: @@ -111,6 +111,15 @@ 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 the Signature header. As this was principally designed to be an authentication helper, that header is not currently supported. PRs welcome. (It is trivial to move the value after generation, of course.) +4. Draft 2 added support for ecdsa-sha256. This is available in PyCryptodome but has not been added to httpsig. PRs welcome. + + License ------- diff --git a/httpsig/tests/test_signature.py b/httpsig/tests/test_signature.py index 00ed29d..d65328f 100755 --- a/httpsig/tests/test_signature.py +++ b/httpsig/tests/test_signature.py @@ -14,7 +14,14 @@ 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: @@ -23,7 +30,7 @@ def setUp(self): def test_default(self): hs = sign.HeaderSigner(key_id='Test', secret=self.key) unsigned = { - 'Date': 'Thu, 05 Jan 2012 21:31:40 GMT' + 'Date': self.header_date } signed = hs.sign(unsigned) self.assertIn('Date', signed) @@ -36,7 +43,33 @@ def test_default(self): self.assertIn('signature', params) self.assertEqual(params['keyId'], 'Test') self.assertEqual(params['algorithm'], 'rsa-sha256') - self.assertEqual(params['signature'], 'ATp0r26dbMIxOopqw0OfABDT7CKMIoENumuruOtarj8n/97Q3htHFYpH8yOSQk3Z5zh8UxUym6FYTb5+A0Nz3NRsXJibnYi7brE/4tx5But9kkFGzG+xpUmimN4c3TMN7OFH//+r8hBf7BT9/GmHDUVZT2JzWGLZES2xDOUuMtA=') + self.assertEqual(params['signature'], 'jKyvPcxB4JbmYY4mByyBY7cZfNl4OW9HpFQlG7N4YcJPteKTu4MWCLyk+gIr0wDgqtLWf9NLpMAMimdfsH7FSWGfbMFSrsVTHNTk0rK3usrfFnti1dxsM4jl0kYJCKTGI/UWkqiaxwNiKqGcdlEDrTcUhhsFsOIo8VhddmZTZ8w=') + + 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=') def test_all(self): hs = sign.HeaderSigner(key_id='Test', secret=self.key, headers=[ @@ -44,17 +77,17 @@ def test_all(self): 'host', 'date', 'content-type', - 'content-md5', + 'digest', 'content-length' ]) unsigned = { - 'Host': 'example.com', - 'Date': 'Thu, 05 Jan 2012 21:31:40 GMT', - 'Content-Type': 'application/json', - 'Content-MD5': 'Sd/dVLAcvNLSq16eXua5uQ==', - 'Content-Length': '18', + '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='POST', path='/foo?param=value&pet=dog') + signed = hs.sign(unsigned, method=self.test_method, path=self.test_path) self.assertIn('Date', signed) self.assertEqual(unsigned['Date'], signed['Date']) @@ -66,5 +99,5 @@ def test_all(self): 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 content-md5 content-length') - self.assertEqual(params['signature'], 'G8/Uh6BBDaqldRi3VfFfklHSFoq8CMt5NUZiepq0q66e+fS3Up3BmXn0NbUnr3L1WgAAZGplifRAJqp2LgeZ5gXNk6UX9zV3hw5BERLWscWXlwX/dvHQES27lGRCvyFv3djHP6Plfd5mhPWRkmjnvqeOOSS0lZJYFYHJz994s6w=') + 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=') diff --git a/httpsig/tests/test_verify.py b/httpsig/tests/test_verify.py index f49eeb3..4068bc6 100755 --- a/httpsig/tests/test_verify.py +++ b/httpsig/tests/test_verify.py @@ -25,6 +25,14 @@ def _parse_auth(self, auth): 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' + def setUp(self): secret = b"something special goes here" @@ -47,7 +55,7 @@ def test_basic_sign(self): def test_default(self): unsigned = { - 'Date': 'Thu, 05 Jan 2012 21:31:40 GMT' + 'Date': self.header_date } hs = HeaderSigner(key_id="Test", secret=self.sign_secret, algorithm=self.algorithm) @@ -56,23 +64,23 @@ def test_default(self): self.assertTrue(hv.verify()) def test_signed_headers(self): - HOST = "example.com" - METHOD = "POST" - PATH = '/foo?param=value&pet=dog' + HOST = self.header_host + METHOD = self.test_method + PATH = self.test_path hs = HeaderSigner(key_id="Test", secret=self.sign_secret, algorithm=self.algorithm, headers=[ '(request-target)', 'host', 'date', 'content-type', - 'content-md5', + 'digest', 'content-length' ]) unsigned = { 'Host': HOST, - 'Date': 'Thu, 05 Jan 2012 21:31:40 GMT', - 'Content-Type': 'application/json', - 'Content-MD5': 'Sd/dVLAcvNLSq16eXua5uQ==', - 'Content-Length': '18', + '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) @@ -80,9 +88,9 @@ def test_signed_headers(self): self.assertTrue(hv.verify()) def test_incorrect_headers(self): - HOST = "example.com" - METHOD = "POST" - PATH = '/foo?param=value&pet=dog' + HOST = self.header_host + METHOD = self.test_method + PATH = self.test_path hs = HeaderSigner(secret=self.sign_secret, key_id="Test", algorithm=self.algorithm, @@ -91,14 +99,14 @@ def test_incorrect_headers(self): 'host', 'date', 'content-type', - 'content-md5', + 'digest', 'content-length']) unsigned = { 'Host': HOST, - 'Date': 'Thu, 05 Jan 2012 21:31:40 GMT', - 'Content-Type': 'application/json', - 'Content-MD5': 'Sd/dVLAcvNLSq16eXua5uQ==', - 'Content-Length': '18', + '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) @@ -115,15 +123,15 @@ def test_extra_auth_headers(self): 'host', 'date', 'content-type', - 'content-md5', + 'digest', 'content-length' ]) unsigned = { 'Host': HOST, - 'Date': 'Thu, 05 Jan 2012 21:31:40 GMT', - 'Content-Type': 'application/json', - 'Content-MD5': 'Sd/dVLAcvNLSq16eXua5uQ==', - 'Content-Length': '18', + '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, required_headers=['date', '(request-target)']) diff --git a/httpsig/utils.py b/httpsig/utils.py index b34e3fa..0284cbc 100644 --- a/httpsig/utils.py +++ b/httpsig/utils.py @@ -151,6 +151,11 @@ def is_rsa(keyobj): # 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: From e5e42a5effd2ab8dbb3b727927de257459bf8b8d Mon Sep 17 00:00:00 2001 From: Adam Knight Date: Wed, 28 Mar 2018 09:49:37 -0500 Subject: [PATCH 04/14] Code formatting cleanup --- CHANGELOG.rst | 8 +++++ httpsig/_version.py | 37 ++++++++++---------- httpsig/requests_auth.py | 19 +++++----- httpsig/sign.py | 59 ++++++++++++++++++------------- httpsig/tests/__init__.py | 2 +- httpsig/tests/test_signature.py | 27 ++++++++------ httpsig/tests/test_utils.py | 13 +++---- httpsig/tests/test_verify.py | 43 +++++++++++++---------- httpsig/utils.py | 54 +++++++++++++++++----------- httpsig/verify.py | 62 ++++++++++++++++++++------------- 10 files changed, 192 insertions(+), 132 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f78e213..8aa5c65 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,14 @@ httpsig Changes --------------- +1.2.0 (2018-Mar-28) +------------------- + +* Switched to pycryptodome instead of PyCrypto +* 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) ------------------- diff --git a/httpsig/_version.py b/httpsig/_version.py index b1a0acd..cf03123 100644 --- a/httpsig/_version.py +++ b/httpsig/_version.py @@ -1,4 +1,3 @@ - # This file helps to compute a version number in source trees obtained from # git-archive tarball (such as those provided by githubs download-from-tag # feature). Distribution tarballs (build by setup.py sdist) and build @@ -8,16 +7,17 @@ # This file is released into the public domain. Generated by # versioneer-0.10 (https://github.com/warner/python-versioneer) +import errno +import os.path +import re +import subprocess +import sys + # these strings will be replaced by git during git-archive git_refnames = "$Format:%d$" git_full = "$Format:%H$" -import subprocess -import sys -import errno - - def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False): assert isinstance(commands, list) p = None @@ -50,10 +50,6 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False): return stdout -import sys -import re -import os.path - def get_expanded_variables(versionfile_abs): # the code embedded in _version.py can just fetch the value of these # variables. When used from setup.py, we don't want to import @@ -61,7 +57,7 @@ def get_expanded_variables(versionfile_abs): # used from _version.py. variables = {} try: - f = open(versionfile_abs,"r") + f = open(versionfile_abs, "r") for line in f.readlines(): if line.strip().startswith("git_refnames ="): mo = re.search(r'=\s*"(.*)"', line) @@ -76,12 +72,13 @@ def get_expanded_variables(versionfile_abs): pass return variables + def versions_from_expanded_variables(variables, tag_prefix, verbose=False): refnames = variables["refnames"].strip() if refnames.startswith("$Format"): if verbose: print("variables are unexpanded, not using") - return {} # unexpanded, so not in an unpacked git-archive tarball + return {} # unexpanded, so not in an unpacked git-archive tarball refs = set([r.strip() for r in refnames.strip("()").split(",")]) # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of # just "foo-1.0". If we see a "tag: " prefix, prefer those. @@ -97,7 +94,7 @@ def versions_from_expanded_variables(variables, tag_prefix, verbose=False): # "stabilization", as well as "HEAD" and "master". tags = set([r for r in refs if re.search(r'\d', r)]) if verbose: - print("discarding '%s', no digits" % ",".join(refs-tags)) + print("discarding '%s', no digits" % ",".join(refs - tags)) if verbose: print("likely tags: %s" % ",".join(sorted(tags))) for ref in sorted(tags): @@ -106,13 +103,14 @@ def versions_from_expanded_variables(variables, tag_prefix, verbose=False): r = ref[len(tag_prefix):] if verbose: print("picking %s" % r) - return { "version": r, - "full": variables["full"].strip() } + return {"version": r, + "full": variables["full"].strip()} # no suitable tags, so we use the full revision id if verbose: print("no suitable tags, using full revision id") - return { "version": variables["full"].strip(), - "full": variables["full"].strip() } + return {"version": variables["full"].strip(), + "full": variables["full"].strip()} + def versions_from_vcs(tag_prefix, root, verbose=False): # this runs 'git' from the root of the source tree. This only gets called @@ -157,17 +155,19 @@ def versions_from_parentdir(parentdir_prefix, root, verbose=False): return None return {"version": dirname[len(parentdir_prefix):], "full": ""} + tag_prefix = "v" parentdir_prefix = "httpsig-" versionfile_source = "httpsig/_version.py" + def get_versions(default={"version": "unknown", "full": ""}, verbose=False): # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have # __file__, we can work backwards from there to the root. Some # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which # case we can only use expanded variables. - variables = { "refnames": git_refnames, "full": git_full } + variables = {"refnames": git_refnames, "full": git_full} ver = versions_from_expanded_variables(variables, tag_prefix, verbose) if ver: return ver @@ -185,4 +185,3 @@ def get_versions(default={"version": "unknown", "full": ""}, verbose=False): return (versions_from_vcs(tag_prefix, root, verbose) or versions_from_parentdir(parentdir_prefix, root, verbose) or default) - diff --git a/httpsig/requests_auth.py b/httpsig/requests_auth.py index 6a02896..1024be5 100644 --- a/httpsig/requests_auth.py +++ b/httpsig/requests_auth.py @@ -1,4 +1,4 @@ -from requests.auth import AuthBase +import requests.auth try: # Python 3 from urllib.parse import urlparse @@ -9,20 +9,23 @@ from .sign import HeaderSigner -class HTTPSignatureAuth(AuthBase): +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. + `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.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): diff --git a/httpsig/sign.py b/httpsig/sign.py index 7125035..2ceb646 100644 --- a/httpsig/sign.py +++ b/httpsig/sign.py @@ -15,20 +15,21 @@ 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") - + 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) @@ -36,28 +37,32 @@ def __init__(self, secret, algorithm=None): 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]) + 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") + 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") + 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") + if isinstance(data, six.string_types): + data = data.encode("ascii") signed = None if self._rsa: signed = self._sign_rsa(data) @@ -70,37 +75,43 @@ def _sign(self, data): class HeaderSigner(Signer): ''' - Generic object that will sign headers as a dictionary using the http-signature scheme. + 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 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 headers: a list of http headers to be included in the signing + string, defaulting to ['date']. ''' def __init__(self, key_id, secret, algorithm=None, headers=None): 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) + self.signature_template = build_signature_template( + key_id, algorithm, headers) 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` 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) - + signable = generate_message( + required_headers, headers, host, method, path) + signature = self._sign(signable) headers['authorization'] = self.signature_template % signature - - return headers + return headers diff --git a/httpsig/tests/__init__.py b/httpsig/tests/__init__.py index 72d4383..d9018eb 100644 --- a/httpsig/tests/__init__.py +++ b/httpsig/tests/__init__.py @@ -1,3 +1,3 @@ from .test_signature import * from .test_utils import * -from .test_verify import * \ No newline at end of file +from .test_verify import * diff --git a/httpsig/tests/test_signature.py b/httpsig/tests/test_signature.py index d65328f..9f948a7 100755 --- a/httpsig/tests/test_signature.py +++ b/httpsig/tests/test_signature.py @@ -1,15 +1,15 @@ #!/usr/bin/env python import sys import os -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) -import json 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" @@ -21,9 +21,10 @@ class TestSign(unittest.TestCase): 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') + 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() @@ -55,8 +56,9 @@ def test_basic(self): 'Host': self.header_host, 'Date': self.header_date, } - signed = hs.sign(unsigned, method=self.test_method, path=self.test_path) - + 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) @@ -67,8 +69,8 @@ def test_basic(self): 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['headers'], '(request-target) host date') self.assertEqual(params['signature'], 'HUxc9BS3P/kPhSmJo+0pQ4IsCo007vkv6bUm4Qehrx+B1Eo4Mq5/6KylET72ZpMUS80XvjlOPjKzxfeTQj4DiKbAzwJAb4HX3qX6obQTa00/qPDXlMepD2JtTw33yNnm/0xV7fQuvILN/ys+378Ysi082+4xBQFwvhNvSoVsGv4=') def test_all(self): @@ -87,8 +89,9 @@ def test_all(self): 'Digest': self.header_digest, 'Content-Length': self.header_content_length, } - signed = hs.sign(unsigned, method=self.test_method, path=self.test_path) - + 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) @@ -99,5 +102,7 @@ def test_all(self): 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['headers'], + '(request-target) host date content-type digest content-length') self.assertEqual(params['signature'], 'Ef7MlxLXoBovhil3AlyjtBwAL9g4TN3tibLj7uuNB3CROat/9KaeQ4hW2NiJ+pZ6HQEOx9vYZAyi+7cmIkmJszJCut5kQLAwuX+Ms/mUFvpKlSo9StS2bMXDBNjOh4Auj774GFj4gwjS+3NhFeoqyr/MuN6HsEnkvn6zdgfE2i0=') diff --git a/httpsig/tests/test_utils.py b/httpsig/tests/test_utils.py index 6d79f69..aa53acd 100755 --- a/httpsig/tests/test_utils.py +++ b/httpsig/tests/test_utils.py @@ -1,17 +1,18 @@ #!/usr/bin/env python import os -import re import sys -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) - 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: + 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") + self.assertEqual( + fingerprint, "73:61:a2:21:67:e0:df:be:7e:4b:93:1e:15:98:a5:b7") diff --git a/httpsig/tests/test_verify.py b/httpsig/tests/test_verify.py index 4068bc6..3a2ad24 100755 --- a/httpsig/tests/test_verify.py +++ b/httpsig/tests/test_verify.py @@ -1,14 +1,14 @@ #!/usr/bin/env python import sys import os -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) - -import json 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.""" @@ -23,7 +23,7 @@ def _parse_auth(self, auth): 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' @@ -35,19 +35,19 @@ class TestVerifyHMACSHA1(BaseTestCase): 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)) @@ -57,7 +57,7 @@ def test_default(self): unsigned = { 'Date': self.header_date } - + hs = HeaderSigner(key_id="Test", secret=self.sign_secret, algorithm=self.algorithm) signed = hs.sign(unsigned) hv = HeaderVerifier(headers=signed, secret=self.verify_secret) @@ -83,7 +83,7 @@ def test_signed_headers(self): '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) self.assertTrue(hv.verify()) @@ -95,12 +95,12 @@ def test_incorrect_headers(self): key_id="Test", algorithm=self.algorithm, headers=[ - '(request-target)', - 'host', - 'date', - 'content-type', - 'digest', - 'content-length']) + '(request-target)', + 'host', + 'date', + 'content-type', + 'digest', + 'content-length']) unsigned = { 'Host': HOST, 'Date': self.header_date, @@ -110,7 +110,8 @@ def test_incorrect_headers(self): } 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) + hv = HeaderVerifier(headers=signed, secret=self.verify_secret, required_headers=["some-other-header"], + host=HOST, method=METHOD, path=PATH) with self.assertRaises(Exception) as ex: hv.verify() @@ -134,7 +135,8 @@ def test_extra_auth_headers(self): '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, required_headers=['date', '(request-target)']) + hv = HeaderVerifier(headers=signed, secret=self.verify_secret, method=METHOD, path=PATH, + required_headers=['date', '(request-target)']) self.assertTrue(hv.verify()) @@ -143,6 +145,7 @@ def setUp(self): super(TestVerifyHMACSHA256, self).setUp() self.algorithm = "hmac-sha256" + class TestVerifyHMACSHA512(TestVerifyHMACSHA1): def setUp(self): super(TestVerifyHMACSHA512, self).setUp() @@ -154,21 +157,23 @@ 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() diff --git a/httpsig/utils.py b/httpsig/utils.py index 0284cbc..2bc3ac5 100644 --- a/httpsig/utils.py +++ b/httpsig/utils.py @@ -14,7 +14,13 @@ from Crypto.PublicKey import RSA from Crypto.Hash import SHA, SHA256, SHA512 -ALGORITHMS = frozenset(['rsa-sha1', 'rsa-sha256', 'rsa-sha512', 'hmac-sha1', 'hmac-sha256', 'hmac-sha512']) +ALGORITHMS = frozenset([ + 'rsa-sha1', + 'rsa-sha256', + 'rsa-sha512', + 'hmac-sha1', + 'hmac-sha256', + 'hmac-sha512']) HASHES = {'sha1': SHA, 'sha256': SHA256, 'sha512': SHA512} @@ -23,11 +29,12 @@ class HttpSigException(Exception): pass -""" -Constant-time string compare. -http://codahale.com/a-lesson-in-timing-attacks/ -""" + 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): @@ -42,15 +49,17 @@ def ct_bytes_compare(a, b): result |= ord(x) ^ ord(y) else: result |= x ^ y - + return (result == 0) -def generate_message(required_headers, headers, host=None, method=None, path=None): + +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() @@ -58,7 +67,7 @@ def generate_message(required_headers, headers, host=None, method=None, path=Non 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 @@ -81,12 +90,12 @@ def generate_message(required_headers, headers, host=None, method=None, path=Non def parse_authorization_header(header): if not isinstance(header, six.string_types): - header = header.decode("ascii") #HTTP headers cannot be Unicode. - + 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: @@ -94,7 +103,7 @@ def parse_authorization_header(header): if auth_value and len(auth_value): # This is tricky string magic. Let urllib do it. fields = parse_http_list(auth_value) - + for item in fields: # Only include keypairs. if '=' in item: @@ -102,16 +111,17 @@ def parse_authorization_header(header): 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 - + # ("Signature", {"headers": "date", "algorithm": "hmac-sha256", ... }) return (auth[0], CaseInsensitiveDict(values)) + def build_signature_template(key_id, algorithm, headers): """ Build the Signature template for use with the Authorization header. @@ -120,7 +130,8 @@ def build_signature_template(key_id, algorithm, headers): 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. + The signature must be interpolated into the template to get the final + Authorization header value. """ param_map = {'keyId': key_id, 'algorithm': algorithm, @@ -143,12 +154,15 @@ def lkv(d): d = d[len+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. @@ -170,6 +184,7 @@ def __getitem__(self, key): def __contains__(self, key): return super(CaseInsensitiveDict, self).__contains__(key.lower()) + # currently busted... def get_fingerprint(key): """ @@ -187,5 +202,4 @@ def get_fingerprint(key): 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])) - + return ':'.join(a+b for a, b in zip(fp_plain[::2], fp_plain[1::2])) diff --git a/httpsig/verify.py b/httpsig/verify.py index a6e1ba3..9b34f51 100644 --- a/httpsig/verify.py +++ b/httpsig/verify.py @@ -24,20 +24,22 @@ def _verify(self, data, 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 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, b64decode(signature)) - + elif self.sign_algorithm == 'hmac': h = self._sign_hmac(data) s = b64decode(signature) return ct_bytes_compare(h, s) - + else: raise HttpSigException("Unsupported algorithm.") @@ -46,45 +48,57 @@ class HeaderVerifier(Verifier): """ Verifies an HTTP signature from given headers. """ - def __init__(self, headers, secret, required_headers=None, method=None, path=None, host=None): + def __init__(self, headers, secret, required_headers=None, method=None, + path=None, host=None): """ Instantiate a HeaderVerifier object. - - :param headers: A dictionary of headers from the HTTP request. + + :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 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. """ required_headers = required_headers or ['date'] - + auth = parse_authorization_header(headers['authorization']) if len(auth) == 2: self.auth_dict = auth[1] else: raise HttpSigException("Invalid authorization header.") - + self.headers = CaseInsensitiveDict(headers) 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']) + + 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. + 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: raise Exception('{} is a required header(s)'.format(', '.join(set(self.required_headers)-set(auth_headers)))) - - signing_str = generate_message(auth_headers, self.headers, self.host, self.method, self.path) - + + signing_str = generate_message( + auth_headers, self.headers, self.host, self.method, self.path) + return self._verify(signing_str, self.auth_dict['signature']) From e4be2742567b4bb5bfa401de7674a0516ef7ac70 Mon Sep 17 00:00:00 2001 From: Adam Knight Date: Wed, 28 Mar 2018 11:11:46 -0500 Subject: [PATCH 05/14] More PEP8 cleanup. --- .gitignore | 2 + README.rst | 2 +- httpsig/__init__.py | 2 + httpsig/_version.py | 4 +- httpsig/requests_auth.py | 4 +- httpsig/sign.py | 4 +- httpsig/tests/test_signature.py | 6 +-- httpsig/tests/test_verify.py | 77 ++++++++++++++++++++++----------- httpsig/utils.py | 21 ++++----- httpsig/verify.py | 21 ++++----- 10 files changed, 88 insertions(+), 55 deletions(-) diff --git a/.gitignore b/.gitignore index 633bd66..4667212 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ doc/__build/* *_rsa.pub locale/ pip-log.txt +/.idea +/.eggs diff --git a/README.rst b/README.rst index f5d0565..57f5e9d 100644 --- a/README.rst +++ b/README.rst @@ -34,7 +34,7 @@ For testing: * tox * pyenv (optional, handy way to access multiple versions) - $ for VERS in 2.7.14 3.3.7 3.4.8 3.5.5 3.6.4; do pyenv install -s $VERS; done + $ for VERS in 2.7.14 3.3.7 3.4.8 3.5.5 3.6.4; do pyenv install -s $VERS; done Usage ----- diff --git a/httpsig/__init__.py b/httpsig/__init__.py index b4a758c..d6f960a 100644 --- a/httpsig/__init__.py +++ b/httpsig/__init__.py @@ -4,3 +4,5 @@ from ._version import get_versions __version__ = get_versions()['version'] del get_versions + +__all__ = (Signer, HeaderSigner, Verifier, HeaderVerifier) diff --git a/httpsig/_version.py b/httpsig/_version.py index cf03123..95a31be 100644 --- a/httpsig/_version.py +++ b/httpsig/_version.py @@ -132,7 +132,7 @@ def versions_from_vcs(tag_prefix, root, verbose=False): return {} if not stdout.startswith(tag_prefix): if verbose: - print("tag '%s' doesn't start with prefix '%s'" % (stdout, tag_prefix)) + print("tag '%s' doesn't start with prefix '%s'" % (stdout, tag_prefix)) # noqa: E501 return {} tag = stdout[len(tag_prefix):] stdout = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) @@ -150,7 +150,7 @@ def versions_from_parentdir(parentdir_prefix, root, verbose=False): dirname = os.path.basename(root) if not dirname.startswith(parentdir_prefix): if verbose: - print("guessing rootdir is '%s', but '%s' doesn't start with prefix '%s'" % + print("guessing rootdir is '%s', but '%s' doesn't start with prefix '%s'" % # noqa: E501 (root, dirname, parentdir_prefix)) return None return {"version": dirname[len(parentdir_prefix):], "full": ""} diff --git a/httpsig/requests_auth.py b/httpsig/requests_auth.py index 1024be5..8a00310 100644 --- a/httpsig/requests_auth.py +++ b/httpsig/requests_auth.py @@ -10,7 +10,7 @@ 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 @@ -20,7 +20,7 @@ class HTTPSignatureAuth(requests.auth.AuthBase): `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( diff --git a/httpsig/sign.py b/httpsig/sign.py index 2ceb646..625639c 100644 --- a/httpsig/sign.py +++ b/httpsig/sign.py @@ -74,7 +74,7 @@ def _sign(self, data): 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 @@ -86,7 +86,7 @@ class HeaderSigner(Signer): :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']. - ''' + """ def __init__(self, key_id, secret, algorithm=None, headers=None): if algorithm is None: algorithm = DEFAULT_SIGN_ALGORITHM diff --git a/httpsig/tests/test_signature.py b/httpsig/tests/test_signature.py index 9f948a7..b8b4c90 100755 --- a/httpsig/tests/test_signature.py +++ b/httpsig/tests/test_signature.py @@ -44,7 +44,7 @@ def test_default(self): self.assertIn('signature', params) self.assertEqual(params['keyId'], 'Test') self.assertEqual(params['algorithm'], 'rsa-sha256') - self.assertEqual(params['signature'], 'jKyvPcxB4JbmYY4mByyBY7cZfNl4OW9HpFQlG7N4YcJPteKTu4MWCLyk+gIr0wDgqtLWf9NLpMAMimdfsH7FSWGfbMFSrsVTHNTk0rK3usrfFnti1dxsM4jl0kYJCKTGI/UWkqiaxwNiKqGcdlEDrTcUhhsFsOIo8VhddmZTZ8w=') + self.assertEqual(params['signature'], 'jKyvPcxB4JbmYY4mByyBY7cZfNl4OW9HpFQlG7N4YcJPteKTu4MWCLyk+gIr0wDgqtLWf9NLpMAMimdfsH7FSWGfbMFSrsVTHNTk0rK3usrfFnti1dxsM4jl0kYJCKTGI/UWkqiaxwNiKqGcdlEDrTcUhhsFsOIo8VhddmZTZ8w=') # noqa: E501 def test_basic(self): hs = sign.HeaderSigner(key_id='Test', secret=self.key, headers=[ @@ -71,7 +71,7 @@ def test_basic(self): 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=') + 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=[ @@ -105,4 +105,4 @@ def test_all(self): 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=') + self.assertEqual(params['signature'], 'Ef7MlxLXoBovhil3AlyjtBwAL9g4TN3tibLj7uuNB3CROat/9KaeQ4hW2NiJ+pZ6HQEOx9vYZAyi+7cmIkmJszJCut5kQLAwuX+Ms/mUFvpKlSo9StS2bMXDBNjOh4Auj774GFj4gwjS+3NhFeoqyr/MuN6HsEnkvn6zdgfE2i0=') # noqa: E501 diff --git a/httpsig/tests/test_verify.py b/httpsig/tests/test_verify.py index 3a2ad24..c53c01c 100755 --- a/httpsig/tests/test_verify.py +++ b/httpsig/tests/test_verify.py @@ -6,10 +6,12 @@ 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' @@ -43,7 +45,8 @@ def setUp(self): def test_basic_sign(self): signer = Signer(secret=self.sign_secret, algorithm=self.algorithm) - verifier = Verifier(secret=self.verify_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..." @@ -58,7 +61,8 @@ def test_default(self): 'Date': self.header_date } - hs = HeaderSigner(key_id="Test", secret=self.sign_secret, algorithm=self.algorithm) + hs = HeaderSigner( + key_id="Test", secret=self.sign_secret, algorithm=self.algorithm) signed = hs.sign(unsigned) hv = HeaderVerifier(headers=signed, secret=self.verify_secret) self.assertTrue(hv.verify()) @@ -67,14 +71,18 @@ 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, headers=[ - '(request-target)', - 'host', - 'date', - 'content-type', - 'digest', - 'content-length' - ]) + hs = HeaderSigner( + key_id="Test", + secret=self.sign_secret, + algorithm=self.algorithm, + headers=[ + '(request-target)', + 'host', + 'date', + 'content-type', + 'digest', + 'content-length' + ]) unsigned = { 'Host': HOST, 'Date': self.header_date, @@ -84,7 +92,9 @@ def test_signed_headers(self): } signed = hs.sign(unsigned, method=METHOD, path=PATH) - hv = HeaderVerifier(headers=signed, secret=self.verify_secret, host=HOST, method=METHOD, path=PATH) + hv = HeaderVerifier( + headers=signed, secret=self.verify_secret, + host=HOST, method=METHOD, path=PATH) self.assertTrue(hv.verify()) def test_incorrect_headers(self): @@ -110,23 +120,27 @@ def test_incorrect_headers(self): } signed = hs.sign(unsigned, method=METHOD, path=PATH) - hv = HeaderVerifier(headers=signed, secret=self.verify_secret, required_headers=["some-other-header"], + hv = HeaderVerifier(headers=signed, secret=self.verify_secret, + required_headers=["some-other-header"], host=HOST, method=METHOD, path=PATH) - with self.assertRaises(Exception) as ex: + 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, algorithm=self.algorithm, headers=[ - '(request-target)', - 'host', - 'date', - 'content-type', - 'digest', - 'content-length' - ]) + hs = HeaderSigner( + key_id="Test", + secret=self.sign_secret, + algorithm=self.algorithm, headers=[ + '(request-target)', + 'host', + 'date', + 'content-type', + 'digest', + 'content-length' + ]) unsigned = { 'Host': HOST, 'Date': self.header_date, @@ -135,30 +149,41 @@ def test_extra_auth_headers(self): '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, - required_headers=['date', '(request-target)']) + hv = HeaderVerifier( + headers=signed, + secret=self.verify_secret, + method=METHOD, + path=PATH, + 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') + 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') + 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() @@ -169,12 +194,14 @@ def setUp(self): 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" diff --git a/httpsig/utils.py b/httpsig/utils.py index 2bc3ac5..9efc8d5 100644 --- a/httpsig/utils.py +++ b/httpsig/utils.py @@ -1,8 +1,8 @@ +import base64 +import six import re import struct import hashlib -import base64 -import six try: # Python 3 @@ -11,7 +11,6 @@ # Python 2 from urllib2 import parse_http_list -from Crypto.PublicKey import RSA from Crypto.Hash import SHA, SHA256, SHA512 ALGORITHMS = frozenset([ @@ -65,7 +64,8 @@ def generate_message(required_headers, headers, host=None, method=None, h = h.lower() if h == '(request-target)': if not method or not path: - raise Exception('method and path arguments required when using "(request-target)"') + raise Exception('method and path arguments required when ' + + 'using "(request-target)"') signable_list.append('%s: %s %s' % (h, method.lower(), path)) elif h == 'host': @@ -76,11 +76,11 @@ def generate_message(required_headers, headers, host=None, method=None, if 'host' in headers: host = headers[h] else: - raise Exception('missing required header "%s"' % (h)) + 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)) + raise Exception('missing required header "%s"' % h) signable_list.append('%s: %s' % (h, headers[h])) @@ -94,7 +94,8 @@ def parse_authorization_header(header): auth = header.split(" ", 1) if len(auth) > 2: - raise ValueError('Invalid authorization header. (eg. Method key1=value1,key2="value, \"2\"")') + raise ValueError('Invalid authorization header. (eg. Method ' + + 'key1=value1,key2="value, \"2\"")') # Split up any args into a dictionary. values = {} @@ -148,10 +149,10 @@ def build_signature_template(key_id, algorithm, headers): def lkv(d): parts = [] while d: - len = struct.unpack('>I', d[:4])[0] - bits = d[4:len+4] + length = struct.unpack('>I', d[:4])[0] + bits = d[4:length+4] parts.append(bits) - d = d[len+4:] + d = d[length+4:] return parts diff --git a/httpsig/verify.py b/httpsig/verify.py index 9b34f51..c734337 100644 --- a/httpsig/verify.py +++ b/httpsig/verify.py @@ -1,13 +1,9 @@ """ Module to assist in verifying a signed header. """ +import base64 import six -from Crypto.Hash import HMAC -from Crypto.PublicKey import RSA -from Crypto.Signature import PKCS1_v1_5 -from base64 import b64decode - from .sign import Signer from .utils import * @@ -18,6 +14,7 @@ class Verifier(Signer): 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. @@ -33,11 +30,11 @@ def _verify(self, data, signature): if self.sign_algorithm == 'rsa': h = self._hash.new() h.update(data) - return self._rsa.verify(h, b64decode(signature)) + return self._rsa.verify(h, base64.b64decode(signature)) elif self.sign_algorithm == 'hmac': h = self._sign_hmac(data) - s = b64decode(signature) + s = base64.b64decode(signature) return ct_bytes_compare(h, s) else: @@ -48,6 +45,7 @@ class HeaderVerifier(Verifier): """ Verifies an HTTP signature from given headers. """ + def __init__(self, headers, secret, required_headers=None, method=None, path=None, host=None): """ @@ -82,7 +80,7 @@ def __init__(self, headers, secret, required_headers=None, method=None, self.host = host super(HeaderVerifier, self).__init__( - secret, algorithm=self.auth_dict['algorithm']) + secret, algorithm=self.auth_dict['algorithm']) def verify(self): """ @@ -96,9 +94,12 @@ def verify(self): auth_headers = self.auth_dict.get('headers', 'date').split(' ') if len(set(self.required_headers) - set(auth_headers)) > 0: - raise Exception('{} is a required header(s)'.format(', '.join(set(self.required_headers)-set(auth_headers)))) + 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) + auth_headers, self.headers, self.host, self.method, self.path) return self._verify(signing_str, self.auth_dict['signature']) From 8cad94b06858cd8d6b13092fadf059886a9a77cb Mon Sep 17 00:00:00 2001 From: Adam Knight Date: Wed, 28 Mar 2018 11:56:56 -0500 Subject: [PATCH 06/14] Switching to setuptools_scm from versioneer --- MANIFEST | 2 - MANIFEST.in | 2 - httpsig/__init__.py | 10 +- httpsig/_version.py | 187 --------- requirements_dev.txt | 2 + setup.cfg | 2 +- setup.py | 12 +- versioneer.py | 885 ------------------------------------------- 8 files changed, 13 insertions(+), 1089 deletions(-) delete mode 100644 httpsig/_version.py create mode 100644 requirements_dev.txt delete mode 100644 versioneer.py diff --git a/MANIFEST b/MANIFEST index d969ca8..3dcab25 100644 --- a/MANIFEST +++ b/MANIFEST @@ -5,9 +5,7 @@ README.rst requirements.txt setup.cfg setup.py -versioneer.py httpsig/__init__.py -httpsig/_version.py httpsig/requests_auth.py httpsig/sign.py httpsig/utils.py diff --git a/MANIFEST.in b/MANIFEST.in index 20b80ef..9d6271c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,3 @@ include *.rst include *.txt -include versioneer.py -include httpsig/_version.py include httpsig/tests/*.pem diff --git a/httpsig/__init__.py b/httpsig/__init__.py index d6f960a..01cb860 100644 --- a/httpsig/__init__.py +++ b/httpsig/__init__.py @@ -1,8 +1,12 @@ +from pkg_resources import get_distribution, DistributionNotFound + from .sign import Signer, HeaderSigner from .verify import Verifier, HeaderVerifier -from ._version import get_versions -__version__ = get_versions()['version'] -del get_versions +try: + __version__ = get_distribution(__name__).version +except DistributionNotFound: + # package is not installed + pass __all__ = (Signer, HeaderSigner, Verifier, HeaderVerifier) diff --git a/httpsig/_version.py b/httpsig/_version.py deleted file mode 100644 index 95a31be..0000000 --- a/httpsig/_version.py +++ /dev/null @@ -1,187 +0,0 @@ -# This file helps to compute a version number in source trees obtained from -# git-archive tarball (such as those provided by githubs download-from-tag -# feature). Distribution tarballs (build by setup.py sdist) and build -# directories (produced by setup.py build) will contain a much shorter file -# that just contains the computed version number. - -# This file is released into the public domain. Generated by -# versioneer-0.10 (https://github.com/warner/python-versioneer) - -import errno -import os.path -import re -import subprocess -import sys - -# these strings will be replaced by git during git-archive -git_refnames = "$Format:%d$" -git_full = "$Format:%H$" - - -def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False): - assert isinstance(commands, list) - p = None - for c in commands: - try: - # remember shell=False, so use git.cmd on windows, not just git - p = subprocess.Popen([c] + args, cwd=cwd, stdout=subprocess.PIPE, - stderr=(subprocess.PIPE if hide_stderr - else None)) - break - except EnvironmentError: - e = sys.exc_info()[1] - if e.errno == errno.ENOENT: - continue - if verbose: - print("unable to run %s" % args[0]) - print(e) - return None - else: - if verbose: - print("unable to find command, tried %s" % (commands,)) - return None - stdout = p.communicate()[0].strip() - if sys.version >= '3': - stdout = stdout.decode() - if p.returncode != 0: - if verbose: - print("unable to run %s (error)" % args[0]) - return None - return stdout - - -def get_expanded_variables(versionfile_abs): - # the code embedded in _version.py can just fetch the value of these - # variables. When used from setup.py, we don't want to import - # _version.py, so we do it with a regexp instead. This function is not - # used from _version.py. - variables = {} - try: - f = open(versionfile_abs, "r") - for line in f.readlines(): - if line.strip().startswith("git_refnames ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - variables["refnames"] = mo.group(1) - if line.strip().startswith("git_full ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - variables["full"] = mo.group(1) - f.close() - except EnvironmentError: - pass - return variables - - -def versions_from_expanded_variables(variables, tag_prefix, verbose=False): - refnames = variables["refnames"].strip() - if refnames.startswith("$Format"): - if verbose: - print("variables are unexpanded, not using") - return {} # unexpanded, so not in an unpacked git-archive tarball - refs = set([r.strip() for r in refnames.strip("()").split(",")]) - # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of - # just "foo-1.0". If we see a "tag: " prefix, prefer those. - TAG = "tag: " - tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) - if not tags: - # Either we're using git < 1.8.3, or there really are no tags. We use - # a heuristic: assume all version tags have a digit. The old git %d - # expansion behaves like git log --decorate=short and strips out the - # refs/heads/ and refs/tags/ prefixes that would let us distinguish - # between branches and tags. By ignoring refnames without digits, we - # filter out many common branch names like "release" and - # "stabilization", as well as "HEAD" and "master". - tags = set([r for r in refs if re.search(r'\d', r)]) - if verbose: - print("discarding '%s', no digits" % ",".join(refs - tags)) - if verbose: - print("likely tags: %s" % ",".join(sorted(tags))) - for ref in sorted(tags): - # sorting will prefer e.g. "2.0" over "2.0rc1" - if ref.startswith(tag_prefix): - r = ref[len(tag_prefix):] - if verbose: - print("picking %s" % r) - return {"version": r, - "full": variables["full"].strip()} - # no suitable tags, so we use the full revision id - if verbose: - print("no suitable tags, using full revision id") - return {"version": variables["full"].strip(), - "full": variables["full"].strip()} - - -def versions_from_vcs(tag_prefix, root, verbose=False): - # this runs 'git' from the root of the source tree. This only gets called - # if the git-archive 'subst' variables were *not* expanded, and - # _version.py hasn't already been rewritten with a short version string, - # meaning we're inside a checked out source tree. - - if not os.path.exists(os.path.join(root, ".git")): - if verbose: - print("no .git in %s" % root) - return {} - - GITS = ["git"] - if sys.platform == "win32": - GITS = ["git.cmd", "git.exe"] - stdout = run_command(GITS, ["describe", "--tags", "--dirty", "--always"], - cwd=root) - if stdout is None: - return {} - if not stdout.startswith(tag_prefix): - if verbose: - print("tag '%s' doesn't start with prefix '%s'" % (stdout, tag_prefix)) # noqa: E501 - return {} - tag = stdout[len(tag_prefix):] - stdout = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) - if stdout is None: - return {} - full = stdout.strip() - if tag.endswith("-dirty"): - full += "-dirty" - return {"version": tag, "full": full} - - -def versions_from_parentdir(parentdir_prefix, root, verbose=False): - # Source tarballs conventionally unpack into a directory that includes - # both the project name and a version string. - dirname = os.path.basename(root) - if not dirname.startswith(parentdir_prefix): - if verbose: - print("guessing rootdir is '%s', but '%s' doesn't start with prefix '%s'" % # noqa: E501 - (root, dirname, parentdir_prefix)) - return None - return {"version": dirname[len(parentdir_prefix):], "full": ""} - - -tag_prefix = "v" -parentdir_prefix = "httpsig-" -versionfile_source = "httpsig/_version.py" - - -def get_versions(default={"version": "unknown", "full": ""}, verbose=False): - # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have - # __file__, we can work backwards from there to the root. Some - # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which - # case we can only use expanded variables. - - variables = {"refnames": git_refnames, "full": git_full} - ver = versions_from_expanded_variables(variables, tag_prefix, verbose) - if ver: - return ver - - try: - root = os.path.abspath(__file__) - # versionfile_source is the relative path from the top of the source - # tree (where the .git directory might live) to this file. Invert - # this to find the root from __file__. - for i in range(len(versionfile_source.split("/"))): - root = os.path.dirname(root) - except NameError: - return default - - return (versions_from_vcs(tag_prefix, root, verbose) - or versions_from_parentdir(parentdir_prefix, root, verbose) - or default) diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..0a8547b --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1,2 @@ +setuptools +wheel diff --git a/setup.cfg b/setup.cfg index 3cbd55d..2be6836 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,2 @@ [bdist_wheel] -universal = True \ No newline at end of file +universal = True diff --git a/setup.py b/setup.py index 0ebb130..3fe1473 100755 --- a/setup.py +++ b/setup.py @@ -1,13 +1,6 @@ #!/usr/bin/env python from setuptools import setup, find_packages -# versioneer config -import versioneer -versioneer.versionfile_source = 'httpsig/_version.py' -versioneer.versionfile_build = 'httpsig/_version.py' -versioneer.tag_prefix = 'v' # tags are like v1.2.0 -versioneer.parentdir_prefix = 'httpsig-' # dirname like 'myproject-1.2.0' - # create long description with open('README.rst') as file: long_description = file.read() @@ -16,8 +9,7 @@ setup( name='httpsig', - version=versioneer.get_version(), - cmdclass=versioneer.get_cmdclass(), + # version=versioneer.get_version(), description="Secure HTTP request signing using the HTTP Signature draft specification", long_description=long_description, classifiers=[ @@ -43,6 +35,8 @@ packages=find_packages(), include_package_data=True, zip_safe=True, + use_scm_version=True, + setup_requires=['setuptools_scm'], install_requires=['pycryptodome==3.4.7', 'six'], test_suite="httpsig.tests", ) diff --git a/versioneer.py b/versioneer.py deleted file mode 100644 index 2cacf9b..0000000 --- a/versioneer.py +++ /dev/null @@ -1,885 +0,0 @@ - -# Version: 0.10 - -""" -The Versioneer -============== - -* like a rocketeer, but for versions! -* https://github.com/warner/python-versioneer -* Brian Warner -* License: Public Domain -* Compatible With: python2.6, 2.7, and 3.2, 3.3 - -[![Build Status](https://travis-ci.org/warner/python-versioneer.png?branch=master)](https://travis-ci.org/warner/python-versioneer) - -This is a tool for managing a recorded version number in distutils-based -python projects. The goal is to remove the tedious and error-prone "update -the embedded version string" step from your release process. Making a new -release should be as easy as recording a new tag in your version-control -system, and maybe making new tarballs. - - -## Quick Install - -* `pip install versioneer` to somewhere to your $PATH -* run `versioneer-installer` in your source tree: this installs `versioneer.py` -* follow the instructions below (also in the `versioneer.py` docstring) - -## Version Identifiers - -Source trees come from a variety of places: - -* a version-control system checkout (mostly used by developers) -* a nightly tarball, produced by build automation -* a snapshot tarball, produced by a web-based VCS browser, like github's - "tarball from tag" feature -* a release tarball, produced by "setup.py sdist", distributed through PyPI - -Within each source tree, the version identifier (either a string or a number, -this tool is format-agnostic) can come from a variety of places: - -* ask the VCS tool itself, e.g. "git describe" (for checkouts), which knows - about recent "tags" and an absolute revision-id -* the name of the directory into which the tarball was unpacked -* an expanded VCS variable ($Id$, etc) -* a `_version.py` created by some earlier build step - -For released software, the version identifier is closely related to a VCS -tag. Some projects use tag names that include more than just the version -string (e.g. "myproject-1.2" instead of just "1.2"), in which case the tool -needs to strip the tag prefix to extract the version identifier. For -unreleased software (between tags), the version identifier should provide -enough information to help developers recreate the same tree, while also -giving them an idea of roughly how old the tree is (after version 1.2, before -version 1.3). Many VCS systems can report a description that captures this, -for example 'git describe --tags --dirty --always' reports things like -"0.7-1-g574ab98-dirty" to indicate that the checkout is one revision past the -0.7 tag, has a unique revision id of "574ab98", and is "dirty" (it has -uncommitted changes. - -The version identifier is used for multiple purposes: - -* to allow the module to self-identify its version: `myproject.__version__` -* to choose a name and prefix for a 'setup.py sdist' tarball - -## Theory of Operation - -Versioneer works by adding a special `_version.py` file into your source -tree, where your `__init__.py` can import it. This `_version.py` knows how to -dynamically ask the VCS tool for version information at import time. However, -when you use "setup.py build" or "setup.py sdist", `_version.py` in the new -copy is replaced by a small static file that contains just the generated -version data. - -`_version.py` also contains `$Revision$` markers, and the installation -process marks `_version.py` to have this marker rewritten with a tag name -during the "git archive" command. As a result, generated tarballs will -contain enough information to get the proper version. - - -## Installation - -First, decide on values for the following configuration variables: - -* `versionfile_source`: - - A project-relative pathname into which the generated version strings should - be written. This is usually a `_version.py` next to your project's main - `__init__.py` file. If your project uses `src/myproject/__init__.py`, this - should be `src/myproject/_version.py`. This file should be checked in to - your VCS as usual: the copy created below by `setup.py versioneer` will - include code that parses expanded VCS keywords in generated tarballs. The - 'build' and 'sdist' commands will replace it with a copy that has just the - calculated version string. - -* `versionfile_build`: - - Like `versionfile_source`, but relative to the build directory instead of - the source directory. These will differ when your setup.py uses - 'package_dir='. If you have `package_dir={'myproject': 'src/myproject'}`, - then you will probably have `versionfile_build='myproject/_version.py'` and - `versionfile_source='src/myproject/_version.py'`. - -* `tag_prefix`: - - a string, like 'PROJECTNAME-', which appears at the start of all VCS tags. - If your tags look like 'myproject-1.2.0', then you should use - tag_prefix='myproject-'. If you use unprefixed tags like '1.2.0', this - should be an empty string. - -* `parentdir_prefix`: - - a string, frequently the same as tag_prefix, which appears at the start of - all unpacked tarball filenames. If your tarball unpacks into - 'myproject-1.2.0', this should be 'myproject-'. - -This tool provides one script, named `versioneer-installer`. That script does -one thing: write a copy of `versioneer.py` into the current directory. - -To versioneer-enable your project: - -* 1: Run `versioneer-installer` to copy `versioneer.py` into the top of your - source tree. - -* 2: add the following lines to the top of your `setup.py`, with the - configuration values you decided earlier: - - import versioneer - versioneer.versionfile_source = 'src/myproject/_version.py' - versioneer.versionfile_build = 'myproject/_version.py' - versioneer.tag_prefix = '' # tags are like 1.2.0 - versioneer.parentdir_prefix = 'myproject-' # dirname like 'myproject-1.2.0' - -* 3: add the following arguments to the setup() call in your setup.py: - - version=versioneer.get_version(), - cmdclass=versioneer.get_cmdclass(), - -* 4: now run `setup.py versioneer`, which will create `_version.py`, and - will modify your `__init__.py` to define `__version__` (by calling a - function from `_version.py`). It will also modify your `MANIFEST.in` to - include both `versioneer.py` and the generated `_version.py` in sdist - tarballs. - -* 5: commit these changes to your VCS. To make sure you won't forget, - `setup.py versioneer` will mark everything it touched for addition. - -## Post-Installation Usage - -Once established, all uses of your tree from a VCS checkout should get the -current version string. All generated tarballs should include an embedded -version string (so users who unpack them will not need a VCS tool installed). - -If you distribute your project through PyPI, then the release process should -boil down to two steps: - -* 1: git tag 1.0 -* 2: python setup.py register sdist upload - -If you distribute it through github (i.e. users use github to generate -tarballs with `git archive`), the process is: - -* 1: git tag 1.0 -* 2: git push; git push --tags - -Currently, all version strings must be based upon a tag. Versioneer will -report "unknown" until your tree has at least one tag in its history. This -restriction will be fixed eventually (see issue #12). - -## Version-String Flavors - -Code which uses Versioneer can learn about its version string at runtime by -importing `_version` from your main `__init__.py` file and running the -`get_versions()` function. From the "outside" (e.g. in `setup.py`), you can -import the top-level `versioneer.py` and run `get_versions()`. - -Both functions return a dictionary with different keys for different flavors -of the version string: - -* `['version']`: condensed tag+distance+shortid+dirty identifier. For git, - this uses the output of `git describe --tags --dirty --always` but strips - the tag_prefix. For example "0.11-2-g1076c97-dirty" indicates that the tree - is like the "1076c97" commit but has uncommitted changes ("-dirty"), and - that this commit is two revisions ("-2-") beyond the "0.11" tag. For - released software (exactly equal to a known tag), the identifier will only - contain the stripped tag, e.g. "0.11". - -* `['full']`: detailed revision identifier. For Git, this is the full SHA1 - commit id, followed by "-dirty" if the tree contains uncommitted changes, - e.g. "1076c978a8d3cfc70f408fe5974aa6c092c949ac-dirty". - -Some variants are more useful than others. Including `full` in a bug report -should allow developers to reconstruct the exact code being tested (or -indicate the presence of local changes that should be shared with the -developers). `version` is suitable for display in an "about" box or a CLI -`--version` output: it can be easily compared against release notes and lists -of bugs fixed in various releases. - -In the future, this will also include a -[PEP-0440](http://legacy.python.org/dev/peps/pep-0440/) -compatible flavor -(e.g. `1.2.post0.dev123`). This loses a lot of information (and has no room -for a hash-based revision id), but is safe to use in a `setup.py` -"`version=`" argument. It also enables tools like *pip* to compare version -strings and evaluate compatibility constraint declarations. - -The `setup.py versioneer` command adds the following text to your -`__init__.py` to place a basic version in `YOURPROJECT.__version__`: - - from ._version import get_versions - __version = get_versions()['version'] - del get_versions - -## Updating Versioneer - -To upgrade your project to a new release of Versioneer, do the following: - -* install the new Versioneer (`pip install -U versioneer` or equivalent) -* re-run `versioneer-installer` in your source tree to replace `versioneer.py` -* edit `setup.py`, if necessary, to include any new configuration settings indicated by the release notes -* re-run `setup.py versioneer` to replace `SRC/_version.py` -* commit any changed files - -## Future Directions - -This tool is designed to make it easily extended to other version-control -systems: all VCS-specific components are in separate directories like -src/git/ . The top-level `versioneer.py` script is assembled from these -components by running make-versioneer.py . In the future, make-versioneer.py -will take a VCS name as an argument, and will construct a version of -`versioneer.py` that is specific to the given VCS. It might also take the -configuration arguments that are currently provided manually during -installation by editing setup.py . Alternatively, it might go the other -direction and include code from all supported VCS systems, reducing the -number of intermediate scripts. - - -## License - -To make Versioneer easier to embed, all its code is hereby released into the -public domain. The `_version.py` that it creates is also in the public -domain. - -""" - -import os, sys, re -from distutils.core import Command -from distutils.command.sdist import sdist as _sdist -from distutils.command.build import build as _build - -versionfile_source = None -versionfile_build = None -tag_prefix = None -parentdir_prefix = None - -VCS = "git" - - -LONG_VERSION_PY = ''' -# This file helps to compute a version number in source trees obtained from -# git-archive tarball (such as those provided by githubs download-from-tag -# feature). Distribution tarballs (build by setup.py sdist) and build -# directories (produced by setup.py build) will contain a much shorter file -# that just contains the computed version number. - -# This file is released into the public domain. Generated by -# versioneer-0.10 (https://github.com/warner/python-versioneer) - -# these strings will be replaced by git during git-archive -git_refnames = "%(DOLLAR)sFormat:%%d%(DOLLAR)s" -git_full = "%(DOLLAR)sFormat:%%H%(DOLLAR)s" - - -import subprocess -import sys -import errno - - -def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False): - assert isinstance(commands, list) - p = None - for c in commands: - try: - # remember shell=False, so use git.cmd on windows, not just git - p = subprocess.Popen([c] + args, cwd=cwd, stdout=subprocess.PIPE, - stderr=(subprocess.PIPE if hide_stderr - else None)) - break - except EnvironmentError: - e = sys.exc_info()[1] - if e.errno == errno.ENOENT: - continue - if verbose: - print("unable to run %%s" %% args[0]) - print(e) - return None - else: - if verbose: - print("unable to find command, tried %%s" %% (commands,)) - return None - stdout = p.communicate()[0].strip() - if sys.version >= '3': - stdout = stdout.decode() - if p.returncode != 0: - if verbose: - print("unable to run %%s (error)" %% args[0]) - return None - return stdout - - -import sys -import re -import os.path - -def get_expanded_variables(versionfile_abs): - # the code embedded in _version.py can just fetch the value of these - # variables. When used from setup.py, we don't want to import - # _version.py, so we do it with a regexp instead. This function is not - # used from _version.py. - variables = {} - try: - f = open(versionfile_abs,"r") - for line in f.readlines(): - if line.strip().startswith("git_refnames ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - variables["refnames"] = mo.group(1) - if line.strip().startswith("git_full ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - variables["full"] = mo.group(1) - f.close() - except EnvironmentError: - pass - return variables - -def versions_from_expanded_variables(variables, tag_prefix, verbose=False): - refnames = variables["refnames"].strip() - if refnames.startswith("$Format"): - if verbose: - print("variables are unexpanded, not using") - return {} # unexpanded, so not in an unpacked git-archive tarball - refs = set([r.strip() for r in refnames.strip("()").split(",")]) - # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of - # just "foo-1.0". If we see a "tag: " prefix, prefer those. - TAG = "tag: " - tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) - if not tags: - # Either we're using git < 1.8.3, or there really are no tags. We use - # a heuristic: assume all version tags have a digit. The old git %%d - # expansion behaves like git log --decorate=short and strips out the - # refs/heads/ and refs/tags/ prefixes that would let us distinguish - # between branches and tags. By ignoring refnames without digits, we - # filter out many common branch names like "release" and - # "stabilization", as well as "HEAD" and "master". - tags = set([r for r in refs if re.search(r'\d', r)]) - if verbose: - print("discarding '%%s', no digits" %% ",".join(refs-tags)) - if verbose: - print("likely tags: %%s" %% ",".join(sorted(tags))) - for ref in sorted(tags): - # sorting will prefer e.g. "2.0" over "2.0rc1" - if ref.startswith(tag_prefix): - r = ref[len(tag_prefix):] - if verbose: - print("picking %%s" %% r) - return { "version": r, - "full": variables["full"].strip() } - # no suitable tags, so we use the full revision id - if verbose: - print("no suitable tags, using full revision id") - return { "version": variables["full"].strip(), - "full": variables["full"].strip() } - -def versions_from_vcs(tag_prefix, root, verbose=False): - # this runs 'git' from the root of the source tree. This only gets called - # if the git-archive 'subst' variables were *not* expanded, and - # _version.py hasn't already been rewritten with a short version string, - # meaning we're inside a checked out source tree. - - if not os.path.exists(os.path.join(root, ".git")): - if verbose: - print("no .git in %%s" %% root) - return {} - - GITS = ["git"] - if sys.platform == "win32": - GITS = ["git.cmd", "git.exe"] - stdout = run_command(GITS, ["describe", "--tags", "--dirty", "--always"], - cwd=root) - if stdout is None: - return {} - if not stdout.startswith(tag_prefix): - if verbose: - print("tag '%%s' doesn't start with prefix '%%s'" %% (stdout, tag_prefix)) - return {} - tag = stdout[len(tag_prefix):] - stdout = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) - if stdout is None: - return {} - full = stdout.strip() - if tag.endswith("-dirty"): - full += "-dirty" - return {"version": tag, "full": full} - - -def versions_from_parentdir(parentdir_prefix, root, verbose=False): - # Source tarballs conventionally unpack into a directory that includes - # both the project name and a version string. - dirname = os.path.basename(root) - if not dirname.startswith(parentdir_prefix): - if verbose: - print("guessing rootdir is '%%s', but '%%s' doesn't start with prefix '%%s'" %% - (root, dirname, parentdir_prefix)) - return None - return {"version": dirname[len(parentdir_prefix):], "full": ""} - -tag_prefix = "%(TAG_PREFIX)s" -parentdir_prefix = "%(PARENTDIR_PREFIX)s" -versionfile_source = "%(VERSIONFILE_SOURCE)s" - -def get_versions(default={"version": "unknown", "full": ""}, verbose=False): - # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have - # __file__, we can work backwards from there to the root. Some - # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which - # case we can only use expanded variables. - - variables = { "refnames": git_refnames, "full": git_full } - ver = versions_from_expanded_variables(variables, tag_prefix, verbose) - if ver: - return ver - - try: - root = os.path.abspath(__file__) - # versionfile_source is the relative path from the top of the source - # tree (where the .git directory might live) to this file. Invert - # this to find the root from __file__. - for i in range(len(versionfile_source.split("/"))): - root = os.path.dirname(root) - except NameError: - return default - - return (versions_from_vcs(tag_prefix, root, verbose) - or versions_from_parentdir(parentdir_prefix, root, verbose) - or default) - -''' - - -import subprocess -import sys -import errno - - -def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False): - assert isinstance(commands, list) - p = None - for c in commands: - try: - # remember shell=False, so use git.cmd on windows, not just git - p = subprocess.Popen([c] + args, cwd=cwd, stdout=subprocess.PIPE, - stderr=(subprocess.PIPE if hide_stderr - else None)) - break - except EnvironmentError: - e = sys.exc_info()[1] - if e.errno == errno.ENOENT: - continue - if verbose: - print("unable to run %s" % args[0]) - print(e) - return None - else: - if verbose: - print("unable to find command, tried %s" % (commands,)) - return None - stdout = p.communicate()[0].strip() - if sys.version >= '3': - stdout = stdout.decode() - if p.returncode != 0: - if verbose: - print("unable to run %s (error)" % args[0]) - return None - return stdout - - -import sys -import re -import os.path - -def get_expanded_variables(versionfile_abs): - # the code embedded in _version.py can just fetch the value of these - # variables. When used from setup.py, we don't want to import - # _version.py, so we do it with a regexp instead. This function is not - # used from _version.py. - variables = {} - try: - f = open(versionfile_abs,"r") - for line in f.readlines(): - if line.strip().startswith("git_refnames ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - variables["refnames"] = mo.group(1) - if line.strip().startswith("git_full ="): - mo = re.search(r'=\s*"(.*)"', line) - if mo: - variables["full"] = mo.group(1) - f.close() - except EnvironmentError: - pass - return variables - -def versions_from_expanded_variables(variables, tag_prefix, verbose=False): - refnames = variables["refnames"].strip() - if refnames.startswith("$Format"): - if verbose: - print("variables are unexpanded, not using") - return {} # unexpanded, so not in an unpacked git-archive tarball - refs = set([r.strip() for r in refnames.strip("()").split(",")]) - # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of - # just "foo-1.0". If we see a "tag: " prefix, prefer those. - TAG = "tag: " - tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) - if not tags: - # Either we're using git < 1.8.3, or there really are no tags. We use - # a heuristic: assume all version tags have a digit. The old git %d - # expansion behaves like git log --decorate=short and strips out the - # refs/heads/ and refs/tags/ prefixes that would let us distinguish - # between branches and tags. By ignoring refnames without digits, we - # filter out many common branch names like "release" and - # "stabilization", as well as "HEAD" and "master". - tags = set([r for r in refs if re.search(r'\d', r)]) - if verbose: - print("discarding '%s', no digits" % ",".join(refs-tags)) - if verbose: - print("likely tags: %s" % ",".join(sorted(tags))) - for ref in sorted(tags): - # sorting will prefer e.g. "2.0" over "2.0rc1" - if ref.startswith(tag_prefix): - r = ref[len(tag_prefix):] - if verbose: - print("picking %s" % r) - return { "version": r, - "full": variables["full"].strip() } - # no suitable tags, so we use the full revision id - if verbose: - print("no suitable tags, using full revision id") - return { "version": variables["full"].strip(), - "full": variables["full"].strip() } - -def versions_from_vcs(tag_prefix, root, verbose=False): - # this runs 'git' from the root of the source tree. This only gets called - # if the git-archive 'subst' variables were *not* expanded, and - # _version.py hasn't already been rewritten with a short version string, - # meaning we're inside a checked out source tree. - - if not os.path.exists(os.path.join(root, ".git")): - if verbose: - print("no .git in %s" % root) - return {} - - GITS = ["git"] - if sys.platform == "win32": - GITS = ["git.cmd", "git.exe"] - stdout = run_command(GITS, ["describe", "--tags", "--dirty", "--always"], - cwd=root) - if stdout is None: - return {} - if not stdout.startswith(tag_prefix): - if verbose: - print("tag '%s' doesn't start with prefix '%s'" % (stdout, tag_prefix)) - return {} - tag = stdout[len(tag_prefix):] - stdout = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) - if stdout is None: - return {} - full = stdout.strip() - if tag.endswith("-dirty"): - full += "-dirty" - return {"version": tag, "full": full} - - -def versions_from_parentdir(parentdir_prefix, root, verbose=False): - # Source tarballs conventionally unpack into a directory that includes - # both the project name and a version string. - dirname = os.path.basename(root) - if not dirname.startswith(parentdir_prefix): - if verbose: - print("guessing rootdir is '%s', but '%s' doesn't start with prefix '%s'" % - (root, dirname, parentdir_prefix)) - return None - return {"version": dirname[len(parentdir_prefix):], "full": ""} -import os.path -import sys - -# os.path.relpath only appeared in Python-2.6 . Define it here for 2.5. -def os_path_relpath(path, start=os.path.curdir): - """Return a relative version of a path""" - - if not path: - raise ValueError("no path specified") - - start_list = [x for x in os.path.abspath(start).split(os.path.sep) if x] - path_list = [x for x in os.path.abspath(path).split(os.path.sep) if x] - - # Work out how much of the filepath is shared by start and path. - i = len(os.path.commonprefix([start_list, path_list])) - - rel_list = [os.path.pardir] * (len(start_list)-i) + path_list[i:] - if not rel_list: - return os.path.curdir - return os.path.join(*rel_list) - -def do_vcs_install(manifest_in, versionfile_source, ipy): - GITS = ["git"] - if sys.platform == "win32": - GITS = ["git.cmd", "git.exe"] - files = [manifest_in, versionfile_source, ipy] - try: - me = __file__ - if me.endswith(".pyc") or me.endswith(".pyo"): - me = os.path.splitext(me)[0] + ".py" - versioneer_file = os_path_relpath(me) - except NameError: - versioneer_file = "versioneer.py" - files.append(versioneer_file) - present = False - try: - f = open(".gitattributes", "r") - for line in f.readlines(): - if line.strip().startswith(versionfile_source): - if "export-subst" in line.strip().split()[1:]: - present = True - f.close() - except EnvironmentError: - pass - if not present: - f = open(".gitattributes", "a+") - f.write("%s export-subst\n" % versionfile_source) - f.close() - files.append(".gitattributes") - run_command(GITS, ["add", "--"] + files) - -SHORT_VERSION_PY = """ -# This file was generated by 'versioneer.py' (0.10) from -# revision-control system data, or from the parent directory name of an -# unpacked source archive. Distribution tarballs contain a pre-generated copy -# of this file. - -version_version = '%(version)s' -version_full = '%(full)s' -def get_versions(default={}, verbose=False): - return {'version': version_version, 'full': version_full} - -""" - -DEFAULT = {"version": "unknown", "full": "unknown"} - -def versions_from_file(filename): - versions = {} - try: - f = open(filename) - except EnvironmentError: - return versions - for line in f.readlines(): - mo = re.match("version_version = '([^']+)'", line) - if mo: - versions["version"] = mo.group(1) - mo = re.match("version_full = '([^']+)'", line) - if mo: - versions["full"] = mo.group(1) - f.close() - return versions - -def write_to_version_file(filename, versions): - f = open(filename, "w") - f.write(SHORT_VERSION_PY % versions) - f.close() - print("set %s to '%s'" % (filename, versions["version"])) - -def get_root(): - try: - return os.path.dirname(os.path.abspath(__file__)) - except NameError: - return os.path.dirname(os.path.abspath(sys.argv[0])) - -def get_versions(default=DEFAULT, verbose=False): - # returns dict with two keys: 'version' and 'full' - assert versionfile_source is not None, "please set versioneer.versionfile_source" - assert tag_prefix is not None, "please set versioneer.tag_prefix" - assert parentdir_prefix is not None, "please set versioneer.parentdir_prefix" - # I am in versioneer.py, which must live at the top of the source tree, - # which we use to compute the root directory. py2exe/bbfreeze/non-CPython - # don't have __file__, in which case we fall back to sys.argv[0] (which - # ought to be the setup.py script). We prefer __file__ since that's more - # robust in cases where setup.py was invoked in some weird way (e.g. pip) - root = get_root() - versionfile_abs = os.path.join(root, versionfile_source) - - # extract version from first of _version.py, 'git describe', parentdir. - # This is meant to work for developers using a source checkout, for users - # of a tarball created by 'setup.py sdist', and for users of a - # tarball/zipball created by 'git archive' or github's download-from-tag - # feature. - - variables = get_expanded_variables(versionfile_abs) - if variables: - ver = versions_from_expanded_variables(variables, tag_prefix) - if ver: - if verbose: print("got version from expanded variable %s" % ver) - return ver - - ver = versions_from_file(versionfile_abs) - if ver: - if verbose: print("got version from file %s %s" % (versionfile_abs,ver)) - return ver - - ver = versions_from_vcs(tag_prefix, root, verbose) - if ver: - if verbose: print("got version from git %s" % ver) - return ver - - ver = versions_from_parentdir(parentdir_prefix, root, verbose) - if ver: - if verbose: print("got version from parentdir %s" % ver) - return ver - - if verbose: print("got version from default %s" % ver) - return default - -def get_version(verbose=False): - return get_versions(verbose=verbose)["version"] - -class cmd_version(Command): - description = "report generated version string" - user_options = [] - boolean_options = [] - def initialize_options(self): - pass - def finalize_options(self): - pass - def run(self): - ver = get_version(verbose=True) - print("Version is currently: %s" % ver) - - -class cmd_build(_build): - def run(self): - versions = get_versions(verbose=True) - _build.run(self) - # now locate _version.py in the new build/ directory and replace it - # with an updated value - target_versionfile = os.path.join(self.build_lib, versionfile_build) - print("UPDATING %s" % target_versionfile) - os.unlink(target_versionfile) - f = open(target_versionfile, "w") - f.write(SHORT_VERSION_PY % versions) - f.close() - -if 'cx_Freeze' in sys.modules: # cx_freeze enabled? - from cx_Freeze.dist import build_exe as _build_exe - - class cmd_build_exe(_build_exe): - def run(self): - versions = get_versions(verbose=True) - target_versionfile = versionfile_source - print("UPDATING %s" % target_versionfile) - os.unlink(target_versionfile) - f = open(target_versionfile, "w") - f.write(SHORT_VERSION_PY % versions) - f.close() - _build_exe.run(self) - os.unlink(target_versionfile) - f = open(versionfile_source, "w") - f.write(LONG_VERSION_PY % {"DOLLAR": "$", - "TAG_PREFIX": tag_prefix, - "PARENTDIR_PREFIX": parentdir_prefix, - "VERSIONFILE_SOURCE": versionfile_source, - }) - f.close() - -class cmd_sdist(_sdist): - def run(self): - versions = get_versions(verbose=True) - self._versioneer_generated_versions = versions - # unless we update this, the command will keep using the old version - self.distribution.metadata.version = versions["version"] - return _sdist.run(self) - - def make_release_tree(self, base_dir, files): - _sdist.make_release_tree(self, base_dir, files) - # now locate _version.py in the new base_dir directory (remembering - # that it may be a hardlink) and replace it with an updated value - target_versionfile = os.path.join(base_dir, versionfile_source) - print("UPDATING %s" % target_versionfile) - os.unlink(target_versionfile) - f = open(target_versionfile, "w") - f.write(SHORT_VERSION_PY % self._versioneer_generated_versions) - f.close() - -INIT_PY_SNIPPET = """ -from ._version import get_versions -__version__ = get_versions()['version'] -del get_versions -""" - -class cmd_update_files(Command): - description = "install/upgrade Versioneer files: __init__.py SRC/_version.py" - user_options = [] - boolean_options = [] - def initialize_options(self): - pass - def finalize_options(self): - pass - def run(self): - print(" creating %s" % versionfile_source) - f = open(versionfile_source, "w") - f.write(LONG_VERSION_PY % {"DOLLAR": "$", - "TAG_PREFIX": tag_prefix, - "PARENTDIR_PREFIX": parentdir_prefix, - "VERSIONFILE_SOURCE": versionfile_source, - }) - f.close() - - ipy = os.path.join(os.path.dirname(versionfile_source), "__init__.py") - try: - old = open(ipy, "r").read() - except EnvironmentError: - old = "" - if INIT_PY_SNIPPET not in old: - print(" appending to %s" % ipy) - f = open(ipy, "a") - f.write(INIT_PY_SNIPPET) - f.close() - else: - print(" %s unmodified" % ipy) - - # Make sure both the top-level "versioneer.py" and versionfile_source - # (PKG/_version.py, used by runtime code) are in MANIFEST.in, so - # they'll be copied into source distributions. Pip won't be able to - # install the package without this. - manifest_in = os.path.join(get_root(), "MANIFEST.in") - simple_includes = set() - try: - for line in open(manifest_in, "r").readlines(): - if line.startswith("include "): - for include in line.split()[1:]: - simple_includes.add(include) - except EnvironmentError: - pass - # That doesn't cover everything MANIFEST.in can do - # (http://docs.python.org/2/distutils/sourcedist.html#commands), so - # it might give some false negatives. Appending redundant 'include' - # lines is safe, though. - if "versioneer.py" not in simple_includes: - print(" appending 'versioneer.py' to MANIFEST.in") - f = open(manifest_in, "a") - f.write("include versioneer.py\n") - f.close() - else: - print(" 'versioneer.py' already in MANIFEST.in") - if versionfile_source not in simple_includes: - print(" appending versionfile_source ('%s') to MANIFEST.in" % - versionfile_source) - f = open(manifest_in, "a") - f.write("include %s\n" % versionfile_source) - f.close() - else: - print(" versionfile_source already in MANIFEST.in") - - # Make VCS-specific changes. For git, this means creating/changing - # .gitattributes to mark _version.py for export-time keyword - # substitution. - do_vcs_install(manifest_in, versionfile_source, ipy) - -def get_cmdclass(): - cmds = {'version': cmd_version, - 'versioneer': cmd_update_files, - 'build': cmd_build, - 'sdist': cmd_sdist, - } - if 'cx_Freeze' in sys.modules: # cx_freeze enabled? - cmds['build_exe'] = cmd_build_exe - del cmds['build'] - - return cmds From b7e25dc9d1123d291839d0f89763788e96975c78 Mon Sep 17 00:00:00 2001 From: Adam Knight Date: Wed, 28 Mar 2018 12:29:10 -0500 Subject: [PATCH 07/14] Updating TravisCI config --- .travis.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 893e0b0..3d6c799 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,10 @@ language: python python: - "2.7" - - "3.2" - "3.3" - "3.4" + - "3.5" + - "3.6" install: - pip install . - pip install nose From 60dbb7b0b43b922c1aba0b23705abf38f1a7afd7 Mon Sep 17 00:00:00 2001 From: Adam Knight Date: Wed, 28 Mar 2018 12:55:23 -0500 Subject: [PATCH 08/14] Fixed #2 and made Signer.sign public. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Really shouldn’t have been otherwise in the first place, honestly. --- httpsig/sign.py | 4 ++-- httpsig/tests/test_verify.py | 2 +- setup.py | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/httpsig/sign.py b/httpsig/sign.py index 625639c..36fab2a 100644 --- a/httpsig/sign.py +++ b/httpsig/sign.py @@ -60,7 +60,7 @@ def _sign_hmac(self, data): hmac.update(data) return hmac.digest() - def _sign(self, data): + def sign(self, data): if isinstance(data, six.string_types): data = data.encode("ascii") signed = None @@ -111,7 +111,7 @@ def sign(self, headers, host=None, method=None, path=None): signable = generate_message( required_headers, headers, host, method, path) - signature = self._sign(signable) + signature = super(HeaderSigner, self).sign(signable) headers['authorization'] = self.signature_template % signature return headers diff --git a/httpsig/tests/test_verify.py b/httpsig/tests/test_verify.py index c53c01c..65db70c 100755 --- a/httpsig/tests/test_verify.py +++ b/httpsig/tests/test_verify.py @@ -52,7 +52,7 @@ def test_basic_sign(self): BAD = b"this is not the signature you were looking for..." # generate signed string - signature = signer._sign(GOOD) + signature = signer.sign(GOOD) self.assertTrue(verifier._verify(data=GOOD, signature=signature)) self.assertFalse(verifier._verify(data=BAD, signature=signature)) diff --git a/setup.py b/setup.py index 3fe1473..7e5eda1 100755 --- a/setup.py +++ b/setup.py @@ -9,7 +9,6 @@ setup( name='httpsig', - # version=versioneer.get_version(), description="Secure HTTP request signing using the HTTP Signature draft specification", long_description=long_description, classifiers=[ From 685ecde303d6ffc87e54a3ea651bf08f6d0827e1 Mon Sep 17 00:00:00 2001 From: Cedric Veilleux Date: Tue, 24 Apr 2018 16:41:02 -0400 Subject: [PATCH 09/14] Relax pycryptodome requirement: Accept any 3.x.x version. Closes #13 --- requirements.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index a486ab9..026278e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -pycryptodome==3.4.7 +pycryptodome==3.6.1 six diff --git a/setup.py b/setup.py index 3fe1473..77d18d2 100755 --- a/setup.py +++ b/setup.py @@ -37,6 +37,6 @@ zip_safe=True, use_scm_version=True, setup_requires=['setuptools_scm'], - install_requires=['pycryptodome==3.4.7', 'six'], + install_requires=['pycryptodome>=3,<4', 'six'], test_suite="httpsig.tests", ) From 4880f6f14cee87251befb803df9cf3f11193153f Mon Sep 17 00:00:00 2001 From: Romain Bignon Date: Fri, 15 Jun 2018 08:04:16 +0200 Subject: [PATCH 10/14] Ability to supply another signature header like Signature Draft 2 has introduced support of the Signature header. A new 'sign_header' optional parameter has been added to HeaderSigner and HeaderVerifier. --- README.rst | 3 +-- httpsig/sign.py | 9 ++++--- httpsig/tests/test_verify.py | 21 ++++++++++++--- httpsig/utils.py | 51 ++++++++++++++++++++---------------- httpsig/verify.py | 17 +++++++----- 5 files changed, 64 insertions(+), 37 deletions(-) diff --git a/README.rst b/README.rst index 57f5e9d..c687784 100644 --- a/README.rst +++ b/README.rst @@ -116,8 +116,7 @@ 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 the Signature header. As this was principally designed to be an authentication helper, that header is not currently supported. PRs welcome. (It is trivial to move the value after generation, of course.) -4. Draft 2 added support for ecdsa-sha256. This is available in PyCryptodome but has not been added to httpsig. PRs welcome. +3. Draft 2 added support for ecdsa-sha256. This is available in PyCryptodome but has not been added to httpsig. PRs welcome. License diff --git a/httpsig/sign.py b/httpsig/sign.py index 625639c..44499f9 100644 --- a/httpsig/sign.py +++ b/httpsig/sign.py @@ -86,15 +86,18 @@ class HeaderSigner(Signer): :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): + 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) + key_id, algorithm, headers, sign_header) + self.sign_header = sign_header def sign(self, headers, host=None, method=None, path=None): """ @@ -112,6 +115,6 @@ def sign(self, headers, host=None, method=None, path=None): required_headers, headers, host, method, path) signature = self._sign(signable) - headers['authorization'] = self.signature_template % signature + headers[self.sign_header] = self.signature_template % signature return headers diff --git a/httpsig/tests/test_verify.py b/httpsig/tests/test_verify.py index c53c01c..f38680f 100755 --- a/httpsig/tests/test_verify.py +++ b/httpsig/tests/test_verify.py @@ -34,6 +34,7 @@ class TestVerifyHMACSHA1(BaseTestCase): 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" @@ -62,9 +63,11 @@ def test_default(self): } hs = HeaderSigner( - key_id="Test", secret=self.sign_secret, algorithm=self.algorithm) + 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) + hv = HeaderVerifier( + headers=signed, secret=self.verify_secret, sign_header=self.sign_header) self.assertTrue(hv.verify()) def test_signed_headers(self): @@ -75,6 +78,7 @@ def test_signed_headers(self): key_id="Test", secret=self.sign_secret, algorithm=self.algorithm, + sign_header=self.sign_header, headers=[ '(request-target)', 'host', @@ -94,7 +98,8 @@ def test_signed_headers(self): hv = HeaderVerifier( headers=signed, secret=self.verify_secret, - host=HOST, method=METHOD, path=PATH) + host=HOST, method=METHOD, path=PATH, + sign_header=self.sign_header) self.assertTrue(hv.verify()) def test_incorrect_headers(self): @@ -104,6 +109,7 @@ def test_incorrect_headers(self): hs = HeaderSigner(secret=self.sign_secret, key_id="Test", algorithm=self.algorithm, + sign_header=self.sign_header, headers=[ '(request-target)', 'host', @@ -122,7 +128,8 @@ def test_incorrect_headers(self): hv = HeaderVerifier(headers=signed, secret=self.verify_secret, required_headers=["some-other-header"], - host=HOST, method=METHOD, path=PATH) + host=HOST, method=METHOD, path=PATH, + sign_header=self.sign_header) with self.assertRaises(Exception): hv.verify() @@ -133,6 +140,7 @@ def test_extra_auth_headers(self): hs = HeaderSigner( key_id="Test", secret=self.sign_secret, + sign_header=self.sign_header, algorithm=self.algorithm, headers=[ '(request-target)', 'host', @@ -154,6 +162,7 @@ def test_extra_auth_headers(self): secret=self.verify_secret, method=METHOD, path=PATH, + sign_header=self.sign_header, required_headers=['date', '(request-target)']) self.assertTrue(hv.verify()) @@ -205,3 +214,7 @@ class TestVerifyRSASHA512(TestVerifyRSASHA1): def setUp(self): super(TestVerifyRSASHA512, self).setUp() self.algorithm = "rsa-sha512" + + +class TestVerifyRSASHA512ChangeHeader(TestVerifyRSASHA1): + sign_header = 'Signature' diff --git a/httpsig/utils.py b/httpsig/utils.py index 9efc8d5..5f80ef0 100644 --- a/httpsig/utils.py +++ b/httpsig/utils.py @@ -88,6 +88,28 @@ def generate_message(required_headers, headers, host=None, method=None, 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. @@ -100,30 +122,13 @@ def parse_authorization_header(header): # Split up any args into a dictionary. values = {} if len(auth) == 2: - auth_value = auth[1] - if auth_value and len(auth_value): - # This is tricky string magic. Let urllib do it. - fields = parse_http_list(auth_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 + values = parse_signature_header(auth[1]) # ("Signature", {"headers": "date", "algorithm": "hmac-sha256", ... }) - return (auth[0], CaseInsensitiveDict(values)) + return (auth[0], values) -def build_signature_template(key_id, algorithm, headers): +def build_signature_template(key_id, algorithm, headers, sign_header='authorization'): """ Build the Signature template for use with the Authorization header. @@ -142,8 +147,10 @@ def build_signature_template(key_id, algorithm, headers): param_map['headers'] = ' '.join(headers) kv = map('{0[0]}="{0[1]}"'.format, param_map.items()) kv_string = ','.join(kv) - sig_string = 'Signature {0}'.format(kv_string) - return sig_string + if sign_header.lower() == 'authorization': + return 'Signature {0}'.format(kv_string) + + return kv_string def lkv(d): diff --git a/httpsig/verify.py b/httpsig/verify.py index c734337..17e313d 100644 --- a/httpsig/verify.py +++ b/httpsig/verify.py @@ -47,7 +47,7 @@ class HeaderVerifier(Verifier): """ def __init__(self, headers, secret, required_headers=None, method=None, - path=None, host=None): + path=None, host=None, sign_header='authorization'): """ Instantiate a HeaderVerifier object. @@ -64,16 +64,21 @@ def __init__(self, headers, secret, required_headers=None, method=None, 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) - auth = parse_authorization_header(headers['authorization']) - if len(auth) == 2: - self.auth_dict = auth[1] + 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: - raise HttpSigException("Invalid authorization header.") + self.auth_dict = parse_signature_header(self.headers[sign_header]) - self.headers = CaseInsensitiveDict(headers) self.required_headers = [s.lower() for s in required_headers] self.method = method self.path = path From 988c78e605b8a7f13ae80124cddf012573474528 Mon Sep 17 00:00:00 2001 From: Adam Knight Date: Wed, 28 Nov 2018 14:03:02 -0600 Subject: [PATCH 11/14] Updated requirements to 2.7 & 3.4-3.7. Mainly due to test failures of a required import on 3.3. --- README.rst | 4 ++-- setup.py | 2 +- tox.ini | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index c687784..bcc9c96 100644 --- a/README.rst +++ b/README.rst @@ -20,7 +20,7 @@ See the original project_, original Python module_, original spec_, and `current Requirements ------------ -* Python 2.7, 3.3-3.6 +* Python 2.7, 3.4-3.7 * PyCryptodome_ Optional: @@ -34,7 +34,7 @@ For testing: * tox * pyenv (optional, handy way to access multiple versions) - $ for VERS in 2.7.14 3.3.7 3.4.8 3.5.5 3.6.4; do pyenv install -s $VERS; done + $ 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 ----- diff --git a/setup.py b/setup.py index 77d18d2..06c98d5 100755 --- a/setup.py +++ b/setup.py @@ -20,10 +20,10 @@ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3.3", "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", ], diff --git a/tox.ini b/tox.ini index 039921d..7049f1c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27, py33, py34, py35, py36 +envlist = py27, py34, py35, py36, py37 [testenv] commands = python setup.py test From 2175a183edbc5d53c36e9a94cb683bff3d6b458d Mon Sep 17 00:00:00 2001 From: Adam Knight Date: Wed, 28 Nov 2018 14:23:22 -0600 Subject: [PATCH 12/14] Updated change log. --- CHANGELOG.rst | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8aa5c65..2489bcf 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,10 +1,18 @@ httpsig 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 +* 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. From 67b420a51f5c8caa8755ba827b3e41cc51a609c3 Mon Sep 17 00:00:00 2001 From: Adam Knight Date: Wed, 28 Nov 2018 14:33:07 -0600 Subject: [PATCH 13/14] Updated travis config --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 3d6c799..d3a4f4c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,10 @@ language: python python: - "2.7" - - "3.3" - "3.4" - "3.5" - "3.6" + - "3.7" install: - pip install . - pip install nose From 8712e9b026eb6053d42bc52612a3cc42380829df Mon Sep 17 00:00:00 2001 From: Adam Knight Date: Wed, 28 Nov 2018 14:37:16 -0600 Subject: [PATCH 14/14] Updated travis config -- they do not have 3.7 currently --- .travis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index d3a4f4c..f5acde5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,7 +4,6 @@ python: - "3.4" - "3.5" - "3.6" - - "3.7" install: - pip install . - pip install nose