Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ var pusher = new Pusher({
cluster: 'CLUSTER', // if `host` is present, it will override the `cluster` option.
host: 'HOST', // optional, defaults to api.pusherapp.com
port: PORT, // optional, defaults to 80 for non-TLS connections and 443 for TLS connections
encryptionMasterKey: ENCRYPTION_MASTER_KEY, // a 32 character long key used to derive secrets for end to end encryption (see below!)
});
```

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

Expand Down Expand Up @@ -169,6 +171,38 @@ var socketId = '1302.1081607';
pusher.trigger(channel, event, data, socketId);
```

### End-to-end encryption [BETA]

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:

1. You should first set up Private channels. This involves [creating an authentication endpoint on your server](https://pusher.com/docs/authenticating_users).

2. Next, Specify your 32 character `encryption_master_key`. This is secret and you should never share this with anyone. Not even Pusher.

```javascript
var pusher = new Pusher({
appId: 'APP_ID',
key: 'APP_KEY',
secret: 'SECRET_KEY',
useTLS: true,
encryptionMasterKey: 'abcdefghijklmnopqrstuvwxyzabcdef',
});
```

3. Channels where you wish to use end-to-end encryption should be prefixed with `private-encrypted-`.

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.

**Important note: This will __not__ encrypt messages on channels that are not prefixed by `private-encrypted-`.**

**Limitation**: you cannot trigger a single event on multiple channels in a call to `trigger`, e.g.

```javascript
pusher.trigger([ 'channel-1', 'private-encrypted-channel-2' ], 'test_event', { message: "hello world" });
```

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.

### Push Notifications [BETA]

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.
Expand Down
12 changes: 11 additions & 1 deletion lib/auth.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
function getSocketSignature(token, channel, socketID, data) {
var util = require('./util');

function getSocketSignature(pusher, token, channel, socketID, data) {
var result = {};

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

result.auth = token.key + ':' + token.sign(signatureData.join(":"));

if (util.isEncryptedChannel(channel)) {
if (pusher.config.encryptionMasterKey === undefined) {
throw new Error("Cannot generate shared_secret because encryptionMasterKey is not set");
}
result.shared_secret = Buffer(pusher.channelSharedSecret(channel)).toString('base64');
}

return result;
}

Expand Down
10 changes: 10 additions & 0 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ function Config(options) {
this.proxy = options.proxy;
this.timeout = options.timeout;
this.keepAlive = options.keepAlive;

if (options.encryptionMasterKey !== undefined) {
if (typeof(options.encryptionMasterKey) !== 'string') {
throw new Error("encryptionMasterKey must be a string");
}
if (options.encryptionMasterKey.length !== 32) {
throw new Error("encryptionMasterKey must be 32 characters long, but the string '" + options.encryptionMasterKey + "' is " + options.encryptionMasterKey.length + " characters long");
}
this.encryptionMasterKey = options.encryptionMasterKey;
}
}

Config.prototype.prefixPath = function(subPath) {
Expand Down
62 changes: 53 additions & 9 deletions lib/events.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,62 @@
var util = require('./util');
var nacl = require('tweetnacl');
var naclUtil = require('tweetnacl-util');

function encrypt(pusher, channel, data) {
if (pusher.config.encryptionMasterKey === undefined) {
throw new Error("Set encryptionMasterKey before triggering events on encrypted channels");
}

var nonceBytes = nacl.randomBytes(24);

var ciphertextBytes = nacl.secretbox(
naclUtil.decodeUTF8(JSON.stringify(data)),
nonceBytes,
pusher.channelSharedSecret(channel));

return JSON.stringify({
nonce: naclUtil.encodeBase64(nonceBytes),
ciphertext: naclUtil.encodeBase64(ciphertextBytes)
});
}

exports.trigger = function(pusher, channels, eventName, data, socketId, callback) {
var event = {
"name": eventName,
"data": ensureJSON(data),
"channels": channels
};
if (socketId) {
event.socket_id = socketId;
if (channels.length === 1 && util.isEncryptedChannel(channels[0])) {
var channel = channels[0];
var event = {
"name": eventName,
"data": encrypt(pusher, channel, data),
"channels": [channel]
};
if (socketId) {
event.socket_id = socketId;
}
pusher.post({ path: '/events', body: event }, callback);
} else {
for (var i = 0; i < channels.length; i++) {
if (util.isEncryptedChannel(channels[i])) {
// For rationale, see limitations of end-to-end encryption in the README
throw new Error("You cannot trigger to multiple channels when using encrypted channels");
}
}

var event = {
"name": eventName,
"data": ensureJSON(data),
"channels": channels
};
if (socketId) {
event.socket_id = socketId;
}
pusher.post({ path: '/events', body: event }, callback);
}
pusher.post({ path: '/events', body: event }, callback);
}

exports.triggerBatch = function(pusher, batch, callback) {
for (var i = 0; i < batch.length; i++) {
batch[i].data = ensureJSON(batch[i].data);
batch[i].data = util.isEncryptedChannel(batch[i].channel) ?
encrypt(pusher, batch[i].channel, batch[i].data) :
ensureJSON(batch[i].data);
}
pusher.post({ path: '/batch_events', body: { batch: batch } }, callback);
}
Expand Down
7 changes: 6 additions & 1 deletion lib/pusher.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
var crypto = require('crypto');
var url = require('url');

var auth = require('./auth');
Expand Down Expand Up @@ -106,7 +107,7 @@ Pusher.prototype.authenticate = function(socketId, channel, data) {
validateSocketId(socketId);
validateChannel(channel);

return auth.getSocketSignature(this.config.token, channel, socketId, data);
return auth.getSocketSignature(this, this.config.token, channel, socketId, data);
};

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

Pusher.prototype.channelSharedSecret = function(channel) {
return crypto.createHash('sha256').update(channel + this.config.encryptionMasterKey).digest();
}

/** Exported {@link Token} constructor. */
Pusher.Token = Token;
/** Exported {@link RequestError} constructor. */
Expand Down
5 changes: 5 additions & 0 deletions lib/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,12 @@ function secureCompare(a, b) {
return result === 0;
}

function isEncryptedChannel(channel) {
return channel.startsWith("private-encrypted-");
}

exports.toOrderedArray = toOrderedArray;
exports.mergeObjects = mergeObjects;
exports.getMD5 = getMD5;
exports.secureCompare = secureCompare;
exports.isEncryptedChannel = isEncryptedChannel;
33 changes: 26 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
],
"dependencies": {
"@types/request": "^2.47.1",
"request": "2.88.0"
"request": "2.88.0",
"tweetnacl": "^1.0.0",
"tweetnacl-util": "^0.15.0"
},
"devDependencies": {
"expect.js": "=0.3.1",
Expand Down
32 changes: 32 additions & 0 deletions tests/integration/pusher/authenticate.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,5 +124,37 @@ describe("Pusher", function() {
pusher.authenticate("111.222", "")
}).to.throwException(/^Invalid channel name: ''$/);
});

it("should throw an error for private-encrypted- channels", function() {
expect(function() {
pusher.authenticate("123.456", "private-encrypted-bla", "foo");
}).to.throwException('Cannot generate shared_secret because encryptionMasterKey is not set');
});
});
});

describe("Pusher with encryptionMasterKey", function() {
var pusher;

var testMasterKey = "01234567890123456789012345678901";

beforeEach(function() {
pusher = new Pusher({ appId: 1234, key: "f00d", secret: "beef", encryptionMasterKey: testMasterKey });
});

describe("#auth", function() {
it("should return a shared_secret for private-encrypted- channels", function() {
expect(pusher.authenticate("123.456", "private-encrypted-bla", "foo")).to.eql({
auth: "f00d:d8df1e524cf38fbde4f1dc38e6eaa4943e60412122801eed1f0e89c8a1268784",
channel_data: "\"foo\"",
shared_secret: "BYBsePpRCQkGPvbWu/5j8x+MmUF5sgPH5DmNBwkTzYs="
});
});
it("should not return a shared_secret for non-encrypted channels", function() {
expect(pusher.authenticate("123.456", "bla", "foo")).to.eql({
auth: "f00d:4c48fa1cb34537501eb3291b28c0b04de270008ae418bc3141f4f11680abe312",
channel_data: "\"foo\"",
});
});
});
});
Loading