End to end encryption in Node.js with Pusher Channels
You will need Node 8+ and an OpenSSL tool installed on your machine.
Privacy is a hot topic this days. Who has access to what and who can read my conversation with a friend. Pusher Channels offers three kinds of channels:
- Public
- Private
- Encrypted
Basically, all three perform the same functions - flexible pub/sub messaging and tons of other cool features. But there are few differences between them. Public channels do not require client-server authentication in order to subscribe to events. Private channels take it a step further by requiring client-server authentication. Encrypted channels build on top of private channels by introducing security in the form of encrypted data.
Below is a screenshot of the Debug Console showing an event published on a public channel.
Below is a screenshot of the Debug Console showing an event published on an encrypted channel.
Kindly take a look at the images above and spot the difference. Seen any yet? In the first image which shows the Debug Console for a public channel, you can see the data being sent to Pusher Channels contains some fields - title
, content
and createdAt
. Now take a look at the second image, you will notice those fields are no longer present but instead you have a bunch of non-human readable content your application obviously didn’t create. The field called ciphertext
is what the data you sent to Pusher Channels was converted to. The word ciphertext
outside this discourse refers to encrypted and/or garbled data.
Understanding encrypted channels
As depicted above, an advantage of an encrypted channel is the ability to send messages only the server SDK and any of your connected clients can read. No one else - including Pusher - will be able to read the messages.
Remember that a client has to go through the authentication process too.
Pusher Channels uses one of the current top encryption algorithms available and that is Secretbox. On the server side, the application author is meant to provide an encryption key to be used for the data encryption. This encryption key never gets to Pusher servers, which is why you are the only one that can read messages in an encrypted channel.
But a question. If the encryption key never gets to Pusher servers, how is a connected client able to subscribe to an event in an encrypted channel and read/decrypt the message? The answer resides in the authentication process. During authentication, a shared secret key is generated based off the master encryption key and the channel name. The generated shared secret key will be used to encrypt the data before being offloaded to Pusher Channels.
The shared secret is also sent as part of a successful authentication response as the client SDK will need to store it as it will be used for decrypting encrypted messages it receives. Again notice that since the encryption key never leaves your server, there is no way Pusher or any other person can read the messages if they don’t go through the authentication process - which is going to be done by the client side SDK.
Note that this shared secret is channel specific. For each channel subscribed to, a new shared secret is generated.
Here is a sample response:
{
"auth": "3b65aa197f334949f0ef:ffd3094d43e1bb21d5eb849c3debcbba0f7dd32bddeb0bb7dd8441516029853d",
"channel_data": {
"user_id": "10",
"user_info": {
"random": "random"
}
},
"shared_secret": "oB4frIyBUiYVzbUSBFCBl7U5BxzW8ni6wIrO4UaYIeo="
}
Apart from privacy and security, another benefit encrypted channels provide is message authenticity and protection against forgery. So there is maximum guarantee that whatever message is being received was published by someone who has access to the encryption key.
Implementing encrypted channels
To show encrypted channels in practice, we will build a live feed application. The application will consist of a server and client. The server will be written in Node.
Before getting started, it will be nice to be aware of some limitations imposed by an encrypted channel. They are:
- Channel name(s) must begin with
private-encrypted-
. Examples includeprivate-encrypted-dashboard
orprivate-encrypted-grocery-list
. If you provide an encryption key but fail to follow the naming scheme, your data will not be encrypted. - Client events cannot be triggered
- Channel and event names are not encrypted. This is for good reasons as events need to be dispatched to right clients and making sure an event in the Pusher Channels namespace -
pusher:
- cannot be used.
Before proceeding, you will need to create a new directory called pusher-encrypted-channels-node
. It can be done by issuing the following command in a terminal:
$ mkdir pusher-encrypted-channels-node
Prerequisites
- Node
>=8.0
- A Pusher account
- OpenSSL tool.
If you are a Windows user, please note that you can make use of Git Bash since it comes with the OpenSSL toolkit.
Building the server
The first thing to do is to create a Pusher Channels account if you don’t have one already. You will need to take note of your app keys and secret as we will be using them later on in the tutorial.
In the pusher-encrypted-channels-node
directory, you will need to create another directory called server
.
The next step of action is to create a .env
file to contain the secret and key gotten from the dashboard. You should paste in the following contents:
// pusher-encrypted-channels-node/server/variable.env
PUSHER_APP_ID="PUSHER_APP_ID"
PUSHER_APP_KEY="PUSHER_APP_KEY"
PUSHER_APP_SECRET="PUSHER_APP_SECRET"
PUSHER_APP_CLUSTER="PUSHER_APP_CLUSTER"
PUSHER_APP_SECURE="1"
PUSHER_CHANNELS_ENCRYPTION_KEY="PUSHER_CHANNELS_ENCRYPTION_KEY"
PUSHER_CHANNELS_ENCRYPTION_KEY
will be the master encryption key used to generate the shared secret and it should be difficult to guess. It is also required to be a 32 byte encryption key. You can generate a suitable encryption key with the following command:
$ openssl rand -base64 24
You will also need to install some dependencies - the Pusher NodeJS SDK , Express and another for parsing the variable.env
file you previously created. You can grab those dependencies by running:
$ npm init -y
$ npm install express body-parser cors dotenv pusher -S
You will need to create an index.js
file and paste in the following content:
// pusher-encrypted-channels-node/server/index.js
require('dotenv').config({ path: 'variable.env' });
const express = require('express');
const bodyParser = require('body-parser');
const cors = require('cors');
const Pusher = require('pusher');
const pusher = new Pusher({
appId: process.env.PUSHER_APP_ID,
key: process.env.PUSHER_APP_KEY,
secret: process.env.PUSHER_APP_SECRET,
cluster: process.env.PUSHER_APP_CLUSTER,
useTLS: true,
encryptionMasterKey: process.env.PUSHER_CHANNELS_ENCRYPTION_KEY,
});
const app = express();
app.use(cors());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
const titles = [];
app.post('/pusher/auth', function(req, res) {
var socketId = req.body.socket_id;
var channel = req.body.channel_name;
var auth = pusher.authenticate(socketId, channel);
res.send(auth);
});
app.post('/feed', (req, res) => {
const title = req.body.title;
const body = req.body.content;
if (title === undefined) {
res
.status(400)
.send({ message: 'Please provide your post title', status: false });
return;
}
if (body === undefined) {
res
.status(400)
.send({ message: 'Please provide your post body', status: false });
return;
}
if (title.length <= 5) {
res.status(400).send({
message: 'Post title should be more than 5 characters',
status: false,
});
return;
}
if (body.length <= 6) {
res.status(400).send({
message: 'Post body should be more than 6 characters',
status: false,
});
return;
}
const index = titles.findIndex(element => {
return element === title;
});
if (index >= 0) {
res
.status(400)
.send({ message: 'Post title already exists', status: false });
return;
}
titles.push(title.trim());
pusher.trigger('private-encrypted-feeds', 'items', {
title: title.trim(),
body: body.trim(),
time: new Date(),
});
res
.status(200)
.send({ message: 'Post was successfully created', status: true });
});
app.set('port', process.env.PORT || 5200);
const server = app.listen(app.get('port'), () => {
console.log(`Express running on port ${server.address().port}`);
});
In the above, we create an HTTP server with two endpoints:
/pusher/auth
for authentication of client SDKs./feed
for the addition of a new feed item.
Note that the feed items will not be stored in a persistent database but in memory instead
You should be able to run the server now. That can be done with:
$ node index.js
Building the client
The client is going to contain three pages:
- A dashboard page.
- A form page for adding new feed items.
- A feed page for displaying feed items in realtime as received from the encrypted channel.
You will need to create a directory called client
. That can be done with:
$ mkdir client
To get started, we will need to build the form page to allow new items to be added. You will need to create a file called new.html
with:
$ touch new.html
In the newly created new.html
file, paste the following content:
// pusher-encrypted-channels-node/client/new.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pusher realtime feed</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<base href="/" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.2/css/bulma.min.css">
<style>
.hidden { display: none }
</style>
<body>
<section class="section">
<div class="container">
<div class="columns">
<div class="column is-5">
<h3 class="notification">Create a new post</h3>
<div class="notification is-success hidden" id="success"></div>
<div class="is-danger notification hidden" id="error"></div>
<form id="feed-form">
<div class="field">
<label class="label">Title : </label>
<div class="control">
<input
class="input"
type="text"
placeholder="Post title"
name="title"
id="title"
/>
</div>
</div>
<div><label>Message: </label></div>
<div>
<textarea
rows="10"
cols="70"
name="content"
id="content"
></textarea>
</div>
<button id="submit" class="button is-info">
Send
</button>
</form>
</div>
<div class="is-7"></div>
</section>
</body>
<script src="app.js"></script>
</html>
This is as simple as can be. We reference the Bulma CSS library, we create a form with an input and text field. Finally we link to a non-existent file called app.js
- we will create that in a bit.
To view what this file looks like, you should navigate to the client
directory and run the following command:
$ python -m http.server 8000
Here I used Python’s inbuilt server but you are free to use whatever. For example you can also make use of
http-server
which can be installed vianpm i http-server
after which you should runhttp-server
.
You should visit localhost:8000/new.html
. You should be presented with something similar to the image below:
As said earlier, we linked to a non-existent file app.js
, we will need to create it and fill it with some code. Create the app.js
file with:
$ touch app.js
In the newly created file, paste the following:
// pusher-encrypted-channels-node/client/app.js
(function() {
const submitFeedBtn = document.getElementById('feed-form');
const isDangerDiv = document.getElementById('error');
const isSuccessDiv = document.getElementById('success');
if (submitFeedBtn !== null) {
submitFeedBtn.addEventListener('submit', function(e) {
isDangerDiv.classList.add('hidden');
isSuccessDiv.classList.add('hidden');
e.preventDefault();
const title = document.getElementById('title');
const content = document.getElementById('content');
if (title.value.length === 0) {
isDangerDiv.classList.remove('hidden');
isDangerDiv.innerHTML = 'Title field is required';
return;
}
if (content.value.length === 0) {
isDangerDiv.classList.remove('hidden');
isDangerDiv.innerHTML = 'Content field is required';
return;
}
fetch('http://localhost:5200/feed', {
method: 'POST',
body: JSON.stringify({ title: title.value, content: content.value }),
headers: {
'Content-Type': 'application/json',
},
}).then(
function(response) {
if (response.status === 200) {
isSuccessDiv.innerHTML = 'Feed item was successfully added';
isSuccessDiv.classList.remove('hidden');
setTimeout(function() {
isSuccessDiv.classList.add('hidden');
}, 1000);
return;
}
response.json().then(data => {
isDangerDiv.innerHTML = data.message;
isDangerDiv.classList.remove('hidden');
});
},
function(error) {
isDangerDiv.innerHTML = 'Could not create feed item';
isDangerDiv.classList.remove('hidden');
}
);
});
}
})();
In the above, we validate the form whenever the Send button is clicked. If the form contains valid data, it is sent to the Node server for processing. The server will store it and trigger a message to Pusher Channels.
Go ahead and submit the form. If successful and you are on the Debug Console, you will notice a JSON that contains a nonce and cipher text - the encrypted information. A visual reprensentation is presented below:
The next point of action will be to create the feeds page so entries can be viewed in realtime. You will need to create a file called feed.html
. That can be done with:
$ touch feed.html
In the new file, paste the following HTML code:
// pusher-encrypted-channels-node/client/feed.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pusher realtime feed</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<base href="/" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.2/css/bulma.min.css">
<body>
<section class="section">
<div class="container">
<h1 class="notification is-info">Your feed</h1>
<div class="columns">
<div class="column is-7">
<div id="feed">
</div>
</div>
</div>
</div>
</section>
</body>
<script src="https://js.pusher.com/4.3/pusher.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.1.0/handlebars.min.js"></script>
<script src="app.js"></script>
</html>
This page is basically empty. It will be updated by the Channels client SDK as it receives data. We are linking to the Pusher Channels client SDK and Handlebars. Handlebars is used to compile templates we will inject into the page.
To be able to receive and update the feeds page with data the app.js
file has to be updated to make use of Pusher Channels. In app.js
, append the following code:
// pusher-encrypted-channels-node/client/app.js
// Sample template to be injected
// below the code we already have in this file
const tmpl = `
<div class="box">
<article class="media">
<div class="media-left">
<figure class="image is-64x64">
<img src="https://bulma.io/images/placeholders/128x128.png" alt="Image" />
</figure>
</div>
<div class="media-content">
<div class="content">
<p>
<strong>{{title}}</strong>
<small>{{time}}</small> <br />
{{body}}
</p>
</div>
</div>
</article>
</div>
`;
const APP_KEY = 'PUSHER_APP_KEY';
const APP_CLUSTER = 'PUSHER_APP_CLUSTER';
const pusher = new Pusher(APP_KEY, {
cluster: APP_CLUSTER,
authEndpoint: 'http://localhost:5200/pusher/auth',
});
const channel = pusher.subscribe('private-encrypted-feeds');
const template = Handlebars.compile(tmpl);
const feedDiv = document.getElementById('feed');
channel.bind('items', function(data) {
console.log(data);
const html = template(data);
const divElement = document.createElement('div');
divElement.innerHTML = html;
feedDiv.appendChild(divElement);
});
Remember to replace both
PUSHER_CLUSTER
andPUSHER_KEY
with your credentials
With the addition above, the entire app.js
should look like:
// pusher-encrypted-channels-node/client/app.js
(function() {
const submitFeedBtn = document.getElementById('feed-form');
const isDangerDiv = document.getElementById('error');
const isSuccessDiv = document.getElementById('success');
if (submitFeedBtn !== null) {
submitFeedBtn.addEventListener('submit', function(e) {
isDangerDiv.classList.add('hidden');
isSuccessDiv.classList.add('hidden');
e.preventDefault();
const title = document.getElementById('title');
const content = document.getElementById('content');
if (title.value.length === 0) {
isDangerDiv.classList.remove('hidden');
isDangerDiv.innerHTML = 'Title field is required';
return;
}
if (content.value.length === 0) {
isDangerDiv.classList.remove('hidden');
isDangerDiv.innerHTML = 'Content field is required';
return;
}
fetch('http://localhost:5200/feed', {
method: 'POST',
body: JSON.stringify({ title: title.value, content: content.value }),
headers: {
'Content-Type': 'application/json',
},
}).then(
function(response) {
if (response.status === 200) {
isSuccessDiv.innerHTML = 'Feed item was successfully added';
isSuccessDiv.classList.remove('hidden');
setTimeout(function() {
isSuccessDiv.classList.add('hidden');
}, 1000);
return;
}
response.json().then(data => {
isDangerDiv.innerHTML = data.message;
isDangerDiv.classList.remove('hidden');
});
},
function(error) {
isDangerDiv.innerHTML = 'Could not create feed item';
isDangerDiv.classList.remove('hidden');
}
);
});
}
const tmpl = `
<div class="box">
<article class="media">
<div class="media-left">
<figure class="image is-64x64">
<img src="https://bulma.io/images/placeholders/128x128.png" alt="Image" />
</figure>
</div>
<div class="media-content">
<div class="content">
<p>
<strong>{{title}}</strong>
<small>{{time}}</small> <br />
{{body}}
</p>
</div>
</div>
</article>
</div>
`;
const APP_KEY = 'PUSHER_APP_KEY';
const APP_CLUSTER = 'PUSHER_APP_CLUSTER';
const pusher = new Pusher(APP_KEY, {
cluster: APP_CLUSTER,
authEndpoint: 'http://localhost:5200/pusher/auth',
});
const channel = pusher.subscribe('private-encrypted-feeds');
const template = Handlebars.compile(tmpl);
const feedDiv = document.getElementById('feed');
channel.bind('items', function(data) {
console.log(data);
const html = template(data);
const divElement = document.createElement('div');
divElement.innerHTML = html;
feedDiv.appendChild(divElement);
});
})();
You can go ahead to open the feed.html
page on a tab and new.html
in another. Watch closely as whatever data you submit in new.html
appears in feed.html
. You can also keep an eye on the Debug Console to make sure all data is encrypted.
To make this app a little more polished, add an index.html
page. You can find the source code at the accompanying GitHub repository of this tutorial.
Conclusion
In this tutorial, I introduced you to a lesser known feature of Pusher Channels - end to end encryption with encrypted channels. We also built an application that uses encrypted channels instead of the regular public channels you might be used to. To learn more about encrypted channels, kindly visit its documentation.
As always, the entire code for this tutorial can be found on GitHub.
3 April 2019
by Lanre Adelowo