Skip to content

Commit 84c46d0

Browse files
authored
End-to-end Encryption
* add end-to-end encryption feature * add end-to-end encryption tests
1 parent 76cb14c commit 84c46d0

File tree

10 files changed

+504
-40
lines changed

10 files changed

+504
-40
lines changed

README.md

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ In order to use this library, you need to have a free account on <http://pusher.
2424
- [Getting Information For A Specific Channel](#getting-information-for-a-specific-channel)
2525
- [Getting User Information For A Presence Channel](#getting-user-information-for-a-presence-channel)
2626
- [Authenticating Channel Subscription](#authenticating-channel-subscription)
27+
- [End-to-end Encryption (Beta)](#end-to-end-encryption-beta)
2728
- [Receiving Webhooks](#receiving-webhooks)
2829
- [Request Library Configuration](#request-library-configuration)
2930
- [Google App Engine](#google-app-engine)
@@ -55,22 +56,22 @@ constructor arguments which identify your Pusher app. You can find them by
5556
going to "API Keys" on your app at <https://app.pusher.com>.
5657

5758
```python
58-
from pusher import Pusher
59-
pusher = Pusher(app_id=u'4', key=u'key', secret=u'secret', cluster=u'cluster')
59+
import pusher
60+
pusher_client = pusher.Pusher(app_id=u'4', key=u'key', secret=u'secret', cluster=u'cluster')
6061
```
6162

6263
You can then trigger events to channels. Channel and event names may only
6364
contain alphanumeric characters, `-` and `_`:
6465

6566
```python
66-
pusher.trigger(u'a_channel', u'an_event', {u'some': u'data'})
67+
pusher_client.trigger(u'a_channel', u'an_event', {u'some': u'data'})
6768
```
6869

6970
## Configuration
7071

7172
```python
72-
from pusher import Pusher
73-
pusher = Pusher(app_id, key, secret, cluster=u'cluster')
73+
import pusher
74+
pusher_client = pusher.Pusher(app_id, key, secret, cluster=u'cluster')
7475
```
7576

7677
|Argument |Description |
@@ -82,6 +83,7 @@ pusher = Pusher(app_id, key, secret, cluster=u'cluster')
8283
|host `String` | **Default:`None`** <br> The host to connect to |
8384
|port `int` | **Default:`None`** <br>Which port to connect to |
8485
|ssl `bool` | **Default:`True`** <br> Use HTTPS |
86+
|encryption_master_key `String` | **Default:`None`** <br> The encryption master key for End-to-end Encryption |
8587
|backend `Object` | an object that responds to the `send_request(request)` method. If none is provided, a `pusher.requests.RequestsBackend` instance is created. |
8688
|json_encoder `Object` | **Default: `None`**<br> Custom JSON encoder. |
8789
|json_decoder `Object` | **Default: `None`**<br> Custom JSON decoder.
@@ -91,8 +93,8 @@ The constructor will throw a `TypeError` if it is called with parameters that do
9193
##### Example
9294

9395
```py
94-
from pusher import Pusher
95-
pusher = Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=u'cluster')
96+
import pusher
97+
pusher_client = pusher.Pusher(app_id=u'4', key=u'key', secret=u'secret', ssl=True, cluster=u'cluster')
9698
```
9799

98100
## Triggering Events
@@ -119,7 +121,7 @@ To trigger an event on one or more channels, use the `trigger` method on the `Pu
119121
This call will trigger to `'a_channel'` and `'another_channel'`, and exclude the recipient with socket_id `"1234.12"`.
120122

121123
```python
122-
pusher.trigger([u'a_channel', u'another_channel'], u'an_event', {u'some': u'data'}, "1234.12")
124+
pusher_client.trigger([u'a_channel', u'another_channel'], u'an_event', {u'some': u'data'}, "1234.12")
123125
```
124126

125127
#### `Pusher::trigger_batch`
@@ -150,7 +152,7 @@ Events are a `Dict` with keys:
150152
##### Example
151153

152154
```python
153-
pusher.trigger_batch([
155+
pusher_client.trigger_batch([
154156
{ u'channel': u'a_channel', u'name': u'an_event', u'data': {u'some': u'data'}, u'socket_id': '1234.12'},
155157
{ u'channel': u'a_channel', u'name': u'an_event', u'data': {u'some': u'other data'}}
156158
])
@@ -177,7 +179,7 @@ pusher.trigger_batch([
177179
##### Example
178180

179181
```python
180-
channels = pusher.channels_info(u"presence-", [u'user_count'])
182+
channels = pusher_client.channels_info(u"presence-", [u'user_count'])
181183

182184
#=> {u'channels': {u'presence-chatroom': {u'user_count': 2}, u'presence-notifications': {u'user_count': 1}}}
183185
```
@@ -200,7 +202,7 @@ channels = pusher.channels_info(u"presence-", [u'user_count'])
200202
##### Example
201203

202204
```python
203-
channel = pusher.channel_info(u'presence-chatroom', [u"user_count"])
205+
channel = pusher_client.channel_info(u'presence-chatroom', [u"user_count"])
204206
#=> {u'user_count': 42, u'occupied': True}
205207
```
206208

@@ -221,7 +223,7 @@ channel = pusher.channel_info(u'presence-chatroom', [u"user_count"])
221223
##### Example
222224

223225
```python
224-
pusher.users_info(u'presence-chatroom')
226+
pusher_client.users_info(u'presence-chatroom')
225227
#=> {u'users': [{u'id': u'1035'}, {u'id': u'4821'}]}
226228
```
227229

@@ -252,7 +254,7 @@ Using your `Pusher` instance, with which you initialized `Pusher`, you can gener
252254
###### Private Channels
253255

254256
```python
255-
auth = pusher.authenticate(
257+
auth = pusher_client.authenticate(
256258

257259
channel=u"private-channel",
258260

@@ -264,7 +266,7 @@ auth = pusher.authenticate(
264266
###### Presence Channels
265267

266268
```python
267-
auth = pusher.authenticate(
269+
auth = pusher_client.authenticate(
268270

269271
channel=u"presence-channel",
270272

@@ -280,6 +282,39 @@ auth = pusher.authenticate(
280282
# return `auth` as a response
281283
```
282284

285+
## End to End Encryption (Beta)
286+
287+
This library supports end to end encryption of your private channels. This means that only you and your connected clients will be able to read your messages. Pusher cannot decrypt them. You can enable this feature by following these steps:
288+
289+
1. You should first set up Private channels. This involves [creating an authentication endpoint on your server](https://pusher.com/docs/authenticating_users).
290+
291+
2. Next, Specify your 32 character `encryption_master_key`. This is secret and you should never share this with anyone. Not even Pusher.
292+
293+
```python
294+
295+
import pusher
296+
297+
pusher_client = pusher.Pusher(
298+
app_id='yourappid',
299+
key='yourkey',
300+
secret='yoursecret',
301+
encryption_master_key='XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX',
302+
cluster='yourclustername',
303+
ssl=True
304+
)
305+
306+
pusher_client.trigger('private-encrypted-my-channel', 'my-event', {
307+
'message': 'hello world'
308+
})
309+
```
310+
3. Channels where you wish to use end to end encryption must be prefixed with `private-encrypted-`.
311+
312+
4. Subscribe to these channels in your client, and you're done! You can verify it is working by checking out the debug console on the https://dashboard.pusher.com/ and seeing the scrambled ciphertext.
313+
314+
**Important note: This will not encrypt messages on channels that are not prefixed by private-encrypted-.**
315+
316+
More info on End-to-end Encrypted Channels [here](https://pusher.com/docs/client_api_guide/client_encrypted_channels).
317+
283318
## Receiving Webhooks
284319

285320
If you have webhooks set up to POST a payload to a specified endpoint, you may wish to validate that these are actually from Pusher. The `Pusher` object achieves this by checking the authentication signature in the request body using your application credentials.
@@ -301,7 +336,7 @@ If you have webhooks set up to POST a payload to a specified endpoint, you may w
301336
##### Example
302337

303338
```python
304-
webhook = pusher.validate_webhook(
339+
webhook = pusher_client.validate_webhook(
305340

306341
key="key_sent_in_header",
307342

@@ -341,11 +376,12 @@ Get the list of channels in an application | *&#10004;*
341376
Get the state of a single channel | *&#10004;*
342377
Get a list of users in a presence channel | *&#10004;*
343378
WebHook validation | *&#10004;*
344-
Heroku add-on support | *&#10004;*
379+
Heroku add-on support | *&#10004;*
345380
Debugging & Logging | *&#10004;*
346381
Cluster configuration | *&#10004;*
347382
Timeouts | *&#10004;*
348383
HTTPS | *&#10004;*
384+
End-to-end Encryption | *&#10004;*
349385
HTTP Proxy configuration | *&#10008;*
350386
HTTP KeepAlive | *&#10008;*
351387

pusher/authentication_client.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,25 +13,28 @@
1313
import re
1414
import six
1515
import time
16+
import base64
1617

1718
from pusher.util import (
1819
ensure_text,
1920
validate_channel,
2021
validate_socket_id,
21-
channel_name_re)
22+
channel_name_re
23+
)
2224

2325
from pusher.client import Client
2426
from pusher.http import GET, POST, Request, request_method
2527
from pusher.signature import sign, verify
28+
from pusher.crypto import *
2629

2730

2831
class AuthenticationClient(Client):
2932
def __init__(
3033
self, app_id, key, secret, ssl=True, host=None, port=None,
31-
timeout=5, cluster=None, json_encoder=None, json_decoder=None,
34+
timeout=5, cluster=None, encryption_master_key=None, json_encoder=None, json_decoder=None,
3235
backend=None, **backend_options):
3336
super(AuthenticationClient, self).__init__(
34-
app_id, key, secret, ssl, host, port, timeout, cluster,
37+
app_id, key, secret, ssl, host, port, timeout, cluster, encryption_master_key,
3538
json_encoder, json_decoder, backend, **backend_options)
3639

3740
if host:
@@ -70,12 +73,17 @@ def authenticate(self, channel, socket_id, custom_data=None):
7073
signature = sign(self.secret, string_to_sign)
7174

7275
auth = "%s:%s" % (self.key, signature)
73-
result = {'auth': auth}
76+
response_payload = { "auth": auth }
77+
78+
if is_encrypted_channel(channel):
79+
shared_secret = generate_shared_secret(channel, self._encryption_master_key)
80+
shared_secret_b64 = base64.b64encode(shared_secret)
81+
response_payload["shared_secret"] = shared_secret_b64
7482

7583
if custom_data:
76-
result['channel_data'] = custom_data
84+
response_payload['channel_data'] = custom_data
7785

78-
return result
86+
return response_payload
7987

8088

8189
def validate_webhook(self, key, signature, body):

pusher/client.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@
88

99
import six
1010

11-
from pusher.util import ensure_text, app_id_re
11+
from pusher.util import ensure_text, ensure_binary, app_id_re
1212

1313

1414
class Client(object):
1515
def __init__(
1616
self, app_id, key, secret, ssl=True, host=None, port=None,
17-
timeout=5, cluster=None, json_encoder=None, json_decoder=None,
17+
timeout=5, cluster=None, encryption_master_key=None, json_encoder=None, json_decoder=None,
1818
backend=None, **backend_options):
1919
if backend is None:
2020
from .requests import RequestsBackend
@@ -44,6 +44,12 @@ def __init__(
4444
self._json_encoder = json_encoder
4545
self._json_decoder = json_decoder
4646

47+
48+
if encryption_master_key is not None:
49+
encryption_master_key = ensure_binary(encryption_master_key, "encryption_master_key")
50+
51+
self._encryption_master_key = encryption_master_key
52+
4753
self.http = backend(self, **backend_options)
4854

4955
@property

pusher/crypto.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# -*- coding: utf-8 -*-
2+
3+
from __future__ import (
4+
print_function,
5+
unicode_literals,
6+
absolute_import,
7+
division)
8+
9+
import hashlib
10+
import nacl
11+
import base64
12+
13+
from pusher.util import (
14+
ensure_text,
15+
ensure_binary,
16+
data_to_string)
17+
18+
import nacl.secret
19+
import nacl.utils
20+
21+
# The prefix any e2e channel must have
22+
ENCRYPTED_PREFIX = 'private-encrypted-'
23+
24+
def is_encrypted_channel(channel):
25+
"""
26+
is_encrypted_channel() checks if the channel is encrypted by verifying the prefix
27+
"""
28+
if channel.startswith(ENCRYPTED_PREFIX):
29+
return True
30+
return False
31+
32+
def is_encryption_master_key_valid(encryption_master_key):
33+
"""
34+
is_encryption_master_key_valid() checks if the provided encryption_master_key is valid by checking its length
35+
the key is assumed to be a six.binary_type (python2 str or python3 bytes)
36+
"""
37+
if encryption_master_key is not None and len(encryption_master_key) == 32:
38+
return True
39+
40+
return False
41+
42+
def generate_shared_secret(channel, encryption_master_key):
43+
"""
44+
generate_shared_secret() takes a six.binary_type (python2 str or python3 bytes) channel name and encryption_master_key
45+
and returns the sha256 hash in six.binary_type format
46+
"""
47+
if is_encryption_master_key_valid(encryption_master_key):
48+
# the key has to be 32 bytes long
49+
hashable = channel + encryption_master_key
50+
return hashlib.sha256(hashable).digest()
51+
52+
raise ValueError("Provided encryption_master_key is not 32 char long")
53+
54+
def encrypt(channel, data, encryption_master_key, nonce=None):
55+
"""
56+
encrypt() encrypts the provided payload specified in the 'data' parameter
57+
"""
58+
channel = ensure_binary(channel, "channel")
59+
shared_secret = generate_shared_secret(channel, encryption_master_key)
60+
# the box setup to seal/unseal data payload
61+
box = nacl.secret.SecretBox(shared_secret)
62+
63+
if nonce is None:
64+
nonce = nacl.utils.random(nacl.secret.SecretBox.NONCE_SIZE)
65+
else:
66+
nonce = ensure_binary(nonce, "nonce")
67+
68+
# convert nonce to base64
69+
nonce_b64 = base64.b64encode(nonce)
70+
71+
# encrypt the data payload with nacl
72+
encrypted = box.encrypt(data.encode("utf-8"), nonce)
73+
74+
# obtain the ciphertext
75+
cipher_text = encrypted.ciphertext
76+
# encode cipertext to base64
77+
cipher_text_b64 = base64.b64encode(cipher_text)
78+
79+
# format output
80+
return { "nonce" : nonce_b64.decode("utf-8"), "ciphertext": cipher_text_b64.decode("utf-8") }

pusher/pusher.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ class Pusher(object):
3636
:param host: Used for custom host destination
3737
:param port: Used for custom port destination
3838
:param timeout: Request timeout (in seconds)
39+
:param encryption_master_key: Used to derive a shared secret between
40+
server and the clients for payload encryption/decryption
3941
:param cluster: Convention for other clusters than the main Pusher-one.
4042
Eg: 'eu' will resolve to the api-eu.pusherapp.com host
4143
:param backend: an http adapter class (AsyncIOBackend, RequestsBackend,
@@ -44,15 +46,14 @@ class Pusher(object):
4446
"""
4547
def __init__(
4648
self, app_id, key, secret, ssl=True, host=None, port=None,
47-
timeout=5, cluster=None, json_encoder=None, json_decoder=None,
48-
backend=None, notification_host=None, notification_ssl=True,
49-
**backend_options):
49+
timeout=5, cluster=None, encryption_master_key=None, json_encoder=None, json_decoder=None,
50+
backend=None, notification_host=None, notification_ssl=True, **backend_options):
5051
self._pusher_client = PusherClient(
51-
app_id, key, secret, ssl, host, port, timeout, cluster,
52+
app_id, key, secret, ssl, host, port, timeout, cluster, encryption_master_key,
5253
json_encoder, json_decoder, backend, **backend_options)
5354

5455
self._authentication_client = AuthenticationClient(
55-
app_id, key, secret, ssl, host, port, timeout, cluster,
56+
app_id, key, secret, ssl, host, port, timeout, cluster, encryption_master_key,
5657
json_encoder, json_decoder, backend, **backend_options)
5758

5859
self._notification_client = NotificationClient(

0 commit comments

Comments
 (0)