Skip to content

Commit 0dbdd32

Browse files
author
Callum Oakley
authored
Merge pull request #159 from pusher/e2e-encryption
E2e encryption
2 parents 7bfe08f + 80f2f21 commit 0dbdd32

File tree

6 files changed

+210
-4
lines changed

6 files changed

+210
-4
lines changed

.travis.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
before_install:
2+
- sudo apt-get -y install libsodium18
3+
14
language: ruby
25
sudo: false
36
rvm:

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
HEAD / 2020-09-29
2+
==================
3+
4+
* Support for end-to-end encryption.
5+
16
1.3.3 / 2019-07-02
27
==================
38

README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,5 +248,51 @@ else
248248
end
249249
```
250250

251+
### End-to-end encryption
252+
253+
This library supports [end-to-end encrypted channels](https://pusher.com/docs/channels/using_channels/encrypted-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:
254+
255+
1. Install [Libsodium](https://github.com/jedisct1/libsodium), which we rely on to do the heavy lifting. [Follow the installation instructions for your platform.](https://github.com/RubyCrypto/rbnacl/wiki/Installing-libsodium)
256+
257+
2. Encrypted channel subscriptions must be authenticated in the exact same way as private channels. You should therefore [create an authentication endpoint on your server](https://pusher.com/docs/authenticating_users).
258+
259+
3. Next, generate your 32 byte master encryption key, encode it as base64 and pass it to the Pusher constructor.
260+
261+
This is secret and you should never share this with anyone.
262+
Not even Pusher.
263+
264+
```bash
265+
openssl rand -base64 32
266+
```
267+
268+
```rb
269+
pusher = new Pusher::Client.new({
270+
app_id: 'your-app-id',
271+
key: 'your-app-key',
272+
secret: 'your-app-secret',
273+
cluster: 'your-app-cluster',
274+
use_tls: true
275+
encryption_master_key_base64: '<KEY GENERATED BY PREVIOUS COMMAND>',
276+
});
277+
```
278+
279+
4. Channels where you wish to use end-to-end encryption should be prefixed with `private-encrypted-`.
280+
281+
5. 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/](dashboard) and seeing the scrambled ciphertext.
282+
283+
**Important note: This will __not__ encrypt messages on channels that are not prefixed by `private-encrypted-`.**
284+
285+
**Limitation**: you cannot trigger a single event on multiple channels in a call to `trigger`, e.g.
286+
287+
```rb
288+
pusher.trigger(
289+
['channel-1', 'private-encrypted-channel-2'],
290+
'test_event',
291+
{ message: 'hello world' },
292+
)
293+
```
294+
295+
Rationale: the methods in this library map directly to individual Channels HTTP API requests. If we allowed triggering a single event on multiple channels (some encrypted, some unencrypted), then it would require two API requests: one where the event is encrypted to the encrypted channels, and one where the event is unencrypted for unencrypted channels.
296+
251297
## Supported Ruby versions
252298
2.4+

lib/pusher/client.rb

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
require 'base64'
2+
13
require 'pusher-signature'
24

35
module Pusher
46
class Client
5-
attr_accessor :scheme, :host, :port, :app_id, :key, :secret, :notification_host, :notification_scheme
7+
attr_accessor :scheme, :host, :port, :app_id, :key, :secret, :notification_host, :notification_scheme, :encryption_master_key
68
attr_reader :http_proxy, :proxy
79
attr_writer :connect_timeout, :send_timeout, :receive_timeout,
810
:keep_alive_timeout
@@ -55,6 +57,11 @@ def initialize(options = {})
5557
:scheme, :host, :port, :app_id, :key, :secret, :notification_host, :notification_scheme
5658
)
5759

60+
if options.has_key?(:encryption_master_key_base64)
61+
@encryption_master_key =
62+
Base64.decode64(options[:encryption_master_key_base64])
63+
end
64+
5865
@http_proxy = nil
5966
self.http_proxy = options[:http_proxy] if options[:http_proxy]
6067

@@ -138,6 +145,12 @@ def timeout=(value)
138145
@connect_timeout, @send_timeout, @receive_timeout = value, value, value
139146
end
140147

148+
# Set an encryption_master_key to use with private-encrypted channels from
149+
# a base64 encoded string.
150+
def encryption_master_key_base64=(s)
151+
@encryption_master_key = s ? Base64.decode64(s) : nil
152+
end
153+
141154
## INTERACT WITH THE API ##
142155

143156
def resource(path)
@@ -413,18 +426,29 @@ def trigger_params(channels, event_name, data, params)
413426
channels = Array(channels).map(&:to_s)
414427
raise Pusher::Error, "Too many channels (#{channels.length}), max 10" if channels.length > 10
415428

429+
encoded_data = if channels.any?{ |c| c.match(/^private-encrypted-/) } then
430+
raise Pusher::Error, "Cannot trigger to multiple channels if any are encrypted" if channels.length > 1
431+
encrypt(channels[0], encode_data(data))
432+
else
433+
encode_data(data)
434+
end
435+
416436
params.merge({
417437
name: event_name,
418438
channels: channels,
419-
data: encode_data(data),
439+
data: encoded_data,
420440
})
421441
end
422442

423443
def trigger_batch_params(events)
424444
{
425445
batch: events.map do |event|
426446
event.dup.tap do |e|
427-
e[:data] = encode_data(e[:data])
447+
e[:data] = if e[:channel].match(/^private-encrypted-/) then
448+
encrypt(e[:channel], encode_data(e[:data]))
449+
else
450+
encode_data(e[:data])
451+
end
428452
end
429453
end
430454
}
@@ -436,6 +460,28 @@ def encode_data(data)
436460
MultiJson.encode(data)
437461
end
438462

463+
# Encrypts a message with a key derived from the master key and channel
464+
# name
465+
def encrypt(channel, encoded_data)
466+
raise ConfigurationError, :encryption_master_key unless @encryption_master_key
467+
468+
# Only now load rbnacl, so that people that aren't using it don't need to
469+
# install libsodium
470+
require 'rbnacl'
471+
472+
secret_box = RbNaCl::SecretBox.new(
473+
RbNaCl::Hash.sha256(channel + @encryption_master_key)
474+
)
475+
476+
nonce = RbNaCl::Random.random_bytes(secret_box.nonce_bytes)
477+
ciphertext = secret_box.encrypt(nonce, encoded_data)
478+
479+
MultiJson.encode({
480+
"nonce" => Base64::encode64(nonce),
481+
"ciphertext" => Base64::encode64(ciphertext),
482+
})
483+
end
484+
439485
def configured?
440486
host && scheme && key && secret && app_id
441487
end

pusher.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Gem::Specification.new do |s|
1717
s.add_dependency 'pusher-signature', "~> 0.1.8"
1818
s.add_dependency "httpclient", "~> 2.8"
1919
s.add_dependency "jruby-openssl" if defined?(JRUBY_VERSION)
20+
s.add_dependency "rbnacl", "~> 7.1"
2021

2122
s.add_development_dependency "rspec", "~> 3.9"
2223
s.add_development_dependency "webmock", "~> 3.9"

spec/client_spec.rb

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1-
require 'spec_helper'
1+
require 'base64'
22

3+
require 'rbnacl'
34
require 'em-http'
45

6+
require 'spec_helper'
7+
8+
encryption_master_key = RbNaCl::Random.random_bytes(32)
9+
510
describe Pusher do
611
# The behaviour should be the same when using the Client object, or the
712
# 'global' client delegated through the Pusher class
@@ -171,11 +176,22 @@
171176
end
172177
end
173178

179+
describe 'can set encryption_master_key_base64' do
180+
it "sets encryption_master_key" do
181+
@client.encryption_master_key_base64 =
182+
Base64.encode64(encryption_master_key)
183+
184+
expect(@client.encryption_master_key).to eq(encryption_master_key)
185+
end
186+
end
187+
174188
describe 'when configured' do
175189
before :each do
176190
@client.app_id = '20'
177191
@client.key = '12345678900000001'
178192
@client.secret = '12345678900000001'
193+
@client.encryption_master_key_base64 =
194+
Base64.encode64(encryption_master_key)
179195
end
180196

181197
describe '#[]' do
@@ -321,6 +337,46 @@
321337
}
322338
end
323339
end
340+
341+
it "should fail to publish to encrypted channels when missing key" do
342+
@client.encryption_master_key_base64 = nil
343+
expect {
344+
@client.trigger('private-encrypted-channel', 'event', {'some' => 'data'})
345+
}.to raise_error(Pusher::ConfigurationError)
346+
expect(WebMock).not_to have_requested(:post, @api_path)
347+
end
348+
349+
it "should fail to publish to multiple channels if one is encrypted" do
350+
expect {
351+
@client.trigger(
352+
['private-encrypted-channel', 'some-other-channel'],
353+
'event',
354+
{'some' => 'data'},
355+
)
356+
}.to raise_error(Pusher::Error)
357+
expect(WebMock).not_to have_requested(:post, @api_path)
358+
end
359+
360+
it "should encrypt publishes to encrypted channels" do
361+
@client.trigger(
362+
'private-encrypted-channel',
363+
'event',
364+
{'some' => 'data'},
365+
)
366+
367+
expect(WebMock).to have_requested(:post, @api_path).with { |req|
368+
data = MultiJson.decode(MultiJson.decode(req.body)["data"])
369+
370+
key = RbNaCl::Hash.sha256(
371+
'private-encrypted-channel' + encryption_master_key
372+
)
373+
374+
expect(MultiJson.decode(RbNaCl::SecretBox.new(key).decrypt(
375+
Base64.decode64(data["nonce"]),
376+
Base64.decode64(data["ciphertext"]),
377+
))).to eq({ 'some' => 'data' })
378+
}
379+
end
324380
end
325381

326382
describe '#trigger_batch' do
@@ -352,6 +408,55 @@
352408
)
353409
}
354410
end
411+
412+
it "should fail to publish to encrypted channels when missing key" do
413+
@client.encryption_master_key_base64 = nil
414+
expect {
415+
@client.trigger_batch(
416+
{
417+
channel: 'private-encrypted-channel',
418+
name: 'event',
419+
data: {'some' => 'data'},
420+
},
421+
{channel: 'mychannel', name: 'event', data: 'already encoded'},
422+
)
423+
}.to raise_error(Pusher::ConfigurationError)
424+
expect(WebMock).not_to have_requested(:post, @api_path)
425+
end
426+
427+
it "should encrypt publishes to encrypted channels" do
428+
@client.trigger_batch(
429+
{
430+
channel: 'private-encrypted-channel',
431+
name: 'event',
432+
data: {'some' => 'data'},
433+
},
434+
{channel: 'mychannel', name: 'event', data: 'already encoded'},
435+
)
436+
437+
expect(WebMock).to have_requested(:post, @api_path).with { |req|
438+
batch = MultiJson.decode(req.body)["batch"]
439+
expect(batch.length).to eq(2)
440+
441+
expect(batch[0]["channel"]).to eq("private-encrypted-channel")
442+
expect(batch[0]["name"]).to eq("event")
443+
444+
data = MultiJson.decode(batch[0]["data"])
445+
446+
key = RbNaCl::Hash.sha256(
447+
'private-encrypted-channel' + encryption_master_key
448+
)
449+
450+
expect(MultiJson.decode(RbNaCl::SecretBox.new(key).decrypt(
451+
Base64.decode64(data["nonce"]),
452+
Base64.decode64(data["ciphertext"]),
453+
))).to eq({ 'some' => 'data' })
454+
455+
expect(batch[1]["channel"]).to eq("mychannel")
456+
expect(batch[1]["name"]).to eq("event")
457+
expect(batch[1]["data"]).to eq("already encoded")
458+
}
459+
end
355460
end
356461

357462
describe '#trigger_async' do

0 commit comments

Comments
 (0)