Skip to content
This repository was archived by the owner on Apr 13, 2024. It is now read-only.

Commit b993d5e

Browse files
committed
Lots of updates for 1.0b2
* Written against http://tools.ietf.org/html/draft-cavage-http-signatures-02 * Added "setup.py test" and tox support. * Added sign/verify unit tests for all currently-supported algorithms. * HeaderSigner and HeaderVerifier now share the same message-building logic. * The HTTP method in the message is now properly lower-case. * Resolved unit test failures. * Updated Verifier and HeaderVerifier to handle verifying both RSA and HMAC sigs. * Updated versioneer. * Updated contact/author info. * Removed stray keypair in test dir. * Removed SSH agent support. * Removed suport for reading keyfiles from disk as this is a huge security hole if this is used in a server framework like drf-httpsig.
1 parent 7cf459c commit b993d5e

21 files changed

+769
-602
lines changed

.gitignore

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,13 @@
1-
.DS_Store
1+
*.egg
2+
*.egg-info
23
*.pyc
34
*~
4-
*.sqlite
5-
*.sqlite-journal
6-
settings_local.py
7-
local_settings.py
8-
.*.sw[po]
5+
.noseids
6+
.tox
7+
build/
98
dist/
10-
*.egg-info
119
doc/__build/*
12-
build/
10+
*_rsa
11+
*_rsa.pub
1312
locale/
1413
pip-log.txt
15-
devdatabase.db
16-
.directory
17-
bundle_version.gen
18-
celeryd.log
19-
celeryd.pid
20-
joy_rsa
21-
joy_rsa.pub
22-
.noseids

CHANGES.rst

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,32 @@
1-
Changes
2-
-------
1+
httpsig Changes
2+
---------------
33

4-
0.2.0-AK (2014-Jun-23)
4+
1.0b2 (2014-Jul-01)
5+
~~~~~~~~~~~~~~~~~~~
6+
* Written against http://tools.ietf.org/html/draft-cavage-http-signatures-02
7+
* Added "setup.py test" and tox support.
8+
* Added sign/verify unit tests for all currently-supported algorithms.
9+
* HeaderSigner and HeaderVerifier now share the same message-building logic.
10+
* The HTTP method in the message is now properly lower-case.
11+
* Resolved unit test failures.
12+
* Updated Verifier and HeaderVerifier to handle verifying both RSA and HMAC sigs.
13+
* Updated versioneer.
14+
* Updated contact/author info.
15+
* Removed stray keypair in test dir.
16+
* Removed SSH agent support.
17+
* Removed suport for reading keyfiles from disk as this is a huge security hole if this is used in a server framework like drf-httpsig.
18+
19+
1.0b1 (2014-Jun-23)
520
~~~~~~~~~~~~~~~~~~~~~~
621
* Removed HTTP version from request-line, per spec (breaks backwards compatability).
722
* Removed auto-generation of missing Date header (ensures client compatability).
823

24+
25+
http-signature (previous)
26+
-------------------------
27+
928
0.2.0 (unreleased)
10-
~~~~~~~~
29+
~~~~~~~~~~~~~~~~~~
1130

1231
* Update to newer spec (incompatible with prior version).
1332
* Handle `request-line` meta-header.

LICENSE

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
Copyright (c) 2012 Adam T. Lindsay
1+
Copyright (c) 2014 Adam Knight
2+
Copyright (c) 2012 Adam T. Lindsay (original author)
23

34
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
45

MANIFEST

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ httpsig/requests_auth.py
1010
httpsig/sign.py
1111
httpsig/utils.py
1212
httpsig/verify.py
13-
tests/__init__.py
14-
tests/test_signature.py
15-
tests/test_utils.py
16-
tests/test_verify.py
13+
httpsig/tests/__init__.py
14+
httpsig/tests/test_signature.py
15+
httpsig/tests/test_utils.py
16+
httpsig/tests/test_verify.py

README.rst

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
Python http-signature
2-
=====================
1+
httpsig
2+
=======
33

4-
Sign HTTP requests with secure signatures. See the original project_, original spec_, and IETF draft_ for details.
4+
Sign HTTP requests with secure signatures. See the original project_, original Python module_, original spec_, and IETF draft_ for details.
55

66
.. _project: https://github.com/joyent/node-http-signature
7+
.. _module: https://github.com/zzsnzmn/py-http-signature
78
.. _spec: https://github.com/joyent/node-http-signature/blob/master/http_signing.md
89
.. _draft: https://datatracker.ietf.org/doc/draft-cavage-http-signatures/
910

@@ -14,12 +15,9 @@ Requirements
1415

1516
Optional:
1617

17-
* ssh_ or paramiko_ >= 1.8.0 (for ssh-agent integration)
1818
* requests_
1919

2020
.. _PyCrypto: https://pypi.python.org/pypi/pycrypto
21-
.. _ssh: https://pypi.python.org/pypi/ssh
22-
.. _paramiko: https://pypi.python.org/pypi/paramiko
2321
.. _requests: https://pypi.python.org/pypi/requests
2422

2523
Usage
@@ -29,7 +27,9 @@ for simple raw signing::
2927

3028
import httpsig
3129
32-
sig_maker = httpsig.Signer(secret='test.pem', algorithm='rsa-sha256')
30+
secret = open('rsa_private.pem', 'r').read()
31+
32+
sig_maker = httpsig.Signer(secret=secret, algorithm='rsa-sha256')
3333
sig_maker.sign('hello world!')
3434

3535
for use with requests::
@@ -38,36 +38,37 @@ for use with requests::
3838
import requests
3939
from httpsig.requests_auth import HTTPSignatureAuth
4040
41-
auth = HTTPSignatureAuth(key_id='Test', secret='test.pem')
42-
z = requests.get('https://api.joyentcloud.com/my/packages/Small+1GB',
41+
secret = open('rsa_private.pem', 'r').read()
42+
43+
auth = HTTPSignatureAuth(key_id='Test', secret=secret)
44+
z = requests.get('https://api.example.com/path/to/endpoint',
4345
auth=auth, headers={'X-Api-Version': '~6.5'})
4446

4547
Class initialization parameters
4648
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
4749

4850
::
4951

50-
httpsig.Signer(secret='', algorithm='rsa-sha256', allow_agent=False)
52+
httpsig.Signer(secret, algorithm='rsa-sha256')
5153

52-
``secret``, in the case of an rsa signature, is a path to a private RSA pem file. In the case of an hmac, it is a secret password.
54+
``secret``, in the case of an RSA signature, is a string containing private RSA pem. In the case of HMAC, it is a secret password.
5355
``algorithm`` is one of the six allowed signatures: ``rsa-sha1``, ``rsa-sha256``, ``rsa-sha512``, ``hmac-sha1``, ``hmac-sha256``,
5456
``hmac-sha512``.
55-
``allow_agent`` uses the ``ssh`` package to find an ``ssh-agent`` instance running, and uses that to sign all requests. Note that if so, this overrides manual selection of the signing algorithm to ``rsa-sha1``.
5657

5758
::
5859

59-
httpsig.requests_auth.HTTPSignatureAuth(key_id='', secret='', algorithm='rsa-sha256', headers=None, allow_agent=False)
60+
httpsig.requests_auth.HTTPSignatureAuth(key_id, secret, algorithm='rsa-sha256', headers=None)
6061

6162
``key_id`` is the label by which the server system knows your RSA signature or password.
6263
``headers`` is the list of HTTP headers that are concatenated and used as signing objects. By default it is the specification's minimum, the ``Date`` HTTP header.
63-
``secret``, ``algorithm``, and ``allow_agent`` are as above.
64+
``secret`` and ``algorithm`` are as above.
6465

6566
Tests
6667
-----
6768

6869
To run tests::
6970

70-
python -m unittest discover
71+
python setup.py test
7172

7273
License
7374
-------

httpsig/sign.py

Lines changed: 24 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,20 @@
1-
from datetime import datetime
2-
from getpass import getpass
3-
from os.path import expanduser
4-
from time import mktime
5-
from wsgiref.handlers import format_date_time
61
import base64
72

8-
from Crypto.Hash import SHA256, SHA, SHA512, HMAC
3+
from Crypto.Hash import HMAC
94
from Crypto.PublicKey import RSA
105
from Crypto.Signature import PKCS1_v1_5
116

12-
from .utils import sig, is_rsa, CaseInsensitiveDict
7+
from .utils import generate_message, sig, is_rsa, CaseInsensitiveDict, ALGORITHMS, HASHES, HttpSigException
138

14-
ALGORITHMS = frozenset(['rsa-sha1', 'rsa-sha256', 'rsa-sha512', 'hmac-sha1', 'hmac-sha256', 'hmac-sha512'])
15-
HASHES = {'sha1': SHA,
16-
'sha256': SHA256,
17-
'sha512': SHA512}
189

1910
class Signer(object):
20-
def __init__(self, secret='~/.ssh/id_rsa', algorithm='rsa-sha256'):
11+
"""
12+
When using an RSA algo, the secret is a PEM-encoded private key.
13+
When using an HMAC algo, the secret is the HMAC signing secret.
14+
15+
Password-protected keyfiles are not supported.
16+
"""
17+
def __init__(self, secret, algorithm='rsa-sha256'):
2118
assert algorithm in ALGORITHMS, "Unknown algorithm"
2219
self._rsa = False
2320
self._hash = None
@@ -33,19 +30,14 @@ def algorithm(self):
3330
return '%s-%s' % (self.sign_algorithm, self.hash_algorithm)
3431

3532
def _get_key(self, secret):
36-
if (secret.startswith('-----BEGIN RSA PRIVATE KEY-----') or
37-
secret.startswith('-----BEGIN PRIVATE KEY-----')):
38-
# string with PEM encoded key data
39-
k = secret
40-
else:
41-
# file with key data
42-
with open(expanduser(secret)) as fh:
43-
k = fh.read()
33+
# if not (secret.startswith('-----BEGIN RSA PRIVATE KEY-----') or secret.startswith('-----BEGIN PRIVATE KEY-----')):
34+
# raise HttpSigException("Invalid PEM key")
35+
4436
try:
45-
rsa_key = RSA.importKey(k)
37+
rsa_key = RSA.importKey(secret)
4638
except ValueError:
47-
pw = getpass('RSA SSH Key Password: ')
48-
rsa_key = RSA.importKey(k, pw)
39+
raise HttpSigException("Invalid key.")
40+
4941
return PKCS1_v1_5.new(rsa_key)
5042

5143
def _sign_rsa(self, sign_string):
@@ -58,48 +50,14 @@ def _sign_hmac(self, sign_string):
5850
hmac.update(sign_string)
5951
return hmac.digest()
6052

61-
6253
def _sign(self, sign_string):
6354
data = None
6455
if self._rsa:
6556
data = self._sign_rsa(sign_string)
6657
elif self._hash:
6758
data = self._sign_hmac(sign_string)
6859
if not data:
69-
raise SystemError('No valid encryption: try allow_agent=False ?')
70-
return base64.b64encode(data)
71-
72-
73-
class AgentSigner(Signer):
74-
def __init__(self, secret='~/.ssh/id_rsa', algorithm='rsa-sha256'):
75-
super(AgentSigner, self).__init__()
76-
self._agent_key = False
77-
78-
def _get_key(self):
79-
try:
80-
import paramiko as ssh
81-
except ImportError:
82-
import ssh
83-
keys = ssh.Agent().get_keys()
84-
self._keys = filter(is_rsa, keys)
85-
if self._keys:
86-
self._agent_key = self._keys[0]
87-
self._keys = self._keys[1:]
88-
self.sign_algorithm, self.hash_algorithm = ('rsa', 'sha1')
89-
90-
def swap_keys(self):
91-
if self._keys:
92-
self._agent_key = self._keys[0]
93-
self._keys = self._keys[1:]
94-
else:
95-
self._agent_key = None
96-
97-
def sign_agent(self, sign_string):
98-
data = self._agent_key.sign_ssh_data(None, sign_string)
99-
return sig(data)
100-
101-
def sign(self, sign_string):
102-
data = self.sign_agent(sign_string)
60+
raise SystemError('No valid encryptor found.')
10361
return base64.b64encode(data)
10462

10563

@@ -111,10 +69,9 @@ class HeaderSigner(Signer):
11169
key_id is the mandatory label indicating to the server which secret to use
11270
secret is the filename of a pem file in the case of rsa, a password string in the case of an hmac algorithm
11371
algorithm is one of the six specified algorithms
114-
headers is a list of http headers to be included in the signing string, defaulting to "Date" alone.
72+
headers is a list of http headers to be included in the signing string, defaulting to ['date'].
11573
'''
116-
def __init__(self, key_id='', secret='~/.ssh/id_rsa', algorithm='rsa-sha256', headers=None):
117-
74+
def __init__(self, key_id, secret, algorithm='rsa-sha256', headers=None):
11875
#PyCrypto wants strings, not unicode. We're not so demanding as an API.
11976
key_id = str(key_id)
12077
secret = str(secret)
@@ -150,37 +107,15 @@ def sign(self, headers, host=None, method=None, path=None):
150107
151108
headers is a case-insensitive dict of mutable headers.
152109
host is a override for the 'host' header (defaults to value in headers).
153-
method is the HTTP method (used for '(request-line)').
154-
path is the HTTP path (used for '(request-line)').
110+
method is the HTTP method (required when using '(request-line)').
111+
path is the HTTP path (required when using '(request-line)').
155112
"""
156113
headers = CaseInsensitiveDict(headers)
157-
158114
required_headers = self.headers or ['date']
159-
signable_list = []
160-
for h in required_headers:
161-
if h == '(request-line)':
162-
if not method or not path:
163-
raise Exception('method and path arguments required when using "(request-line)"')
164-
signable_list.append('%s: %s %s' % (h, method.lower(), path))
165-
166-
elif h == 'host':
167-
# 'host' special case due to requests lib restrictions
168-
# 'host' is not available when adding auth so must use a param
169-
# if no param used, defaults back to the 'host' header
170-
if not host:
171-
if 'host' in headers:
172-
host = headers[h]
173-
else:
174-
raise Exception('missing required header "%s"' % (h))
175-
signable_list.append('%s: %s' % (h.lower(), host))
176-
else:
177-
if h not in headers:
178-
raise Exception('missing required header "%s"' % (h))
179-
180-
signable_list.append('%s: %s' % (h.lower(), headers[h]))
181-
182-
signable = '\n'.join(signable_list)
115+
signable = generate_message(required_headers, headers, host, method, path)
116+
183117
signature = self._sign(signable)
184118
headers['Authorization'] = self.signature_template % signature
185-
119+
186120
return headers
121+

httpsig/tests/__init__.py

Whitespace-only changes.
Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,8 @@ def _parse_auth(self, auth):
2525
return param_dict
2626

2727
def setUp(self):
28-
self.key = os.path.join(os.path.dirname(__file__), 'rsa_private.pem')
29-
30-
def test_date_added(self):
31-
hs = HeaderSigner(key_id='', secret=self.key)
32-
unsigned = {}
33-
signed = hs.sign(unsigned)
34-
self.assertIn('Date', signed)
35-
self.assertIn('Authorization', signed)
28+
self.key_path = os.path.join(os.path.dirname(__file__), 'rsa_private.pem')
29+
self.key = open(self.key_path, 'r').read()
3630

3731
def test_default(self):
3832
hs = HeaderSigner(key_id='Test', secret=self.key)
@@ -67,8 +61,8 @@ def test_all(self):
6761
'Content-MD5': 'Sd/dVLAcvNLSq16eXua5uQ==',
6862
'Content-Length': '18',
6963
}
70-
signed = hs.sign(unsigned, method='POST',
71-
path='/foo?param=value&pet=dog')
64+
signed = hs.sign(unsigned, method='POST', path='/foo?param=value&pet=dog')
65+
7266
self.assertIn('Date', signed)
7367
self.assertEqual(unsigned['Date'], signed['Date'])
7468
self.assertIn('Authorization', signed)
@@ -79,7 +73,4 @@ def test_all(self):
7973
self.assertEqual(params['keyId'], 'Test')
8074
self.assertEqual(params['algorithm'], 'rsa-sha256')
8175
self.assertEqual(params['headers'], '(request-line) host date content-type content-md5 content-length')
82-
self.assertEqual(params['signature'], 'H/AaTDkJvLELy4i1RujnKlS6dm8QWiJvEpn9cKRMi49kKF+mohZ15z1r+mF+XiKS5kOOscyS83olfBtsVhYjPg2Ei3/D9D4Mvb7bFm9IaLJgYTFFuQCghrKQQFPiqJN320emjHxFowpIm1BkstnEU7lktH/XdXVBo8a6Uteiztw=')
83-
84-
if __name__ == '__main__':
85-
unittest.main()
76+
self.assertEqual(params['signature'], 'vYJio4AxbN38TKdzE1Qk/3qXhzTaBS7zUIPCqV+NsjLSf8ZK/19L9ErTz8FYBAW8Gko2dEaU70McrIO33k0PUlPsWvbGn/IhnU14rvSPF/F+AnFVFeA9ivvvyVZQYYYp17fnNfiCzHrvUn+VnqMhRKA15Nr8KKwt9Eqi36wQ8Vg=')

0 commit comments

Comments
 (0)