Skip to content

Commit 870d83f

Browse files
jameshfisherWillSewell
authored andcommitted
End-to-end encryption (#86)
Add support for end-to-end encryption
1 parent 4bbc81a commit 870d83f

File tree

10 files changed

+293
-19
lines changed

10 files changed

+293
-19
lines changed

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ var pusher = new Pusher({
5656
cluster: 'CLUSTER', // if `host` is present, it will override the `cluster` option.
5757
host: 'HOST', // optional, defaults to api.pusherapp.com
5858
port: PORT, // optional, defaults to 80 for non-TLS connections and 443 for TLS connections
59+
encryptionMasterKey: ENCRYPTION_MASTER_KEY, // a 32 character long key used to derive secrets for end to end encryption (see below!)
5960
});
6061
```
6162

@@ -70,6 +71,7 @@ var pusher = Pusher.forCluster("CLUSTER", {
7071
secret: 'SECRET_KEY',
7172
useTLS: USE_TLS, // optional, defaults to false
7273
port: PORT, // optional, defaults to 80 for non-TLS connections and 443 for TLS connections
74+
encryptionMasterKey: ENCRYPTION_MASTER_KEY, // a 32 character long key used to derive secrets for end to end encryption (see below!)
7375
});
7476
```
7577

@@ -169,6 +171,38 @@ var socketId = '1302.1081607';
169171
pusher.trigger(channel, event, data, socketId);
170172
```
171173

174+
### End-to-end encryption [BETA]
175+
176+
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:
177+
178+
1. You should first set up Private channels. This involves [creating an authentication endpoint on your server](https://pusher.com/docs/authenticating_users).
179+
180+
2. Next, Specify your 32 character `encryption_master_key`. This is secret and you should never share this with anyone. Not even Pusher.
181+
182+
```javascript
183+
var pusher = new Pusher({
184+
appId: 'APP_ID',
185+
key: 'APP_KEY',
186+
secret: 'SECRET_KEY',
187+
useTLS: true,
188+
encryptionMasterKey: 'abcdefghijklmnopqrstuvwxyzabcdef',
189+
});
190+
```
191+
192+
3. Channels where you wish to use end-to-end encryption should be prefixed with `private-encrypted-`.
193+
194+
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/](dashboard) and seeing the scrambled ciphertext.
195+
196+
**Important note: This will __not__ encrypt messages on channels that are not prefixed by `private-encrypted-`.**
197+
198+
**Limitation**: you cannot trigger a single event on multiple channels in a call to `trigger`, e.g.
199+
200+
```javascript
201+
pusher.trigger([ 'channel-1', 'private-encrypted-channel-2' ], 'test_event', { message: "hello world" });
202+
```
203+
204+
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.
205+
172206
### Push Notifications [BETA]
173207

174208
Pusher now allows sending native notifications to iOS and Android devices. Check out the [documentation](https://pusher.com/docs/push_notifications) for information on how to set up push notifications on Android and iOS. There is no additional setup required to use it with this library. It works out of the box wit the same Pusher instance. All you need are the same pusher credentials.

lib/auth.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
function getSocketSignature(token, channel, socketID, data) {
1+
var util = require('./util');
2+
3+
function getSocketSignature(pusher, token, channel, socketID, data) {
24
var result = {};
35

46
var signatureData = [socketID, channel];
@@ -9,6 +11,14 @@ function getSocketSignature(token, channel, socketID, data) {
911
}
1012

1113
result.auth = token.key + ':' + token.sign(signatureData.join(":"));
14+
15+
if (util.isEncryptedChannel(channel)) {
16+
if (pusher.config.encryptionMasterKey === undefined) {
17+
throw new Error("Cannot generate shared_secret because encryptionMasterKey is not set");
18+
}
19+
result.shared_secret = Buffer(pusher.channelSharedSecret(channel)).toString('base64');
20+
}
21+
1222
return result;
1323
}
1424

lib/config.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ function Config(options) {
2222
this.proxy = options.proxy;
2323
this.timeout = options.timeout;
2424
this.keepAlive = options.keepAlive;
25+
26+
if (options.encryptionMasterKey !== undefined) {
27+
if (typeof(options.encryptionMasterKey) !== 'string') {
28+
throw new Error("encryptionMasterKey must be a string");
29+
}
30+
if (options.encryptionMasterKey.length !== 32) {
31+
throw new Error("encryptionMasterKey must be 32 characters long, but the string '" + options.encryptionMasterKey + "' is " + options.encryptionMasterKey.length + " characters long");
32+
}
33+
this.encryptionMasterKey = options.encryptionMasterKey;
34+
}
2535
}
2636

2737
Config.prototype.prefixPath = function(subPath) {

lib/events.js

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,62 @@
1+
var util = require('./util');
2+
var nacl = require('tweetnacl');
3+
var naclUtil = require('tweetnacl-util');
4+
5+
function encrypt(pusher, channel, data) {
6+
if (pusher.config.encryptionMasterKey === undefined) {
7+
throw new Error("Set encryptionMasterKey before triggering events on encrypted channels");
8+
}
9+
10+
var nonceBytes = nacl.randomBytes(24);
11+
12+
var ciphertextBytes = nacl.secretbox(
13+
naclUtil.decodeUTF8(JSON.stringify(data)),
14+
nonceBytes,
15+
pusher.channelSharedSecret(channel));
16+
17+
return JSON.stringify({
18+
nonce: naclUtil.encodeBase64(nonceBytes),
19+
ciphertext: naclUtil.encodeBase64(ciphertextBytes)
20+
});
21+
}
22+
123
exports.trigger = function(pusher, channels, eventName, data, socketId, callback) {
2-
var event = {
3-
"name": eventName,
4-
"data": ensureJSON(data),
5-
"channels": channels
6-
};
7-
if (socketId) {
8-
event.socket_id = socketId;
24+
if (channels.length === 1 && util.isEncryptedChannel(channels[0])) {
25+
var channel = channels[0];
26+
var event = {
27+
"name": eventName,
28+
"data": encrypt(pusher, channel, data),
29+
"channels": [channel]
30+
};
31+
if (socketId) {
32+
event.socket_id = socketId;
33+
}
34+
pusher.post({ path: '/events', body: event }, callback);
35+
} else {
36+
for (var i = 0; i < channels.length; i++) {
37+
if (util.isEncryptedChannel(channels[i])) {
38+
// For rationale, see limitations of end-to-end encryption in the README
39+
throw new Error("You cannot trigger to multiple channels when using encrypted channels");
40+
}
41+
}
42+
43+
var event = {
44+
"name": eventName,
45+
"data": ensureJSON(data),
46+
"channels": channels
47+
};
48+
if (socketId) {
49+
event.socket_id = socketId;
50+
}
51+
pusher.post({ path: '/events', body: event }, callback);
952
}
10-
pusher.post({ path: '/events', body: event }, callback);
1153
}
1254

1355
exports.triggerBatch = function(pusher, batch, callback) {
1456
for (var i = 0; i < batch.length; i++) {
15-
batch[i].data = ensureJSON(batch[i].data);
57+
batch[i].data = util.isEncryptedChannel(batch[i].channel) ?
58+
encrypt(pusher, batch[i].channel, batch[i].data) :
59+
ensureJSON(batch[i].data);
1660
}
1761
pusher.post({ path: '/batch_events', body: { batch: batch } }, callback);
1862
}

lib/pusher.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
var crypto = require('crypto');
12
var url = require('url');
23

34
var auth = require('./auth');
@@ -106,7 +107,7 @@ Pusher.prototype.authenticate = function(socketId, channel, data) {
106107
validateSocketId(socketId);
107108
validateChannel(channel);
108109

109-
return auth.getSocketSignature(this.config.token, channel, socketId, data);
110+
return auth.getSocketSignature(this, this.config.token, channel, socketId, data);
110111
};
111112

112113
/** Triggers an event.
@@ -230,6 +231,10 @@ Pusher.prototype.createSignedQueryString = function(options) {
230231
return requests.createSignedQueryString(this.config.token, options);
231232
};
232233

234+
Pusher.prototype.channelSharedSecret = function(channel) {
235+
return crypto.createHash('sha256').update(channel + this.config.encryptionMasterKey).digest();
236+
}
237+
233238
/** Exported {@link Token} constructor. */
234239
Pusher.Token = Token;
235240
/** Exported {@link RequestError} constructor. */

lib/util.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,12 @@ function secureCompare(a, b) {
3838
return result === 0;
3939
}
4040

41+
function isEncryptedChannel(channel) {
42+
return channel.startsWith("private-encrypted-");
43+
}
44+
4145
exports.toOrderedArray = toOrderedArray;
4246
exports.mergeObjects = mergeObjects;
4347
exports.getMD5 = getMD5;
4448
exports.secureCompare = secureCompare;
49+
exports.isEncryptedChannel = isEncryptedChannel;

package-lock.json

Lines changed: 26 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@
2323
],
2424
"dependencies": {
2525
"@types/request": "^2.47.1",
26-
"request": "2.88.0"
26+
"request": "2.88.0",
27+
"tweetnacl": "^1.0.0",
28+
"tweetnacl-util": "^0.15.0"
2729
},
2830
"devDependencies": {
2931
"expect.js": "=0.3.1",

tests/integration/pusher/authenticate.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,5 +124,37 @@ describe("Pusher", function() {
124124
pusher.authenticate("111.222", "")
125125
}).to.throwException(/^Invalid channel name: ''$/);
126126
});
127+
128+
it("should throw an error for private-encrypted- channels", function() {
129+
expect(function() {
130+
pusher.authenticate("123.456", "private-encrypted-bla", "foo");
131+
}).to.throwException('Cannot generate shared_secret because encryptionMasterKey is not set');
132+
});
133+
});
134+
});
135+
136+
describe("Pusher with encryptionMasterKey", function() {
137+
var pusher;
138+
139+
var testMasterKey = "01234567890123456789012345678901";
140+
141+
beforeEach(function() {
142+
pusher = new Pusher({ appId: 1234, key: "f00d", secret: "beef", encryptionMasterKey: testMasterKey });
143+
});
144+
145+
describe("#auth", function() {
146+
it("should return a shared_secret for private-encrypted- channels", function() {
147+
expect(pusher.authenticate("123.456", "private-encrypted-bla", "foo")).to.eql({
148+
auth: "f00d:d8df1e524cf38fbde4f1dc38e6eaa4943e60412122801eed1f0e89c8a1268784",
149+
channel_data: "\"foo\"",
150+
shared_secret: "BYBsePpRCQkGPvbWu/5j8x+MmUF5sgPH5DmNBwkTzYs="
151+
});
152+
});
153+
it("should not return a shared_secret for non-encrypted channels", function() {
154+
expect(pusher.authenticate("123.456", "bla", "foo")).to.eql({
155+
auth: "f00d:4c48fa1cb34537501eb3291b28c0b04de270008ae418bc3141f4f11680abe312",
156+
channel_data: "\"foo\"",
157+
});
158+
});
127159
});
128160
});

0 commit comments

Comments
 (0)