Skip to content

Commit 0f480d7

Browse files
committed
Merge branch 'master' of github.com:web-push-libs/pywebpush
2 parents 9c95cdc + b6348a6 commit 0f480d7

File tree

10 files changed

+240
-84
lines changed

10 files changed

+240
-84
lines changed

.travis.yml

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
language: python
22
python:
3-
- "2.7"
3+
- "2.7"
4+
- "3.6"
45
install:
5-
- pip install -r requirements.txt
6-
- pip install -r test-requirements.txt
6+
- pip install -r requirements.txt
7+
- pip install -r test-requirements.txt
78
script:
8-
- nosetests
9-
- flake8 pywebpush
9+
- nosetests
10+
- flake8 pywebpush
1011
after_success:
11-
- codecov
12+
- codecov

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
## 0.7.0 (2017-02-14)
2+
feat: update to http-ece 0.7.0 (with draft-06 support)
3+
feat: Allow empty payloads for send()
4+
feat: Add python3 classfiers & python3.6 travis tests
5+
feat: Add README.rst
6+
bug: change long to int to support python3
7+
18
## 0.4.0 (2016-06-05)
29
feat: make python 2.7 / 3.5 polyglot
310

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
[![Build_Status](https://travis-ci.org/jrconlin/pywebpush.svg?branch=master)](https://travis-ci.org/jrconlin/pywebpush)
2+
[![Requirements
3+
Status](https://requires.io/github/web-push-libs/pywebpush/requirements.svg?branch=master)]
4+
25

36
# Webpush Data encryption library for Python
47

README.rst

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
|Build\_Status| [|Requirements Status|]
2+
3+
Webpush Data encryption library for Python
4+
==========================================
5+
6+
This is a work in progress. This library is available on `pypi as
7+
pywebpush <https://pypi.python.org/pypi/pywebpush>`__. Source is
8+
available on `github <https://github.com/web-push-libs/pywebpush>`__
9+
10+
Installation
11+
------------
12+
13+
You'll need to run ``python virtualenv``. Then
14+
15+
::
16+
17+
bin/pip install -r requirements.txt
18+
bin/python setup.py develop
19+
20+
Usage
21+
-----
22+
23+
In the browser, the promise handler for
24+
`registration.pushManager.subscribe() <https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe>`__
25+
returns a
26+
`PushSubscription <https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription>`__
27+
object. This object has a .toJSON() method that will return a JSON
28+
object that contains all the info we need to encrypt and push data.
29+
30+
As illustration, a subscription info object may look like:
31+
32+
::
33+
34+
{"endpoint": "https://updates.push.services.mozilla.com/push/v1/gAA...", "keys": {"auth": "k8J...", "p256dh": "BOr..."}}
35+
36+
How you send the PushSubscription data to your backend, store it
37+
referenced to the user who requested it, and recall it when there's new
38+
a new push subscription update is left as an excerise for the reader.
39+
40+
The data can be any serial content (string, bit array, serialized JSON,
41+
etc), but be sure that your receiving application is able to parse and
42+
understand it. (e.g. ``data = "Mary had a little lamb."``)
43+
44+
gcm\_key is the API key obtained from the Google Developer Console. It
45+
is only needed if endpoint is https://android.googleapis.com/gcm/send
46+
47+
``headers`` is a ``dict``\ ionary of additional HTTP header values (e.g.
48+
`VAPID <https://github.com/mozilla-services/vapid/tree/master/python>`__
49+
self identification headers). It is optional and may be omitted.
50+
51+
to send:
52+
53+
::
54+
55+
WebPusher(subscription_info).send(data, headers)
56+
57+
to send for Chrome:
58+
59+
::
60+
61+
WebPusher(subscription_info).send(data, headers, ttl, gcm_key)
62+
63+
You can also simply encode the data to send later by calling
64+
65+
::
66+
67+
encoded = WebPush(subscription_info).encode(data)
68+
69+
.. |Build\_Status| image:: https://travis-ci.org/web-push-libs/pywebpush.svg?branch=master
70+
:target: https://travis-ci.org/web-push-libs/pywebpush
71+
.. |Requirements Status| image:: https://requires.io/github/web-push-libs/pywebpush/requirements.svg?branch=master

circle.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# circle ci file
2+
machine:
3+
post:
4+
- pyenv global 2.7.13 3.5
5+
6+
dependencies:
7+
pre:
8+
- pip install -r test-requirements.txt
9+
10+
test:
11+
override:
12+
- nosetests -v pywebpush

convert_readme.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pandoc --from=markdown --to=rst --output README.rst README.md

pywebpush/__init__.py

Lines changed: 57 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ class WebPusher:
8282
8383
"""
8484
subscription_info = {}
85+
valid_encodings = [
86+
# "aesgcm128", # this is draft-0, but DO NOT USE.
87+
"aesgcm", # draft-httpbis-encryption-encoding-01
88+
"aes128gcm" # draft-httpbis-encryption-encoding-04
89+
]
8590

8691
def __init__(self, subscription_info):
8792
"""Initialize using the info provided by the client PushSubscription
@@ -113,16 +118,28 @@ def _repad(self, data):
113118
"""Add base64 padding to the end of a string, if required"""
114119
return data + b"===="[:len(data) % 4]
115120

116-
def encode(self, data):
121+
def encode(self, data, content_encoding="aesgcm"):
117122
"""Encrypt the data.
118123
119124
:param data: A serialized block of byte data (String, JSON, bit array,
120125
etc.) Make sure that whatever you send, your client knows how
121126
to understand it.
127+
:type data: str
128+
:param content_encoding: The content_encoding type to use to encrypt
129+
the data. Defaults to draft-01 "aesgcm". Latest draft-04 is
130+
"aes128gcm", however not all clients may be able to use this
131+
format.
132+
:type content_encoding: enum("aesgcm", "aes128gcm")
122133
123134
"""
124135
# Salt is a random 16 byte array.
125-
salt = os.urandom(16)
136+
salt = None
137+
if content_encoding not in self.valid_encodings:
138+
raise WebPushException("Invalid content encoding specified. "
139+
"Select from " +
140+
json.dumps(self.valid_encodings))
141+
if (content_encoding == "aesgcm"):
142+
salt = os.urandom(16)
126143
# The server key is an ephemeral ECDH key used only for this
127144
# transaction
128145
server_key = pyelliptic.ECC(curve="prime256v1")
@@ -133,26 +150,31 @@ def encode(self, data):
133150
if isinstance(data, six.string_types):
134151
data = bytes(data.encode('utf8'))
135152

153+
key_id = server_key_id.decode('utf8')
136154
# http_ece requires that these both be set BEFORE encrypt or
137155
# decrypt is called if you specify the key as "dh".
138-
http_ece.keys[server_key_id] = server_key
139-
http_ece.labels[server_key_id] = "P-256"
156+
http_ece.keys[key_id] = server_key
157+
http_ece.labels[key_id] = "P-256"
140158

141159
encrypted = http_ece.encrypt(
142160
data,
143161
salt=salt,
144-
keyid=server_key_id,
162+
keyid=key_id,
145163
dh=self.receiver_key,
146-
authSecret=self.auth_key)
164+
authSecret=self.auth_key,
165+
version=content_encoding)
147166

148-
return CaseInsensitiveDict({
167+
reply = CaseInsensitiveDict({
149168
'crypto_key': base64.urlsafe_b64encode(
150169
server_key.get_pubkey()).strip(b'='),
151-
'salt': base64.urlsafe_b64encode(salt).strip(b'='),
152170
'body': encrypted,
153171
})
172+
if salt:
173+
reply['salt'] = base64.urlsafe_b64encode(salt).strip(b'=')
174+
return reply
154175

155-
def send(self, data, headers=None, ttl=0, gcm_key=None, reg_id=None):
176+
def send(self, data=None, headers=None, ttl=0, gcm_key=None, reg_id=None,
177+
content_encoding="aesgcm"):
156178
"""Encode and send the data to the Push Service.
157179
158180
:param data: A serialized block of data (see encode() ).
@@ -169,22 +191,25 @@ def send(self, data, headers=None, ttl=0, gcm_key=None, reg_id=None):
169191
# Encode the data.
170192
if headers is None:
171193
headers = dict()
172-
encoded = self.encode(data)
173-
# Append the p256dh to the end of any existing crypto-key
194+
encoded = {}
174195
headers = CaseInsensitiveDict(headers)
175-
crypto_key = headers.get("crypto-key", "")
176-
if crypto_key:
177-
# due to some confusion by a push service provider, we should
178-
# use ';' instead of ',' to append the headers.
179-
# see https://github.com/webpush-wg/webpush-encryption/issues/6
180-
crypto_key += ';'
181-
crypto_key += "keyid=p256dh;dh=" + encoded["crypto_key"].decode('utf8')
182-
headers.update({
183-
'crypto-key': crypto_key,
184-
'content-encoding': 'aesgcm',
185-
'encryption': "keyid=p256dh;salt=" +
186-
encoded['salt'].decode('utf8'),
187-
})
196+
if data:
197+
encoded = self.encode(data)
198+
# Append the p256dh to the end of any existing crypto-key
199+
crypto_key = headers.get("crypto-key", "")
200+
if crypto_key:
201+
# due to some confusion by a push service provider, we should
202+
# use ';' instead of ',' to append the headers.
203+
# see https://github.com/webpush-wg/webpush-encryption/issues/6
204+
crypto_key += ';'
205+
crypto_key += (
206+
"keyid=p256dh;dh=" + encoded["crypto_key"].decode('utf8'))
207+
headers.update({
208+
'crypto-key': crypto_key,
209+
'content-encoding': 'aesgcm',
210+
'encryption': "keyid=p256dh;salt=" +
211+
encoded['salt'].decode('utf8'),
212+
})
188213
gcm_endpoint = 'https://android.googleapis.com/gcm/send'
189214
if self.subscription_info['endpoint'].startswith(gcm_endpoint):
190215
if not gcm_key:
@@ -194,12 +219,14 @@ def send(self, data, headers=None, ttl=0, gcm_key=None, reg_id=None):
194219
if not reg_id:
195220
reg_id = self.subscription_info['endpoint'].rsplit('/', 1)[-1]
196221
reg_ids.append(reg_id)
197-
data = dict()
198-
data['registration_ids'] = reg_ids
199-
data['raw_data'] = base64.b64encode(
200-
encoded.get('body')).decode('utf8')
201-
data['time_to_live'] = int(headers['ttl'] if 'ttl' in headers else ttl)
202-
encoded_data = json.dumps(data)
222+
gcm_data = dict()
223+
gcm_data['registration_ids'] = reg_ids
224+
if data:
225+
gcm_data['raw_data'] = base64.b64encode(
226+
encoded.get('body')).decode('utf8')
227+
gcm_data['time_to_live'] = int(
228+
headers['ttl'] if 'ttl' in headers else ttl)
229+
encoded_data = json.dumps(gcm_data)
203230
headers.update({
204231
'Authorization': 'key='+gcm_key,
205232
'Content-Type': 'application/json',

pywebpush/tests/test_webpush.py

Lines changed: 51 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
import os
44
import unittest
55

6-
import http_ece
76
from mock import patch
87
from nose.tools import eq_, ok_
8+
import http_ece
99
import pyelliptic
1010

1111
from pywebpush import WebPusher, WebPushException, CaseInsensitiveDict
@@ -65,32 +65,44 @@ def test_init(self):
6565
eq_(push.auth_key, b'\x93\xc2U\xea\xc8\xddn\x10"\xd6}\xff,0K\xbc')
6666

6767
def test_encode(self):
68+
for content_encoding in ["aesgcm", "aes128gcm"]:
69+
recv_key = pyelliptic.ECC(curve="prime256v1")
70+
subscription_info = self._gen_subscription_info(recv_key)
71+
data = "Mary had a little lamb, with some nice mint jelly"
72+
push = WebPusher(subscription_info)
73+
encoded = push.encode(data, content_encoding=content_encoding)
74+
keyid = base64.urlsafe_b64encode(recv_key.get_pubkey()[1:])
75+
http_ece.keys[keyid] = recv_key
76+
http_ece.labels[keyid] = 'P-256'
77+
# Convert these b64 strings into their raw, binary form.
78+
raw_salt = None
79+
if 'salt' in encoded:
80+
raw_salt = base64.urlsafe_b64decode(
81+
push._repad(encoded['salt']))
82+
raw_dh = base64.urlsafe_b64decode(
83+
push._repad(encoded['crypto_key']))
84+
raw_auth = base64.urlsafe_b64decode(
85+
push._repad(subscription_info['keys']['auth']))
86+
87+
decoded = http_ece.decrypt(
88+
encoded['body'],
89+
salt=raw_salt,
90+
dh=raw_dh,
91+
keyid=keyid,
92+
authSecret=raw_auth,
93+
version=content_encoding
94+
)
95+
eq_(decoded.decode('utf8'), data)
96+
97+
def test_bad_content_encoding(self):
6898
recv_key = pyelliptic.ECC(curve="prime256v1")
6999
subscription_info = self._gen_subscription_info(recv_key)
70100
data = "Mary had a little lamb, with some nice mint jelly"
71101
push = WebPusher(subscription_info)
72-
encoded = push.encode(data)
73-
74-
keyid = base64.urlsafe_b64encode(recv_key.get_pubkey()[1:])
75-
76-
http_ece.keys[keyid] = recv_key
77-
http_ece.labels[keyid] = 'P-256'
78-
79-
# Convert these b64 strings into their raw, binary form.
80-
raw_salt = base64.urlsafe_b64decode(push._repad(encoded['salt']))
81-
raw_dh = base64.urlsafe_b64decode(push._repad(encoded['crypto_key']))
82-
raw_auth = base64.urlsafe_b64decode(
83-
push._repad(subscription_info['keys']['auth']))
84-
85-
decoded = http_ece.decrypt(
86-
buffer=encoded['body'],
87-
salt=raw_salt,
88-
dh=raw_dh,
89-
keyid=keyid,
90-
authSecret=raw_auth
91-
)
92-
93-
eq_(decoded.decode('utf8'), data)
102+
self.assertRaises(WebPushException,
103+
push.encode,
104+
data,
105+
content_encoding="aesgcm128")
94106

95107
@patch("requests.post")
96108
def test_send(self, mock_post):
@@ -109,6 +121,22 @@ def test_send(self, mock_post):
109121
ok_('pre-existing' in ckey)
110122
eq_(pheaders.get('content-encoding'), 'aesgcm')
111123

124+
@patch("requests.post")
125+
def test_send_empty(self, mock_post):
126+
recv_key = pyelliptic.ECC(curve="prime256v1")
127+
subscription_info = self._gen_subscription_info(recv_key)
128+
headers = {"Crypto-Key": "pre-existing",
129+
"Authentication": "bearer vapid"}
130+
data = None
131+
WebPusher(subscription_info).send(data, headers)
132+
eq_(subscription_info.get('endpoint'), mock_post.call_args[0][0])
133+
pheaders = mock_post.call_args[1].get('headers')
134+
eq_(pheaders.get('ttl'), '0')
135+
ok_('encryption' not in pheaders)
136+
eq_(pheaders.get('AUTHENTICATION'), headers.get('Authentication'))
137+
ckey = pheaders.get('crypto-key')
138+
ok_('pre-existing' in ckey)
139+
112140
@patch("requests.post")
113141
def test_send_no_headers(self, mock_post):
114142
recv_key = pyelliptic.ECC(curve="prime256v1")

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
http-ece==0.5.0
1+
http-ece==0.7.0
22
python-jose>1.2.0
33
requests>2.11.0
44
flake8

0 commit comments

Comments
 (0)