From b528da62d4481d2b91ec8f53ac98b98968da6f4c Mon Sep 17 00:00:00 2001 From: jrconlin Date: Tue, 9 May 2017 15:01:38 -0700 Subject: [PATCH] feat: update to use Cryptography library * uses lastest ece(1.7.2) and vapid libraries (1.2.1) * Will attempt to autofill vapid `aud` from the endpoint if VAPID requested * Allows for the older `'aesgcm'` and newer, albeit not as widely supported `'aes128gcm'` encryption content types. * Includes fixes provided by https://github.com/Flimm NOTE: Currently BLOCKED due to https://github.com/martinthomson/encrypted-content-encoding/issues/36 closes: #49, #48, #42 --- README.md | 11 +++-- README.rst | 13 ++++-- pywebpush/__init__.py | 38 +++++++-------- pywebpush/tests/test_webpush.py | 83 ++++++++++++++++----------------- requirements.txt | 6 +-- setup.py | 2 +- test-requirements.txt | 2 +- 7 files changed, 80 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 7d218f6..3d60c2f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ -[![Build_Status](https://travis-ci.org/web-push-libs/pywebpush.svg?branch=master)](https://travis-ci.org/web-push-libs/pywebpush) +[![Build +Status](https://travis-ci.org/web-push-libs/pywebpush.svg?branch=master)](https://travis-ci.org/web-push-libs/pywebpush) [![Requirements -Status](https://requires.io/github/web-push-libs/pywebpush/requirements.svg?branch=feat%2F44)](https://requires.io/github/web-push-libs/pywebpush/requirements/?branch=master) +Status](https://requires.io/github/web-push-libs/pywebpush/requirements.svg?branch=master)](https://requires.io/github/web-push-libs/pywebpush/requirements/?branch=master) # Webpush Data encryption library for Python @@ -61,8 +62,12 @@ in the `subscription_info` block. *data* - can be any serial content (string, bit array, serialized JSON, etc), but be sure that your receiving application is able to parse and understand it. (e.g. `data = "Mary had a little lamb."`) +*content_type* - specifies the form of Encryption to use, either `'aesgcm'` or the newer `'aes128gcm'`. NOTE that +not all User Agents can decrypt `'aes128gcm'`, so the library defaults to the older form. + *vapid_claims* - a `dict` containing the VAPID claims required for authorization (See -[py_vapid](https://github.com/web-push-libs/vapid/tree/master/python) for more details) +[py_vapid](https://github.com/web-push-libs/vapid/tree/master/python) for more details). If `aud` is not specified, +pywebpush will attempt to auto-fill from the `endpoint`. *vapid_private_key* - Either a path to a VAPID EC2 private key PEM file, or a string containing the DER representation. (See [py_vapid](https://github.com/web-push-libs/vapid/tree/master/python) for more details.) The `private_key` may be diff --git a/README.rst b/README.rst index 9ca9cbe..71604aa 100644 --- a/README.rst +++ b/README.rst @@ -1,4 +1,4 @@ -|Build\_Status| |Requirements Status| +|Build Status| |Requirements Status| Webpush Data encryption library for Python ========================================== @@ -65,10 +65,15 @@ above). etc), but be sure that your receiving application is able to parse and understand it. (e.g. ``data = "Mary had a little lamb."``) +*content\_type* - specifies the form of Encryption to use, either +``'aesgcm'`` or the newer ``'aes128gcm'``. NOTE that not all User Agents +can decrypt ``'aes128gcm'``, so the library defaults to the older form. + *vapid\_claims* - a ``dict`` containing the VAPID claims required for authorization (See `py\_vapid `__ -for more details) +for more details). If ``aud`` is not specified, pywebpush will attempt +to auto-fill from the ``endpoint``. *vapid\_private\_key* - Either a path to a VAPID EC2 private key PEM file, or a string containing the DER representation. (See @@ -170,7 +175,7 @@ Encode the ``data`` for future use. On error, returns a encoded_data = WebPush(subscription_info).encode(data) -.. |Build\_Status| image:: https://travis-ci.org/web-push-libs/pywebpush.svg?branch=master +.. |Build Status| image:: https://travis-ci.org/web-push-libs/pywebpush.svg?branch=master :target: https://travis-ci.org/web-push-libs/pywebpush -.. |Requirements Status| image:: https://requires.io/github/web-push-libs/pywebpush/requirements.svg?branch=feat%2F44 +.. |Requirements Status| image:: https://requires.io/github/web-push-libs/pywebpush/requirements.svg?branch=master :target: https://requires.io/github/web-push-libs/pywebpush/requirements/?branch=master diff --git a/pywebpush/__init__.py b/pywebpush/__init__.py index 3bb48e3..55d4a8f 100644 --- a/pywebpush/__init__.py +++ b/pywebpush/__init__.py @@ -13,8 +13,9 @@ except ImportError: # pragma nocover import six import http_ece -import pyelliptic import requests +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.asymmetric import ec from py_vapid import Vapid @@ -112,7 +113,7 @@ class WebPusher: keys = self.subscription_info['keys'] for k in ['p256dh', 'auth']: if keys.get(k) is None: - raise WebPushException("Missing keys value: %s", k) + raise WebPushException("Missing keys value: {}".format(k)) if isinstance(keys[k], six.string_types): keys[k] = bytes(keys[k].encode('utf8')) receiver_raw = base64.urlsafe_b64decode( @@ -155,31 +156,25 @@ class WebPusher: salt = os.urandom(16) # The server key is an ephemeral ECDH key used only for this # transaction - server_key = pyelliptic.ECC(curve="prime256v1") - # the ID is the base64 of the raw key, minus the leading "\x04" - # ID tag. - server_key_id = base64.urlsafe_b64encode(server_key.get_pubkey()[1:]) + server_key = ec.generate_private_key(ec.SECP256R1, default_backend()) + crypto_key = base64.urlsafe_b64encode( + server_key.public_key().public_numbers().encode_point() + ).strip(b'=') if isinstance(data, six.string_types): data = bytes(data.encode('utf8')) - key_id = server_key_id.decode('utf8') - # http_ece requires that these both be set BEFORE encrypt or - # decrypt is called if you specify the key as "dh". - http_ece.keys[key_id] = server_key - http_ece.labels[key_id] = "P-256" - encrypted = http_ece.encrypt( data, salt=salt, - keyid=key_id, + keyid=crypto_key.decode(), + private_key=server_key, dh=self.receiver_key, - authSecret=self.auth_key, + auth_secret=self.auth_key, version=content_encoding) reply = CaseInsensitiveDict({ - 'crypto_key': base64.urlsafe_b64encode( - server_key.get_pubkey()).strip(b'='), + 'crypto_key': crypto_key, 'body': encrypted, }) if salt: @@ -329,7 +324,7 @@ def webpush(subscription_info, :type subscription_info: dict :param data: Serialized data to send :type data: str - :param vapid_private_key: Dath to vapid private key PEM or encoded str + :param vapid_private_key: Path to vapid private key PEM or encoded str :type vapid_private_key: str :param vapid_claims: Dictionary of claims ('sub' required) :type vapid_claims: dict @@ -344,16 +339,17 @@ def webpush(subscription_info, if vapid_claims: if not vapid_claims.get('aud'): url = urlparse(subscription_info.get('endpoint')) - aud = "{}://{}/".format(url.scheme, url.netloc) + aud = "{}://{}".format(url.scheme, url.netloc) vapid_claims['aud'] = aud if not vapid_private_key: raise WebPushException("VAPID dict missing 'private_key'") if os.path.isfile(vapid_private_key): # Presume that key from file is handled correctly by # py_vapid. - vv = Vapid(private_key_file=vapid_private_key) # pragma no cover + vv = Vapid.from_file( + private_key_file=vapid_private_key) # pragma no cover else: - vv = Vapid(private_key=vapid_private_key) + vv = Vapid.from_raw(private_raw=vapid_private_key.encode()) vapid_headers = vv.sign(vapid_claims) result = WebPusher(subscription_info).send( data, @@ -362,6 +358,6 @@ def webpush(subscription_info, curl=curl, ) if not curl and result.status_code > 202: - raise WebPushException("Push failed: {}:".format( + raise WebPushException("Push failed: {}: {}".format( result, result.text)) return result diff --git a/pywebpush/tests/test_webpush.py b/pywebpush/tests/test_webpush.py index ee332fa..71b54e7 100644 --- a/pywebpush/tests/test_webpush.py +++ b/pywebpush/tests/test_webpush.py @@ -6,7 +6,8 @@ import unittest from mock import patch, Mock from nose.tools import eq_, ok_, assert_raises import http_ece -import pyelliptic +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.backends import default_backend from pywebpush import WebPusher, WebPushException, CaseInsensitiveDict, webpush @@ -21,17 +22,24 @@ class WebpushTestCase(unittest.TestCase): "M5xqEwuPM7VuQcyiLDhvovthPIXx+gsQRQ==" ) - def _gen_subscription_info(self, recv_key, + def _gen_subscription_info(self, + recv_key=None, endpoint="https://example.com/"): + if not recv_key: + recv_key = ec.generate_private_key(ec.SECP256R1, default_backend()) return { "endpoint": endpoint, "keys": { 'auth': base64.urlsafe_b64encode(os.urandom(16)).strip(b'='), - 'p256dh': base64.urlsafe_b64encode( - recv_key.get_pubkey()).strip(b'='), + 'p256dh': self._get_pubkey_str(recv_key), } } + def _get_pubkey_str(self, priv_key): + return base64.urlsafe_b64encode( + priv_key.public_key().public_numbers().encode_point() + ).strip(b'=') + def test_init(self): # use static values so we know what to look for in the reply subscription_info = { @@ -72,14 +80,17 @@ class WebpushTestCase(unittest.TestCase): def test_encode(self): for content_encoding in ["aesgcm", "aes128gcm"]: - recv_key = pyelliptic.ECC(curve="prime256v1") + recv_key = ec.generate_private_key( + ec.SECP256R1, default_backend()) subscription_info = self._gen_subscription_info(recv_key) data = "Mary had a little lamb, with some nice mint jelly" push = WebPusher(subscription_info) encoded = push.encode(data, content_encoding=content_encoding) - keyid = base64.urlsafe_b64encode(recv_key.get_pubkey()[1:]) - http_ece.keys[keyid] = recv_key - http_ece.labels[keyid] = 'P-256' + """ + crypto_key = base64.urlsafe_b64encode( + self._get_pubkey_str(recv_key) + ).strip(b'=') + """ # Convert these b64 strings into their raw, binary form. raw_salt = None if 'salt' in encoded: @@ -94,15 +105,14 @@ class WebpushTestCase(unittest.TestCase): encoded['body'], salt=raw_salt, dh=raw_dh, - keyid=keyid, - authSecret=raw_auth, + private_key=recv_key, + auth_secret=raw_auth, version=content_encoding ) eq_(decoded.decode('utf8'), data) def test_bad_content_encoding(self): - recv_key = pyelliptic.ECC(curve="prime256v1") - subscription_info = self._gen_subscription_info(recv_key) + subscription_info = self._gen_subscription_info() data = "Mary had a little lamb, with some nice mint jelly" push = WebPusher(subscription_info) self.assertRaises(WebPushException, @@ -112,8 +122,7 @@ class WebpushTestCase(unittest.TestCase): @patch("requests.post") def test_send(self, mock_post): - recv_key = pyelliptic.ECC(curve="prime256v1") - subscription_info = self._gen_subscription_info(recv_key) + subscription_info = self._gen_subscription_info() headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"} data = "Mary had a little lamb" @@ -131,9 +140,7 @@ class WebpushTestCase(unittest.TestCase): def test_send_vapid(self, mock_post): mock_post.return_value = Mock() mock_post.return_value.status_code = 200 - recv_key = pyelliptic.ECC(curve="prime256v1") - - subscription_info = self._gen_subscription_info(recv_key) + subscription_info = self._gen_subscription_info() data = "Mary had a little lamb" webpush( subscription_info=subscription_info, @@ -165,43 +172,40 @@ class WebpushTestCase(unittest.TestCase): def test_send_bad_vapid_no_key(self, mock_post): mock_post.return_value = Mock() mock_post.return_value.status_code = 200 - recv_key = pyelliptic.ECC(curve="prime256v1") - subscription_info = self._gen_subscription_info(recv_key) + subscription_info = self._gen_subscription_info() data = "Mary had a little lamb" assert_raises(WebPushException, webpush, subscription_info=subscription_info, data=data, vapid_claims={ - "aud": "https://example.com", - "sub": "mailto:ops@example.com" - } + "aud": "https://example.com", + "sub": "mailto:ops@example.com" + } ) @patch("requests.post") def test_send_bad_vapid_bad_return(self, mock_post): mock_post.return_value = Mock() mock_post.return_value.status_code = 410 - recv_key = pyelliptic.ECC(curve="prime256v1") - subscription_info = self._gen_subscription_info(recv_key) + subscription_info = self._gen_subscription_info() data = "Mary had a little lamb" assert_raises(WebPushException, webpush, subscription_info=subscription_info, data=data, vapid_claims={ - "aud": "https://example.com", - "sub": "mailto:ops@example.com" - }, + "aud": "https://example.com", + "sub": "mailto:ops@example.com" + }, vapid_private_key=self.vapid_key ) @patch("requests.post") def test_send_empty(self, mock_post): - recv_key = pyelliptic.ECC(curve="prime256v1") - subscription_info = self._gen_subscription_info(recv_key) + subscription_info = self._gen_subscription_info() headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"} WebPusher(subscription_info).send('', headers) @@ -214,16 +218,14 @@ class WebpushTestCase(unittest.TestCase): ok_('pre-existing' in ckey) def test_encode_empty(self): - recv_key = pyelliptic.ECC(curve="prime256v1") - subscription_info = self._gen_subscription_info(recv_key) + subscription_info = self._gen_subscription_info() headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"} encoded = WebPusher(subscription_info).encode('', headers) eq_(encoded, None) def test_encode_no_crypto(self): - recv_key = pyelliptic.ECC(curve="prime256v1") - subscription_info = self._gen_subscription_info(recv_key) + subscription_info = self._gen_subscription_info() del(subscription_info['keys']) headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"} @@ -236,8 +238,7 @@ class WebpushTestCase(unittest.TestCase): @patch("requests.post") def test_send_no_headers(self, mock_post): - recv_key = pyelliptic.ECC(curve="prime256v1") - subscription_info = self._gen_subscription_info(recv_key) + subscription_info = self._gen_subscription_info() data = "Mary had a little lamb" WebPusher(subscription_info).send(data) eq_(subscription_info.get('endpoint'), mock_post.call_args[0][0]) @@ -248,15 +249,14 @@ class WebpushTestCase(unittest.TestCase): @patch("pywebpush.open") def test_as_curl(self, opener): - recv_key = pyelliptic.ECC(curve="prime256v1") - subscription_info = self._gen_subscription_info(recv_key) + subscription_info = self._gen_subscription_info() result = webpush( subscription_info, data="Mary had a little lamb", vapid_claims={ - "aud": "https://example.com", - "sub": "mailto:ops@example.com" - }, + "aud": "https://example.com", + "sub": "mailto:ops@example.com" + }, vapid_private_key=self.vapid_key, curl=True ) @@ -281,9 +281,8 @@ class WebpushTestCase(unittest.TestCase): @patch("requests.post") def test_gcm(self, mock_post): - recv_key = pyelliptic.ECC(curve="prime256v1") subscription_info = self._gen_subscription_info( - recv_key, + None, endpoint="https://android.googleapis.com/gcm/send/regid123") headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"} diff --git a/requirements.txt b/requirements.txt index 26f522b..e770f36 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -http-ece==0.7.1 -python-jose==1.3.2 +cryptography==1.8.1 +http-ece==1.0.1 requests==2.13.0 -py-vapid==0.8.1 +py-vapid==1.2.1 diff --git a/setup.py b/setup.py index 13629be..66d8dd6 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ import os from setuptools import find_packages, setup -__version__ = "0.8.0" +__version__ = "1.0.0" def read_from(file): diff --git a/test-requirements.txt b/test-requirements.txt index 8668601..ec48245 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,5 +1,5 @@ -r requirements.txt nose>=1.3.7 -coverage>=4.3.4 +coverage>=4.4 mock==2.0.0 flake8