diff --git a/README.md b/README.md index cad2540..10b31a5 100644 --- a/README.md +++ b/README.md @@ -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!) }); ``` @@ -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!) }); ``` @@ -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. diff --git a/lib/auth.js b/lib/auth.js index d6600c1..4fdd8e9 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -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]; @@ -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; } diff --git a/lib/config.js b/lib/config.js index d09fbe5..c32ff09 100644 --- a/lib/config.js +++ b/lib/config.js @@ -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) { diff --git a/lib/events.js b/lib/events.js index 3c50a57..e8a74f3 100644 --- a/lib/events.js +++ b/lib/events.js @@ -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); } diff --git a/lib/pusher.js b/lib/pusher.js index 1cc1462..2a40f10 100644 --- a/lib/pusher.js +++ b/lib/pusher.js @@ -1,3 +1,4 @@ +var crypto = require('crypto'); var url = require('url'); var auth = require('./auth'); @@ -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. @@ -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. */ diff --git a/lib/util.js b/lib/util.js index 098fc8d..f8bd965 100644 --- a/lib/util.js +++ b/lib/util.js @@ -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; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index bff4b82..0d09095 100644 --- a/package-lock.json +++ b/package-lock.json @@ -204,6 +204,13 @@ "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", "requires": { "tweetnacl": "^0.14.3" + }, + "dependencies": { + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" + } } }, "big.js": { @@ -1473,7 +1480,7 @@ }, "graceful-fs": { "version": "2.0.3", - "resolved": "http://registry.npmjs.org/graceful-fs/-/graceful-fs-2.0.3.tgz", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-2.0.3.tgz", "integrity": "sha1-fNLNsiiko/Nule+mzBQt59GhNtA=", "dev": true }, @@ -1672,13 +1679,13 @@ "dependencies": { "commander": { "version": "0.6.1", - "resolved": "http://registry.npmjs.org/commander/-/commander-0.6.1.tgz", + "resolved": "https://registry.npmjs.org/commander/-/commander-0.6.1.tgz", "integrity": "sha1-+mihT2qUXVTbvlDYzbMyDp47GgY=", "dev": true }, "mkdirp": { "version": "0.3.0", - "resolved": "http://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz", "integrity": "sha1-G79asbqCevI1dRQ0kEJkVfSB/h4=", "dev": true } @@ -1768,7 +1775,7 @@ }, "lru-cache": { "version": "2.7.3", - "resolved": "http://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz", "integrity": "sha1-bUUk6LlV+V1PW1iFHOId1y+06VI=", "dev": true }, @@ -2619,6 +2626,13 @@ "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" + }, + "dependencies": { + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" + } } }, "stream-browserify": { @@ -2777,9 +2791,14 @@ } }, "tweetnacl": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.0.tgz", + "integrity": "sha1-cT2LgY2kIGh0C/aDhtBHnmb8ins=" + }, + "tweetnacl-util": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.0.tgz", + "integrity": "sha1-RXbBzuXi1j0gf+5S8boCgZSAvHU=" }, "type-detect": { "version": "4.0.8", diff --git a/package.json b/package.json index 97bd4ae..6b766f6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tests/integration/pusher/authenticate.js b/tests/integration/pusher/authenticate.js index af8b8ff..cc833cb 100644 --- a/tests/integration/pusher/authenticate.js +++ b/tests/integration/pusher/authenticate.js @@ -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\"", + }); + }); }); }); diff --git a/tests/integration/pusher/trigger.js b/tests/integration/pusher/trigger.js index d39a2ea..fe08c10 100644 --- a/tests/integration/pusher/trigger.js +++ b/tests/integration/pusher/trigger.js @@ -1,5 +1,7 @@ var expect = require("expect.js"); var nock = require("nock"); +var nacl = require('tweetnacl'); +var naclUtil = require('tweetnacl-util'); var Pusher = require("../../../lib/pusher"); @@ -354,3 +356,114 @@ describe("Pusher", function() { }); }) }); + +describe("Pusher with encryptionMasterKey", function() { + var pusher; + + var testMasterKey = "01234567890123456789012345678901"; + + beforeEach(function() { + pusher = new Pusher({ appId: 1234, key: "f00d", secret: "beef", encryptionMasterKey: testMasterKey }); + nock.disableNetConnect(); + }); + + afterEach(function() { + nock.cleanAll(); + nock.enableNetConnect(); + }); + + describe("#trigger", function() { + it("should not encrypt the body of an event triggered on a single channel", function(done) { + var mock = nock("http://api.pusherapp.com") + .filteringPath(function(path) { + return path + .replace(/auth_timestamp=[0-9]+/, "auth_timestamp=X") + .replace(/auth_signature=[0-9a-f]{64}/, "auth_signature=Y"); + }) + .post( + "/apps/1234/events?auth_key=f00d&auth_timestamp=X&auth_version=1.0&body_md5=e95168baf497b2e54b2c6cadd41a6a3f&auth_signature=Y", + { name: "my_event", data: "{\"some\":\"data \"}", channels: ["one"] } + ) + .reply(200, "{}"); + + pusher.trigger("one", "my_event", { some: "data "}, done); + }); + + it("should encrypt the body of an event triggered on a private-encrypted- channel", function(done) { + var sentPlaintext = "Hello!"; + + var mock = nock("http://api.pusherapp.com") + .filteringPath(function(path) { + return path + .replace(/auth_timestamp=[0-9]+/, "auth_timestamp=X") + .replace(/auth_signature=[0-9a-f]{64}/, "auth_signature=Y") + .replace(/body_md5=[0-9a-f]{32}/, "body_md5=Z"); + }) + .post( + "/apps/1234/events?auth_key=f00d&auth_timestamp=X&auth_version=1.0&body_md5=Z&auth_signature=Y", + function (body) { + if (body.name !== "test_event") return false; + if (body.channels.length !== 1) return false; + var channel = body.channels[0]; + if (channel !== "private-encrypted-bla") return false; + var encrypted = JSON.parse(body.data); + var nonce = naclUtil.decodeBase64(encrypted.nonce); + var ciphertext = naclUtil.decodeBase64(encrypted.ciphertext); + var channelSharedSecret = pusher.channelSharedSecret(channel); + var receivedPlaintextBytes = nacl.secretbox.open(ciphertext, nonce, channelSharedSecret); + var receivedPlaintextJson = naclUtil.encodeUTF8(receivedPlaintextBytes); + var receivedPlaintext = JSON.parse(receivedPlaintextJson); + return receivedPlaintext === sentPlaintext; + } + ) + .reply(200, "{}"); + + pusher.trigger("private-encrypted-bla", "test_event", sentPlaintext, done); + }); + }); + + describe("#triggerBatch", function(){ + it("should encrypt the bodies of an events triggered on a private-encrypted- channels", function(done) { + var mock = nock("http://api.pusherapp.com") + .filteringPath(function(path) { + return path + .replace(/auth_timestamp=[0-9]+/, "auth_timestamp=X") + .replace(/auth_signature=[0-9a-f]{64}/, "auth_signature=Y") + .replace(/body_md5=[0-9a-f]{32}/, "body_md5=Z"); + }) + .post( + "/apps/1234/batch_events?auth_key=f00d&auth_timestamp=X&auth_version=1.0&body_md5=Z&auth_signature=Y", + function (body) { + if (body.batch.length !== 2) return false; + var event1 = body.batch[0]; + if (event1.channel !== "integration") return false; + if (event1.name !== "event") return false; + if (event1.data !== "test") return false; + var event2 = body.batch[1]; + if (event2.channel !== "private-encrypted-integration2") return false; + if (event2.name !== "event2") return false; + var encrypted = JSON.parse(event2.data); + var nonce = naclUtil.decodeBase64(encrypted.nonce); + var ciphertext = naclUtil.decodeBase64(encrypted.ciphertext); + var channelSharedSecret = pusher.channelSharedSecret(event2.channel); + var receivedPlaintextBytes = nacl.secretbox.open(ciphertext, nonce, channelSharedSecret); + var receivedPlaintextJson = naclUtil.encodeUTF8(receivedPlaintextBytes); + var receivedPlaintext = JSON.parse(receivedPlaintextJson); + return receivedPlaintext === 'test2'; + } + ) + .reply(200, "{}"); + + pusher.triggerBatch([{ + channel: "integration", + name: "event", + data: "test" + }, + { + channel: "private-encrypted-integration2", + name: "event2", + data: "test2" + }], done); + }); + }); +});