From b546f17b1face1e1e4d8f2bef7a31eb3fc9b571c Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Wed, 3 Dec 2014 14:43:49 +0000 Subject: [PATCH 01/43] nest tests under `jose` module --- jose.py => jose/__init__.py | 0 tests.py => jose/tests/__init__.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename jose.py => jose/__init__.py (100%) rename tests.py => jose/tests/__init__.py (100%) diff --git a/jose.py b/jose/__init__.py similarity index 100% rename from jose.py rename to jose/__init__.py diff --git a/tests.py b/jose/tests/__init__.py similarity index 100% rename from tests.py rename to jose/tests/__init__.py From a08561270e8196a668c869cf230672b94c40d4f8 Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Wed, 3 Dec 2014 14:49:36 +0000 Subject: [PATCH 02/43] make it possible to run tests without nose --- jose/tests/__init__.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/jose/tests/__init__.py b/jose/tests/__init__.py index dc1edb9..17b68f2 100644 --- a/jose/tests/__init__.py +++ b/jose/tests/__init__.py @@ -301,5 +301,11 @@ def test_invalid_error(self): self.assertTrue(e.message.startswith('Unsupported')) -if __name__ == '__main__': - unittest.main() +loader = unittest.TestLoader() +suite = unittest.TestSuite(( + loader.loadTestsFromTestCase(TestSerializeDeserialize), + loader.loadTestsFromTestCase(TestJWE), + loader.loadTestsFromTestCase(TestJWS), + loader.loadTestsFromTestCase(TestUtils), + loader.loadTestsFromTestCase(TestJWA), +)) From 42303f4c05eebb039be6c2c87fc99c803579e702 Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Wed, 3 Dec 2014 14:50:44 +0000 Subject: [PATCH 03/43] register test suite --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index f56f5cc..5c477fd 100644 --- a/setup.py +++ b/setup.py @@ -61,4 +61,5 @@ def finalize_package_data(self): 'jose = jose:_cli', ) }, + test_suite='jose.tests.suite', ) From 9d7226e1095d49e671a760cda55e461f4b6e8f8a Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Wed, 3 Dec 2014 14:52:21 +0000 Subject: [PATCH 04/43] run tox tests using setup.py --- tox.ini | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 56a1d96..576737a 100644 --- a/tox.ini +++ b/tox.ini @@ -5,5 +5,4 @@ ignore = E128 envlist=py27 [testenv] -deps=nose -commands=nosetests +commands = python setup.py test From fe73198d315b4261c751c6f121f08950ca04a605 Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Wed, 3 Dec 2014 14:53:06 +0000 Subject: [PATCH 05/43] tox should run tests on py34 --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 576737a..753b1dc 100644 --- a/tox.ini +++ b/tox.ini @@ -2,7 +2,7 @@ ignore = E128 [tox] -envlist=py27 +envlist = py27, py34 [testenv] commands = python setup.py test From 9cb2d0617a5655b662927b00a1f9ba3e2b236634 Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Wed, 3 Dec 2014 14:56:24 +0000 Subject: [PATCH 06/43] run tests directly in travis --- .travis.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index ede2df1..b1ba32b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,9 @@ language: python python: - - "2.7" + - "2.7" install: - - pip install tox -script: tox + - "pip install -e ." + - "pip install flake8" +script: + - "python setup.py test" + - "flake8" From e5134c0523826b1477d2dac2f823777a925a8fba Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Wed, 3 Dec 2014 14:56:59 +0000 Subject: [PATCH 07/43] test under python 3.4 on travis --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index b1ba32b..f6a6ac9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,7 @@ language: python python: - "2.7" + - "3.4" install: - "pip install -e ." - "pip install flake8" From 7a8f1ae1289accdc8efb7b6b74cedfc338fa6ab4 Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Wed, 3 Dec 2014 15:01:01 +0000 Subject: [PATCH 08/43] don't load requirements from requirements.txt --- requirements.txt | 1 - setup.py | 5 +++-- 2 files changed, 3 insertions(+), 3 deletions(-) delete mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 9fadf90..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -pycrypto >= 2.6 diff --git a/setup.py b/setup.py index 5c477fd..1ce8800 100644 --- a/setup.py +++ b/setup.py @@ -5,8 +5,9 @@ from setuptools.command.bdist_rpm import bdist_rpm as _bdist_rpm here = os.path.abspath(os.path.dirname(__file__)) -REQUIRES = filter(lambda s: len(s) > 0, - open(os.path.join(here, 'requirements.txt')).read().split('\n')) +REQUIRES = [ + 'pycrypto >= 2.6', +] pkg_name = 'jose' pyver = ''.join(('python', '.'.join(map(str, sys.version_info[:2])))) From f30286f9e47a32599b4eac3d8dfa0b0dc90fa44a Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Wed, 3 Dec 2014 15:01:27 +0000 Subject: [PATCH 09/43] print function --- jose/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jose/__init__.py b/jose/__init__.py index 039f1ec..b77e845 100644 --- a/jose/__init__.py +++ b/jose/__init__.py @@ -502,8 +502,8 @@ def _jws_hash_str(header, claims): def cli_decrypt(jwt, key): - print decrypt(deserialize_compact(jwt), {'k':key}, - validate_claims=False) + print(decrypt(deserialize_compact(jwt), {'k':key}, + validate_claims=False)) def _cli(): From 4e26e317356312b874aaa07b038cda5ea86e4395 Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Wed, 3 Dec 2014 15:05:16 +0000 Subject: [PATCH 10/43] Exception.message is removed in python3. just use str --- jose/tests/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jose/tests/__init__.py b/jose/tests/__init__.py index 17b68f2..67246ce 100644 --- a/jose/tests/__init__.py +++ b/jose/tests/__init__.py @@ -28,7 +28,7 @@ def test_serialize(self): jose.deserialize_compact('1.2.3.4') self.fail() except jose.Error as e: - self.assertEqual(e.message, 'Malformed JWT') + self.assertEqual(str(e), 'Malformed JWT') class TestJWE(unittest.TestCase): From e08e5d1d7e7fd51bc59f14bdf9c8650a5d37c399 Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Wed, 3 Dec 2014 15:09:52 +0000 Subject: [PATCH 11/43] build header using `update` fixes py3 and makes precedence more explicit --- jose/__init__.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/jose/__init__.py b/jose/__init__.py index b77e845..90a3fb6 100644 --- a/jose/__init__.py +++ b/jose/__init__.py @@ -125,8 +125,15 @@ def encrypt(claims, jwk, adata='', add_header=None, alg='RSA-OAEP', :raises: :class:`~jose.Error` if there is an error producing the JWE """ - header = dict((add_header or {}).items() + [ - ('enc', enc), ('alg', alg)]) + header = {} + + if add_header: + header.update(add_header) + + header.update({ + 'enc': enc, + 'alg': alg, + }) plaintext = json_encode(claims) From 7f1da41b3c864abd65d85b1d1216416bfed6458a Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Wed, 3 Dec 2014 15:16:54 +0000 Subject: [PATCH 12/43] encode json plaintext as utf-8 --- jose/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jose/__init__.py b/jose/__init__.py index 90a3fb6..15b3809 100644 --- a/jose/__init__.py +++ b/jose/__init__.py @@ -135,7 +135,7 @@ def encrypt(claims, jwk, adata='', add_header=None, alg='RSA-OAEP', 'alg': alg, }) - plaintext = json_encode(claims) + plaintext = json_encode(claims).encode('utf-8') # compress (if required) if compression is not None: From fb3c513b4eb8033c3598b399dddab6f8380338f4 Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Wed, 3 Dec 2014 15:28:56 +0000 Subject: [PATCH 13/43] pad_pkcs7 with bytes not unicode code points --- jose/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/jose/__init__.py b/jose/__init__.py index 15b3809..906b290 100644 --- a/jose/__init__.py +++ b/jose/__init__.py @@ -309,7 +309,8 @@ def auth_tag(hmac): def pad_pkcs7(s): sz = AES.block_size - (len(s) % AES.block_size) - return s + (chr(sz) * sz) + # TODO would be cleaner to do `bytes(sz) * sz` but py2 behaves strangely + return s + (chr(sz) * sz).encode('ascii') def unpad_pkcs7(s): From a812c2dbf48bc0d5dcf70298c9c1d04fc9a6874d Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Wed, 3 Dec 2014 16:03:36 +0000 Subject: [PATCH 14/43] fix str literals in jwe and jws hash string functions --- jose/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/jose/__init__.py b/jose/__init__.py index 906b290..48c13d9 100644 --- a/jose/__init__.py +++ b/jose/__init__.py @@ -499,14 +499,14 @@ def _validate(claims, validate_claims, expiry_seconds): _check_not_before(now, not_before) -def _jwe_hash_str(plaintext, iv, adata=''): +def _jwe_hash_str(plaintext, iv, adata=b''): # http://tools.ietf.org/html/ # draft-ietf-jose-json-web-algorithms-24#section-5.2.2.1 - return '.'.join((adata, iv, plaintext, str(len(adata)))) + return b'.'.join((adata, iv, plaintext, bytes(len(adata)))) def _jws_hash_str(header, claims): - return '.'.join((header, claims)) + return b'.'.join((header, claims)) def cli_decrypt(jwt, key): From 17c8e16ba3ec9efdba4dfe5280293d0e3da32bc5 Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Wed, 3 Dec 2014 16:05:41 +0000 Subject: [PATCH 15/43] more references to deprecated Exception.message --- jose/tests/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/jose/tests/__init__.py b/jose/tests/__init__.py index 67246ce..1e22a6b 100644 --- a/jose/tests/__init__.py +++ b/jose/tests/__init__.py @@ -59,7 +59,7 @@ def test_jwe(self): jose.decrypt(jose.deserialize_compact(token), bad_key) self.fail() except jose.Error as e: - self.assertEqual(e.message, 'Incorrect decryption.') + self.assertEqual(str(e), 'Incorrect decryption.') def test_jwe_add_header(self): add_header = {'foo': 'bar'} @@ -85,7 +85,7 @@ def test_jwe_adata(self): rsa_priv_key) self.fail() except jose.Error as e: - self.assertEqual(e.message, 'Mismatched authentication tags') + self.assertEqual(str(e), 'Mismatched authentication tags') self.assertEqual(jwt.claims, claims) @@ -223,7 +223,7 @@ def test_decrypt_invalid_compression_error(self): jose.decrypt(jose.JWE(*((header,) + (jwe[1:]))), rsa_priv_key) self.fail() except jose.Error as e: - self.assertEqual(e.message, + self.assertEqual(str(e), 'Unsupported compression algorithm: BAD') @@ -254,7 +254,7 @@ def test_jws_signature_mismatch_error(self): try: jose.verify(jose.JWS(jws.header, jws.payload, 'asd'), jwk) except jose.Error as e: - self.assertEqual(e.message, 'Mismatched signatures') + self.assertEqual(str(e), 'Mismatched signatures') class TestUtils(unittest.TestCase): @@ -298,7 +298,7 @@ def test_invalid_error(self): jose.JWA['bad'] self.fail() except jose.Error as e: - self.assertTrue(e.message.startswith('Unsupported')) + self.assertTrue(str(e).startswith('Unsupported')) loader = unittest.TestLoader() From 656632f6093b994276479351420bc06e359abeb3 Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Wed, 3 Dec 2014 16:12:54 +0000 Subject: [PATCH 16/43] encode adata if necessary --- jose/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/jose/__init__.py b/jose/__init__.py index 48c13d9..c107bad 100644 --- a/jose/__init__.py +++ b/jose/__init__.py @@ -152,6 +152,10 @@ def encrypt(claims, jwk, adata='', add_header=None, alg='RSA-OAEP', iv = rng(AES.block_size) encryption_key = rng((key_size // 8) + hash_mod.digest_size) + if not isinstance(adata, bytes): + # TODO this should probably just be an error + adata = adata.encode('utf-8') + ciphertext = cipher(plaintext, encryption_key[:-hash_mod.digest_size], iv) hash = hash_fn(_jwe_hash_str(plaintext, iv, adata), encryption_key[-hash_mod.digest_size:], hash_mod) From 18ac1eedfa0ffe9318fa6e237db59ee7322ad9c2 Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Wed, 3 Dec 2014 16:15:01 +0000 Subject: [PATCH 17/43] wrap map results in a list for python 3 compatibility --- jose/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jose/__init__.py b/jose/__init__.py index c107bad..299b6d4 100644 --- a/jose/__init__.py +++ b/jose/__init__.py @@ -164,12 +164,12 @@ def encrypt(claims, jwk, adata='', add_header=None, alg='RSA-OAEP', (cipher, _), _ = JWA[alg] encryption_key_ciphertext = cipher(encryption_key, jwk) - return JWE(*map(b64encode_url, + return JWE(*list(map(b64encode_url, (json_encode(header), encryption_key_ciphertext, iv, ciphertext, - auth_tag(hash)))) + auth_tag(hash))))) def decrypt(jwe, jwk, adata='', validate_claims=True, expiry_seconds=None): From 073916de81a94e4f6a345d29bfd675be9b135921 Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Wed, 3 Dec 2014 16:16:33 +0000 Subject: [PATCH 18/43] build sign header using update --- jose/__init__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/jose/__init__.py b/jose/__init__.py index 299b6d4..64f0e46 100644 --- a/jose/__init__.py +++ b/jose/__init__.py @@ -238,7 +238,15 @@ def sign(claims, jwk, add_header=None, alg='HS256'): """ (hash_fn, _), mod = JWA[alg] - header = dict((add_header or {}).items() + [('alg', alg)]) + header = {} + + if add_header: + header.update(add_header) + + header.update({ + 'alg': alg, + }) + header, payload = map(b64encode_url, map(json_encode, (header, claims))) sig = b64encode_url(hash_fn(_jws_hash_str(header, payload), jwk['k'], From 1ccfa7e7a16cb3f1207850e857e238c59422af14 Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Wed, 3 Dec 2014 20:35:38 +0000 Subject: [PATCH 19/43] docstrings for b64encode_url and b64decode_url --- jose/__init__.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/jose/__init__.py b/jose/__init__.py index 64f0e46..65e5067 100644 --- a/jose/__init__.py +++ b/jose/__init__.py @@ -289,6 +289,9 @@ def verify(jws, jwk, validate_claims=True, expiry_seconds=None): def b64decode_url(istr): """ JWT Tokens may be truncated without the usual trailing padding '=' symbols. Compensate by padding to the nearest 4 bytes. + + :param istr: A unicode string to decode + :returns: The byte string represented by `istr` """ istr = encode_safe(istr) try: @@ -300,8 +303,12 @@ def b64decode_url(istr): def b64encode_url(istr): """ JWT Tokens may be truncated without the usual trailing padding '=' symbols. Compensate by padding to the nearest 4 bytes. + + :param istr: a byte string to encode + :returns: The base64 representation of the input byte string as a regular + `str` object """ - return urlsafe_b64encode(encode_safe(istr)).rstrip('=') + return urlsafe_b64encode(encode_safe(istr)).rstrip(b'=') def encode_safe(istr, encoding='utf8'): From 4dddb9255f1fd11e124f9a8f9e9e4bfe7c1fade7 Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Wed, 3 Dec 2014 20:36:25 +0000 Subject: [PATCH 20/43] input to b64decode should always be a unicode str in the ascii range --- jose/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/jose/__init__.py b/jose/__init__.py index 65e5067..1fe5726 100644 --- a/jose/__init__.py +++ b/jose/__init__.py @@ -293,7 +293,6 @@ def b64decode_url(istr): :param istr: A unicode string to decode :returns: The byte string represented by `istr` """ - istr = encode_safe(istr) try: return urlsafe_b64decode(istr + '=' * (4 - (len(istr) % 4))) except TypeError as e: From 3bd35c9ae154504b95a4e7c78d3d545287221e45 Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Wed, 3 Dec 2014 20:38:10 +0000 Subject: [PATCH 21/43] input to b64encode_url should always be an encoded byte string --- jose/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jose/__init__.py b/jose/__init__.py index 1fe5726..51859f9 100644 --- a/jose/__init__.py +++ b/jose/__init__.py @@ -307,7 +307,7 @@ def b64encode_url(istr): :returns: The base64 representation of the input byte string as a regular `str` object """ - return urlsafe_b64encode(encode_safe(istr)).rstrip(b'=') + return urlsafe_b64encode(istr).rstrip(b'=') def encode_safe(istr, encoding='utf8'): From c002f58e11ba53483f82ee918b9948a4167e47c1 Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Wed, 3 Dec 2014 20:39:08 +0000 Subject: [PATCH 22/43] delete unneeded encode_safe function --- jose/__init__.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/jose/__init__.py b/jose/__init__.py index 51859f9..9bb4d88 100644 --- a/jose/__init__.py +++ b/jose/__init__.py @@ -310,15 +310,6 @@ def b64encode_url(istr): return urlsafe_b64encode(istr).rstrip(b'=') -def encode_safe(istr, encoding='utf8'): - try: - return istr.encode(encoding) - except UnicodeDecodeError: - # this will fail if istr is already encoded - pass - return istr - - def auth_tag(hmac): # http://tools.ietf.org/html/ # draft-ietf-oauth-json-web-token-19#section-4.1.4 From 632a1bff82f4276198693d7a8124a14a7c1e4aa2 Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Wed, 3 Dec 2014 20:48:35 +0000 Subject: [PATCH 23/43] catch unencoded header string --- jose/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/jose/__init__.py b/jose/__init__.py index 9bb4d88..a575988 100644 --- a/jose/__init__.py +++ b/jose/__init__.py @@ -165,13 +165,14 @@ def encrypt(claims, jwk, adata='', add_header=None, alg='RSA-OAEP', encryption_key_ciphertext = cipher(encryption_key, jwk) return JWE(*list(map(b64encode_url, - (json_encode(header), + (json_encode(header).encode('utf-8'), encryption_key_ciphertext, iv, ciphertext, auth_tag(hash))))) + def decrypt(jwe, jwk, adata='', validate_claims=True, expiry_seconds=None): """ Decrypts a deserialized :class:`~jose.JWE` From 4e3a37c4bd3a5d69ac42fcde061a641e6d02b40a Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Wed, 3 Dec 2014 20:51:33 +0000 Subject: [PATCH 24/43] add debugging type checks to b64encode_url and b64decode_url --- jose/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/jose/__init__.py b/jose/__init__.py index a575988..01f5dca 100644 --- a/jose/__init__.py +++ b/jose/__init__.py @@ -294,6 +294,8 @@ def b64decode_url(istr): :param istr: A unicode string to decode :returns: The byte string represented by `istr` """ + if not isinstance(istr, str): + raise ValueError("expected string") try: return urlsafe_b64decode(istr + '=' * (4 - (len(istr) % 4))) except TypeError as e: @@ -308,6 +310,8 @@ def b64encode_url(istr): :returns: The base64 representation of the input byte string as a regular `str` object """ + if not isinstance(istr, bytes): + raise Exception("expected bytestring") return urlsafe_b64encode(istr).rstrip(b'=') From 4ac5f35cd71ef53c42df4d2f096f6765362913f3 Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Wed, 3 Dec 2014 20:53:17 +0000 Subject: [PATCH 25/43] b64encode bytestring in test_decrypt_invalid_compression_error --- jose/tests/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jose/tests/__init__.py b/jose/tests/__init__.py index 1e22a6b..4dfe6e7 100644 --- a/jose/tests/__init__.py +++ b/jose/tests/__init__.py @@ -216,8 +216,8 @@ def test_encrypt_invalid_compression_error(self): def test_decrypt_invalid_compression_error(self): jwe = jose.encrypt(claims, rsa_pub_key, compression='DEF') - header = jose.b64encode_url('{"alg": "RSA-OAEP", ' - '"enc": "A128CBC-HS256", "zip": "BAD"}') + header = jose.b64encode_url(b'{"alg": "RSA-OAEP", ' + b'"enc": "A128CBC-HS256", "zip": "BAD"}') try: jose.decrypt(jose.JWE(*((header,) + (jwe[1:]))), rsa_priv_key) From 67ed6fd456216c4da206855416704ec66729f731 Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Fri, 5 Dec 2014 14:29:40 +0000 Subject: [PATCH 26/43] b64encode_url should return a string --- jose/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jose/__init__.py b/jose/__init__.py index 01f5dca..513e944 100644 --- a/jose/__init__.py +++ b/jose/__init__.py @@ -312,7 +312,7 @@ def b64encode_url(istr): """ if not isinstance(istr, bytes): raise Exception("expected bytestring") - return urlsafe_b64encode(istr).rstrip(b'=') + return urlsafe_b64encode(istr).rstrip(b'=').decode('ascii') def auth_tag(hmac): From 8401f0f941c231a6ccbe64e69cc0eaf03de1490a Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Fri, 5 Dec 2014 14:31:32 +0000 Subject: [PATCH 27/43] adata should be a byte string --- jose/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/jose/__init__.py b/jose/__init__.py index 513e944..061c865 100644 --- a/jose/__init__.py +++ b/jose/__init__.py @@ -173,7 +173,7 @@ def encrypt(claims, jwk, adata='', add_header=None, alg='RSA-OAEP', -def decrypt(jwe, jwk, adata='', validate_claims=True, expiry_seconds=None): +def decrypt(jwe, jwk, adata=b'', validate_claims=True, expiry_seconds=None): """ Decrypts a deserialized :class:`~jose.JWE` :param jwe: An instance of :class:`~jose.JWE` @@ -203,6 +203,10 @@ def decrypt(jwe, jwk, adata='', validate_claims=True, expiry_seconds=None): # decrypt body ((_, decipher), _), ((hash_fn, _), mod) = JWA[header['enc']] + if not isinstance(adata, bytes): + # TODO this should probably just be an error + adata = adata.encode('utf-8') + plaintext = decipher(ciphertext, encryption_key[:-mod.digest_size], iv) hash = hash_fn(_jwe_hash_str(plaintext, iv, adata), encryption_key[-mod.digest_size:], mod=mod) From 1fed594354a881d00b721c711bcca62e1889a2cf Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Fri, 5 Dec 2014 14:31:52 +0000 Subject: [PATCH 28/43] hacky python3 fallback for unpad_pkcs7 --- jose/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/jose/__init__.py b/jose/__init__.py index 061c865..d6129d2 100644 --- a/jose/__init__.py +++ b/jose/__init__.py @@ -332,7 +332,11 @@ def pad_pkcs7(s): def unpad_pkcs7(s): - return s[:-ord(s[-1])] + try: + return s[:-ord(s[-1])] + # Python 3 compatibility + except TypeError: + return s[:-s[-1]] def encrypt_oaep(plaintext, jwk): From 6315bbb39666b6ef6ef1b70b18ef72830b5ff039 Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Fri, 5 Dec 2014 14:32:56 +0000 Subject: [PATCH 29/43] parse unicode before decoding header --- jose/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/jose/__init__.py b/jose/__init__.py index d6129d2..d0a4ec5 100644 --- a/jose/__init__.py +++ b/jose/__init__.py @@ -194,7 +194,8 @@ def decrypt(jwe, jwk, adata=b'', validate_claims=True, expiry_seconds=None): """ header, encryption_key_ciphertext, iv, ciphertext, tag = map( b64decode_url, jwe) - header = json_decode(header) + header = json_decode(header.decode('utf-8')) + # decrypt cek (_, decipher), _ = JWA[header['alg']] From daf26bcfc6a7619526396d843c1f0e07edf92c72 Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Fri, 5 Dec 2014 15:44:31 +0000 Subject: [PATCH 30/43] decode bytestring before calling json.loads --- jose/tests/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jose/tests/__init__.py b/jose/tests/__init__.py index 4dfe6e7..5905514 100644 --- a/jose/tests/__init__.py +++ b/jose/tests/__init__.py @@ -43,7 +43,7 @@ def test_jwe(self): # make sure the body can't be loaded as json (should be encrypted) try: - json.loads(jose.b64decode_url(jwe.ciphertext)) + json.loads(jose.b64decode_url(jwe.ciphertext).decode('utf-8')) self.fail() except ValueError: pass From 29870bab288ef3f2b4215496e35b3ffdd3a7501c Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Fri, 5 Dec 2014 15:47:03 +0000 Subject: [PATCH 31/43] decode bytes string before calling json_decode --- jose/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/jose/__init__.py b/jose/__init__.py index d0a4ec5..7eb7ed8 100644 --- a/jose/__init__.py +++ b/jose/__init__.py @@ -224,7 +224,7 @@ def decrypt(jwe, jwk, adata=b'', validate_claims=True, expiry_seconds=None): plaintext = decompress(plaintext) - claims = json_decode(plaintext) + claims = json_decode(plaintext.decode('utf-8')) _validate(claims, validate_claims, expiry_seconds) return JWT(header, claims) @@ -389,6 +389,8 @@ def decrypt_aescbc(ciphertext, key, iv): def const_compare(stra, strb): + # TODO TODO TODO + return stra == strb if len(stra) != len(strb): return False From b7ebf9e7df420d97945e14e1c1838943becdf5a1 Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Fri, 5 Dec 2014 15:48:21 +0000 Subject: [PATCH 32/43] just use range A 1000 integer array isn't big enough to cause problems --- jose/tests/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jose/tests/__init__.py b/jose/tests/__init__.py index 5905514..665da5b 100644 --- a/jose/tests/__init__.py +++ b/jose/tests/__init__.py @@ -191,7 +191,7 @@ def test_format_timestamp(self): def test_jwe_compression(self): local_claims = copy(claims) - for v in xrange(1000): + for v in range(1000): local_claims['dummy_' + str(v)] = '0' * 100 jwe = jose.serialize_compact(jose.encrypt(local_claims, rsa_pub_key)) From 57c0b5e780078660119fe14071ec4128d602d2af Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Fri, 5 Dec 2014 15:52:06 +0000 Subject: [PATCH 33/43] add else statement for calls that should raise exceptions --- jose/tests/__init__.py | 66 ++++++++++++++++++++---------------------- 1 file changed, 32 insertions(+), 34 deletions(-) diff --git a/jose/tests/__init__.py b/jose/tests/__init__.py index 665da5b..c26d0ed 100644 --- a/jose/tests/__init__.py +++ b/jose/tests/__init__.py @@ -96,14 +96,13 @@ def test_jwe_invalid_base64(self): try: jose.decrypt(jose.deserialize_compact(bad), rsa_priv_key) - self.fail() # expecting error due to invalid base64 except jose.Error as e: - pass - - self.assertEquals( - e.args[0], - 'Unable to decode base64: Incorrect padding' - ) + self.assertEquals( + e.args[0], + 'Unable to decode base64: Incorrect padding' + ) + else: + self.fail() # expecting error due to invalid base64 def test_jwe_no_error_with_exp_claim(self): claims = {jose.CLAIM_EXPIRATION_TIME: int(time()) + 5} @@ -116,16 +115,15 @@ def test_jwe_expired_error_with_exp_claim(self): try: jose.decrypt(jose.deserialize_compact(et), rsa_priv_key) - self.fail() # expecting expired token except jose.Expired as e: - pass - - self.assertEquals( - e.args[0], - 'Token expired at {}'.format( - jose._format_timestamp(claims[jose.CLAIM_EXPIRATION_TIME]) + self.assertEquals( + e.args[0], + 'Token expired at {}'.format( + jose._format_timestamp(claims[jose.CLAIM_EXPIRATION_TIME]) + ) ) - ) + else: + self.fail() # expecting expired token def test_jwe_no_error_with_iat_claim(self): claims = {jose.CLAIM_ISSUED_AT: int(time()) - 15} @@ -142,17 +140,16 @@ def test_jwe_expired_error_with_iat_claim(self): try: jose.decrypt(jose.deserialize_compact(et), rsa_priv_key, expiry_seconds=expiry_seconds) - self.fail() # expecting expired token except jose.Expired as e: - pass - - expiration_time = claims[jose.CLAIM_ISSUED_AT] + expiry_seconds - self.assertEquals( - e.args[0], - 'Token expired at {}'.format( - jose._format_timestamp(expiration_time) + expiration_time = claims[jose.CLAIM_ISSUED_AT] + expiry_seconds + self.assertEquals( + e.args[0], + 'Token expired at {}'.format( + jose._format_timestamp(expiration_time) + ) ) - ) + else: + self.fail() # expecting expired token def test_jwe_no_error_with_nbf_claim(self): claims = {jose.CLAIM_NOT_BEFORE: int(time()) - 5} @@ -165,16 +162,15 @@ def test_jwe_not_yet_valid_error_with_nbf_claim(self): try: jose.decrypt(jose.deserialize_compact(et), rsa_priv_key) - self.fail() # expecting not valid yet except jose.NotYetValid as e: - pass - - self.assertEquals( - e.args[0], - 'Token not valid until {}'.format( - jose._format_timestamp(claims[jose.CLAIM_NOT_BEFORE]) + self.assertEquals( + e.args[0], + 'Token not valid until {}'.format( + jose._format_timestamp(claims[jose.CLAIM_NOT_BEFORE]) + ) ) - ) + else: + self.fail() # expecting not valid yet def test_jwe_ignores_expired_token_if_validate_claims_is_false(self): claims = {jose.CLAIM_EXPIRATION_TIME: int(time()) - 5} @@ -210,9 +206,10 @@ def test_jwe_compression(self): def test_encrypt_invalid_compression_error(self): try: jose.encrypt(claims, rsa_pub_key, compression='BAD') - self.fail() except jose.Error: pass + else: + self.fail() def test_decrypt_invalid_compression_error(self): jwe = jose.encrypt(claims, rsa_pub_key, compression='DEF') @@ -221,10 +218,11 @@ def test_decrypt_invalid_compression_error(self): try: jose.decrypt(jose.JWE(*((header,) + (jwe[1:]))), rsa_priv_key) - self.fail() except jose.Error as e: self.assertEqual(str(e), 'Unsupported compression algorithm: BAD') + else: + self.fail() class TestJWS(unittest.TestCase): From 21f4d6699dfc343c9c3e63f775c46ae3078734b2 Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Fri, 5 Dec 2014 15:57:56 +0000 Subject: [PATCH 34/43] compact serialization should be a string not a byte string --- jose/tests/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jose/tests/__init__.py b/jose/tests/__init__.py index c26d0ed..4de2688 100644 --- a/jose/tests/__init__.py +++ b/jose/tests/__init__.py @@ -92,7 +92,7 @@ def test_jwe_adata(self): def test_jwe_invalid_base64(self): claims = {jose.CLAIM_EXPIRATION_TIME: int(time()) - 5} et = jose.serialize_compact(jose.encrypt(claims, rsa_pub_key)) - bad = b'\x00' + et + bad = '\x00' + et try: jose.decrypt(jose.deserialize_compact(bad), rsa_priv_key) From 219733b41b1c817bcc57454a12e1a8f0a54f4709 Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Fri, 5 Dec 2014 16:15:26 +0000 Subject: [PATCH 35/43] break up map --- jose/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/jose/__init__.py b/jose/__init__.py index 7eb7ed8..11cc3d8 100644 --- a/jose/__init__.py +++ b/jose/__init__.py @@ -253,7 +253,8 @@ def sign(claims, jwk, add_header=None, alg='HS256'): 'alg': alg, }) - header, payload = map(b64encode_url, map(json_encode, (header, claims))) + header = b64encode_url(json_encode(header).encode('utf-8')) + payload = b64encode_url(json_encode(claims).encode('utf-8')) sig = b64encode_url(hash_fn(_jws_hash_str(header, payload), jwk['k'], mod=mod)) From 630e693e6468948c7d2db08d894dfcc932029b1d Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Fri, 5 Dec 2014 16:15:41 +0000 Subject: [PATCH 36/43] base64 params are unicode strings --- jose/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jose/__init__.py b/jose/__init__.py index 11cc3d8..4663cc7 100644 --- a/jose/__init__.py +++ b/jose/__init__.py @@ -532,7 +532,7 @@ def _jwe_hash_str(plaintext, iv, adata=b''): def _jws_hash_str(header, claims): - return b'.'.join((header, claims)) + return b'.'.join((header.encode('ascii'), claims.encode('ascii'))) def cli_decrypt(jwt, key): From 34ec1d12a15631245374f33dfcfebb8ffb197c96 Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Fri, 5 Dec 2014 16:26:26 +0000 Subject: [PATCH 37/43] fix implementation of const_compare for both python versions --- jose/__init__.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/jose/__init__.py b/jose/__init__.py index 4663cc7..ceafe52 100644 --- a/jose/__init__.py +++ b/jose/__init__.py @@ -390,14 +390,18 @@ def decrypt_aescbc(ciphertext, key, iv): def const_compare(stra, strb): - # TODO TODO TODO - return stra == strb if len(stra) != len(strb): return False + try: + # python 2 compatibility + orda, ordb = list(map(ord, stra)), list(map(ord, strb)) + except TypeError: + orda, ordb = stra, strb + res = 0 - for a, b in zip(stra, strb): - res |= ord(a) ^ ord(b) + for a, b in zip(orda, ordb): + res |= a ^ b return res == 0 From c9b9da1d6cac9bfd6d214d11a026cc86d3723670 Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Fri, 5 Dec 2014 16:27:18 +0000 Subject: [PATCH 38/43] more decoding charset before json --- jose/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jose/__init__.py b/jose/__init__.py index ceafe52..2ad4753 100644 --- a/jose/__init__.py +++ b/jose/__init__.py @@ -280,14 +280,14 @@ def verify(jws, jwk, validate_claims=True, expiry_seconds=None): :raises: :class:`~jose.Error` if there is an error decrypting the JWE """ header, payload, sig = map(b64decode_url, jws) - header = json_decode(header) + header = json_decode(header.decode('utf-8')) (_, verify_fn), mod = JWA[header['alg']] if not verify_fn(_jws_hash_str(jws.header, jws.payload), jwk['k'], sig, mod=mod): raise Error('Mismatched signatures') - claims = json_decode(b64decode_url(jws.payload)) + claims = json_decode(b64decode_url(jws.payload).decode('utf-8')) _validate(claims, validate_claims, expiry_seconds) return JWT(header, claims) From bdcae119d6a93bdabe51082a44c20b4255273e5b Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Tue, 9 Dec 2014 12:46:11 +0000 Subject: [PATCH 39/43] fix problems with python2 getting confused by unicode objects --- jose/__init__.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/jose/__init__.py b/jose/__init__.py index 2ad4753..748873f 100644 --- a/jose/__init__.py +++ b/jose/__init__.py @@ -21,6 +21,13 @@ from Crypto.Signature import PKCS1_v1_5 as PKCS1_v1_5_SIG +try: + # python 2 compatibility + unicode +except NameError: + unicode = str + + __all__ = ['encrypt', 'decrypt', 'sign', 'verify'] @@ -300,8 +307,14 @@ def b64decode_url(istr): :param istr: A unicode string to decode :returns: The byte string represented by `istr` """ - if not isinstance(istr, str): - raise ValueError("expected string") + # unicode check for python 2 compatibility + if not isinstance(istr, (str, unicode)): + raise ValueError("expected string, got %r" % type(istr)) + + # required for python 2 as urlsafe_b64decode does not like unicode objects + # safe as b64 encoded string should be only ascii anyway + istr = str(istr) + try: return urlsafe_b64decode(istr + '=' * (4 - (len(istr) % 4))) except TypeError as e: From 0a826a0510d129efd1facf45885fa4652ebb004b Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Tue, 9 Dec 2014 12:50:21 +0000 Subject: [PATCH 40/43] in python 3 b64decode raises a binascii.Error if padding is incorrect --- jose/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/jose/__init__.py b/jose/__init__.py index 748873f..5c14b44 100644 --- a/jose/__init__.py +++ b/jose/__init__.py @@ -11,6 +11,7 @@ import datetime from base64 import urlsafe_b64encode, urlsafe_b64decode +import binascii from collections import namedtuple from time import time @@ -317,7 +318,7 @@ def b64decode_url(istr): try: return urlsafe_b64decode(istr + '=' * (4 - (len(istr) % 4))) - except TypeError as e: + except (TypeError, binascii.Error) as e: raise Error('Unable to decode base64: %s' % (e)) From 1790d2dc1d35283ea149ea1fd2db0183c89c8524 Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Tue, 9 Dec 2014 12:53:13 +0000 Subject: [PATCH 41/43] b64encode expects byte string --- jose/tests/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/jose/tests/__init__.py b/jose/tests/__init__.py index 4de2688..a10b508 100644 --- a/jose/tests/__init__.py +++ b/jose/tests/__init__.py @@ -262,18 +262,18 @@ def test_b64encode_url_utf8(self): self.assertEqual(jose.b64decode_url(encoded), istr) def test_b64encode_url_ascii(self): - istr = 'eric idle' + istr = b'eric idle' encoded = jose.b64encode_url(istr) self.assertEqual(jose.b64decode_url(encoded), istr) def test_b64encode_url(self): - istr = '{"alg": "RSA-OAEP", "enc": "A128CBC-HS256"}' + istr = b'{"alg": "RSA-OAEP", "enc": "A128CBC-HS256"}' # sanity check - self.assertEqual(b64encode(istr)[-1], '=') + self.assertTrue(b64encode(istr).endswith(b'=')) # actual test - self.assertNotEqual(jose.b64encode_url(istr), '=') + self.assertFalse(jose.b64encode_url(istr).endswith('=')) class TestJWA(unittest.TestCase): From c0b27a33420e35eaf283f0174e8a5ee3260ddd3f Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Tue, 9 Dec 2014 12:56:03 +0000 Subject: [PATCH 42/43] assertEquals is deprecated --- jose/tests/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/jose/tests/__init__.py b/jose/tests/__init__.py index a10b508..9e4cf76 100644 --- a/jose/tests/__init__.py +++ b/jose/tests/__init__.py @@ -97,7 +97,7 @@ def test_jwe_invalid_base64(self): try: jose.decrypt(jose.deserialize_compact(bad), rsa_priv_key) except jose.Error as e: - self.assertEquals( + self.assertEqual( e.args[0], 'Unable to decode base64: Incorrect padding' ) @@ -116,7 +116,7 @@ def test_jwe_expired_error_with_exp_claim(self): try: jose.decrypt(jose.deserialize_compact(et), rsa_priv_key) except jose.Expired as e: - self.assertEquals( + self.assertEqual( e.args[0], 'Token expired at {}'.format( jose._format_timestamp(claims[jose.CLAIM_EXPIRATION_TIME]) @@ -142,7 +142,7 @@ def test_jwe_expired_error_with_iat_claim(self): expiry_seconds=expiry_seconds) except jose.Expired as e: expiration_time = claims[jose.CLAIM_ISSUED_AT] + expiry_seconds - self.assertEquals( + self.assertEqual( e.args[0], 'Token expired at {}'.format( jose._format_timestamp(expiration_time) @@ -163,7 +163,7 @@ def test_jwe_not_yet_valid_error_with_nbf_claim(self): try: jose.decrypt(jose.deserialize_compact(et), rsa_priv_key) except jose.NotYetValid as e: - self.assertEquals( + self.assertEqual( e.args[0], 'Token not valid until {}'.format( jose._format_timestamp(claims[jose.CLAIM_NOT_BEFORE]) @@ -179,7 +179,7 @@ def test_jwe_ignores_expired_token_if_validate_claims_is_false(self): validate_claims=False) def test_format_timestamp(self): - self.assertEquals( + self.assertEqual( jose._format_timestamp(1403054056), '2014-06-18T01:14:16Z' ) From 88cd33144ae58e8a2a1381d46511255a450ee453 Mon Sep 17 00:00:00 2001 From: Ben Mather Date: Tue, 9 Dec 2014 13:02:03 +0000 Subject: [PATCH 43/43] use "python 2" instead of "py2" to make compatibility changes searcheable --- jose/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/jose/__init__.py b/jose/__init__.py index 5c14b44..2e7591f 100644 --- a/jose/__init__.py +++ b/jose/__init__.py @@ -343,7 +343,8 @@ def auth_tag(hmac): def pad_pkcs7(s): sz = AES.block_size - (len(s) % AES.block_size) - # TODO would be cleaner to do `bytes(sz) * sz` but py2 behaves strangely + # TODO would be cleaner to do `bytes(sz) * sz` but python 2 behaves + # strangely return s + (chr(sz) * sz).encode('ascii')