Skip to content
This repository has been archived by the owner on Jun 29, 2021. It is now read-only.

Commit

Permalink
[5.x] Encrypted private channels (#37)
Browse files Browse the repository at this point in the history
* Added encrypted channels
  • Loading branch information
rennokki authored Jun 2, 2021
1 parent 58239a8 commit e16fc07
Show file tree
Hide file tree
Showing 9 changed files with 198 additions and 23 deletions.
81 changes: 64 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,19 @@ This is a fork of the original [Laravel Echo Server package](https://github.com/
- [System Requirements](#system-requirements)
- [🚀 Installation](#-installation)
- [🙌 Usage](#-usage)
- [📀 Environment Variables](#-environment-variables)
- [📡 Pusher Compatibility](#-pusher-compatibility)
- [📡 Frontend (Client) Configuration](#-frontend-client-configuration)
- [👓 App Management](#-app-management)
- [↔ Horizontal Scaling](#-horizontal-scaling)
- [📈 Per-application Statistics](#-per-application-statistics)
- [🚢 Deploying with PM2](#-deploying-with-pm2)
- [🐳 Docker Images](#-docker-images)
- [⚙ Configuration](#-configuration)
- [📀 Environment Variables](#-environment-variables)
- [📡 Pusher Compatibility](#-pusher-compatibility)
- [🔐 Encrypted Private Channels](#-encrypted-private-channels)
- [📡 Frontend (Client) Configuration](#-frontend-client-configuration)
- [👓 App Management](#-app-management)
- [↔ Horizontal Scaling](#-horizontal-scaling)
- [📈 Per-application Statistics](#-per-application-statistics)
- [📦 Deploying](#-deploying)
- [🚢 Deploying with PM2](#-deploying-with-pm2)
- [🐳 Deploying with Docker](#-deploying-with-docker)
- [⚓ Deploy with Helm](#-deploy-with-helm)
- [Running at scale](#running-at-scale)
- [🌍 Running at scale](#-running-at-scale)
- [🤝 Contributing](#-contributing)
- [🔒 Security](#--security)
- [🎉 Credits](#-credits)
Expand Down Expand Up @@ -69,7 +72,9 @@ You can run Echo Server directly from the CLI:
$ echo-server start
```

## 📀 Environment Variables
## ⚙ Configuration

### 📀 Environment Variables

Since there is no configuration file, you may declare the parameters using environment variables directly passed in the CLI, either as key-value attributes in an `.env` file at the root of the project:

Expand All @@ -86,7 +91,7 @@ ECHO_SERVER_DEBUG=1

Check the [environment variables documentation file](docs/ENV.md) on how you can configure the server according to your use case.

## 📡 Pusher Compatibility
### 📡 Pusher Compatibility

This server has HTTP REST API compatibility with the Pusher clients. This means that you can use the `pusher` broadcasting driver pointing to the server and it will work seamlessly.

Expand Down Expand Up @@ -136,7 +141,47 @@ ECHO_SERVER_DEFAULT_SECRET=echo-app-secret

**The given configuration is an example and should not be used on production as-is. Consider checking [App Management](#app-management) section to learn how to create your own apps.**

## 📡 Frontend (Client) Configuration
#### 🔐 Encrypted Private Channels

Starting with 5.4.0, Echo Server supports Encrypted Private Channels. This feature needs extra configuration from your side to make them work, according to the [Pusher protocol](https://pusher.com/docs/channels/using_channels/encrypted-channels).

The minimum required soketi-js package is [@soketi/soketi-js@^1.4.2](https://github.com/soketi/soketi-js/releases/tag/1.4.2). You will be using it instead of Laravel Echo. To find more about the frontend configuration, read [Frontend (Client) Configuration](#-frontend-client-configuration).

You will need to configure your frontend client and your event-sending backend client with a master key that will be generated using the OpenSSL CLI:

```bash
$ openssl rand -base64 32
```

The generated key by the command will be then used **only in your client** configurations. These has nothing to do with the server configuration, as finding the key at the server-side will not match with the protocol specifications and will reside a potential security breach.

```php
'socketio' => [
'driver' => 'pusher',
...
'options' => [
...
'encryption_master_key_base64' => 'vwTqW/UBENYBOySubUo8fldlMFvCzOY8BFO5xAgnOus=',
],
],
```

```js
window.Soketi = new Soketi({
...
encryptionMasterKeyBase64: 'vwTqW/UBENYBOySubUo8fldlMFvCzOY8BFO5xAgnOus=',
});
```

You then are free to use the encrypted private channels:

```js
Soketi.encryptedPrivate('top-secret').listen('.new-documents', e => {
//
});
```

### 📡 Frontend (Client) Configuration

Soketi.js is a hard fork of [laravel/echo](https://github.com/laravel/echo), meaning that you can use it as a normal Echo client, being fully compatible with all the docs [in the Broadcasting docs](https://laravel.com/docs/8.x/broadcasting).

Expand Down Expand Up @@ -164,29 +209,31 @@ Soketi.channel('twitter').listen('.tweet', e => {
});
```

## 👓 App Management
### 👓 App Management

Apps are used to allow access to clients, as well as to be able to securely publish events to your users by using signatures generated by app secret.

Check [documentation on how to implement different app management drivers](docs/APP_MANAGERS.md) for your app.

## ↔ Horizontal Scaling
### ↔ Horizontal Scaling

To be able to scale it horizontally, you might want to use Redis as the replication driver, that will enable the Redis Adapter for Socket.IO and nodes can be connected to same Redis instance via Pub/Sub.

```bash
$ REPLICATION_DRIVER=redis echo-server start
```

## 📈 Per-application Statistics
### 📈 Per-application Statistics

Statistics are available for each registered app, if opted-in. They are disabled by default, and [you can turn them on for each app](docs/STATISTICS.md).

## 🚢 Deploying with PM2
## 📦 Deploying

### 🚢 Deploying with PM2

This server is PM2-ready and [can scale to a lot of processes](docs/PM2.md).

## 🐳 Docker Images
### 🐳 Deploying with Docker

Automatically, after each release, a Docker tag is created with an image that holds the app code. [Read about versions & usage on DockerHub](https://hub.docker.com/r/soketi/echo-server).

Expand Down
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@
"dotenv": "^8.2.0",
"express": "^4.17.1",
"ioredis": "^4.19.0",
"os-utils": "0.0.14",
"prom-client": "^13.1.0",
"prometheus-query": "^3.0.0",
"pusher": "^5.0.0",
Expand All @@ -54,7 +53,7 @@
"@babel/plugin-proposal-throw-expressions": "^7.8.3",
"@babel/plugin-transform-object-assign": "^7.8.3",
"@babel/preset-env": "^7.9.6",
"@soketi/soketi-js": "^1.1.2",
"@soketi/soketi-js": "^1.4.2",
"@types/jest": "^26.0.19",
"@types/node": "^14.14.16",
"@typescript-eslint/eslint-plugin": "^4.11.1",
Expand Down
1 change: 0 additions & 1 deletion src/api/http-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { nextTick } from 'process';

const bodyParser = require('body-parser');
const dayjs = require('dayjs');
const os = require('os-utils');
const pusherUtil = require('pusher/lib/util');
const Pusher = require('pusher');
const url = require('url');
Expand Down
11 changes: 11 additions & 0 deletions src/channels/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export class Channel {
*/
protected static _privateChannelPatterns: string[] = [
'private-*',
'private-encrypted-*',
'presence-*',
];

Expand Down Expand Up @@ -258,4 +259,14 @@ export class Channel {
static isPresenceChannel(channel: string): boolean {
return channel.lastIndexOf('presence-', 0) === 0;
}

/**
* Check if the given channel name is a encrypted private channel.
*
* @param {string} channel
* @return {boolean}
*/
static isEncryptedPrivateChannel(channel: string): boolean {
return channel.lastIndexOf('private-encrypted-', 0) === 0;
}
}
5 changes: 5 additions & 0 deletions src/channels/encrypted-private-channel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { PrivateChannel } from './private-channel';

export class EncryptedPrivateChannel extends PrivateChannel {
//
}
1 change: 1 addition & 0 deletions src/channels/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { PresenceChannel } from './presence-channel';
export { EncryptedPrivateChannel } from './encrypted-private-channel';
export { PrivateChannel } from './private-channel';
export { Channel } from './channel';
16 changes: 13 additions & 3 deletions src/echo-server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { AppManager } from './app-managers/app-manager';
import { Channel, PresenceChannel, PrivateChannel } from './channels';
import { Channel, EncryptedPrivateChannel, PresenceChannel, PrivateChannel } from './channels';
import { HttpApi } from './api';
import { Log } from './log';
import { Prometheus } from './prometheus';
Expand Down Expand Up @@ -151,6 +151,13 @@ export class EchoServer {
*/
protected privateChannel: PrivateChannel;

/**
* Encrypted private channel instance.
*
* @type {PrivateChannel}
*/
protected encryptedPrivateChannel: EncryptedPrivateChannel;

/**
* Presence channel instance.
*
Expand Down Expand Up @@ -261,6 +268,7 @@ export class EchoServer {

this.publicChannel = new Channel(io, this.stats, this.prometheus, this.options);
this.privateChannel = new PrivateChannel(io, this.stats, this.prometheus, this.options);
this.encryptedPrivateChannel = new EncryptedPrivateChannel(io, this.stats, this.prometheus, this.options);
this.presenceChannel = new PresenceChannel(io, this.stats, this.prometheus, this.options);

this.httpApi = new HttpApi(
Expand Down Expand Up @@ -483,11 +491,13 @@ export class EchoServer {
* Get the channel instance for a channel name.
*
* @param {string} channel
* @return {Channel|PrivateChannel|PresenceChannel}
* @return {Channel|PrivateChannel|EncryptedPrivateChannel|PresenceChannel}
*/
getChannelInstance(channel): Channel|PrivateChannel|PresenceChannel {
getChannelInstance(channel): Channel|PrivateChannel|EncryptedPrivateChannel|PresenceChannel {
if (Channel.isPresenceChannel(channel)) {
return this.presenceChannel;
} else if (Channel.isEncryptedPrivateChannel(channel)) {
return this.encryptedPrivateChannel;
} else if (Channel.isPrivateChannel(channel)) {
return this.privateChannel;
} else {
Expand Down
20 changes: 20 additions & 0 deletions tests/connector.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { decode as decodeBase64 } from '@stablelib/base64';
import Soketi from '@soketi/soketi-js';

const crypto = require('crypto');
const Pusher = require('pusher');

export class Connector {
Expand All @@ -11,6 +13,7 @@ export class Connector {
authHost: `http://127.0.0.1:${port}`,
authEndpoint: '/test/broadcasting/auth',
authorizer,
encryptionMasterKeyBase64: 'vwTqW/UBENYBOySubUo8fldlMFvCzOY8BFO5xAgnOus=',
});
}

Expand All @@ -36,6 +39,18 @@ export class Connector {
}), host, port, key);
}

static newClientForEncryptedPrivateChannel(host = '127.0.0.1', port = 6001, key = 'echo-app-key'): Soketi {
return this.newClient((channel, options) => ({
authorize: (socketId, callback) => {
callback(false, {
auth: this.signTokenForPrivateChannel(socketId, channel),
channel_data: null,
shared_secret: this.newPusherClient().channelSharedSecret(channel.name).toString('base64'),
})
},
}), host, port, key);
}

static connectToPublicChannel(client, channel: string): any {
return client.channel(channel);
}
Expand All @@ -44,6 +59,10 @@ export class Connector {
return client.private(channel);
}

static connectToEncryptedPrivateChannel(client, channel: string): any {
return client.encryptedPrivate(channel);
}

static connectToPresenceChannel(client, channel: string): any {
return client.join(channel);
}
Expand All @@ -68,6 +87,7 @@ export class Connector {
httpHost: host,
httpPort: port,
httpsPort: port,
encryptionMasterKeyBase64: 'vwTqW/UBENYBOySubUo8fldlMFvCzOY8BFO5xAgnOus=',
});
}

Expand Down
83 changes: 83 additions & 0 deletions tests/encrypted-private.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { Connector } from './connector';

jest.retryTimes(5);

describe('encrypted private channel test', () => {
// TODO: Fix test.
/* test('connects to encrypted private channel', done => {
let client = Connector.newClientForEncryptedPrivateChannel();
let pusher = Connector.newPusherClient();
let roomName = Connector.randomChannelName();
client.connector.socket.onAny((event, ...args) => {
if (event === 'channel:joined' && args[0] === `private-encrypted-${roomName}`) {
Connector.sendEventToChannel(pusher, `private-encrypted-${roomName}`, 'message', { message: 'hello' });
}
});
Connector.connectToEncryptedPrivateChannel(client, roomName).listen('.message', e => {
expect(e.message).toBe('hello');
client.disconnect();
done();
});
}); */

test('whisper works', done => {
let client1 = Connector.newClientForEncryptedPrivateChannel();
let client2 = Connector.newClientForEncryptedPrivateChannel();
let roomName = Connector.randomChannelName();

Connector.connectToEncryptedPrivateChannel(client1, roomName)
.listenForWhisper('typing', whisper => {
expect(whisper.typing).toBe(true);
client1.disconnect();
client2.disconnect();
done();
});

Connector.wait(5000).then(() => {
Connector.connectToEncryptedPrivateChannel(client2, roomName)
.whisper('typing', { typing: true });
});
});

test('get app channels', done => {
let client = Connector.newClientForEncryptedPrivateChannel();
let pusher = Connector.newPusherClient();
let roomName = Connector.randomChannelName();

client.connector.socket.onAny((event, ...args) => {
if (event === 'channel:joined') {
pusher.get({ path: '/channels' }).then(res => res.json()).then(body => {
expect(body.channels[`private-encrypted-${roomName}`].occupied).toBe(true);
expect(body.channels[`private-encrypted-${roomName}`].subscription_count).toBe(1);

client.disconnect();
done();
});
}
});

Connector.connectToEncryptedPrivateChannel(client, roomName);
});

test('get app channel', done => {
let client = Connector.newClientForEncryptedPrivateChannel();
let pusher = Connector.newPusherClient();
let roomName = Connector.randomChannelName();

client.connector.socket.onAny((event, ...args) => {
if (event === 'channel:joined') {
pusher.get({ path: `/channels/private-encrypted-${roomName}` }).then(res => res.json()).then(body => {
expect(body.subscription_count).toBe(1);
expect(body.occupied).toBe(true);

client.disconnect();
done();
});
}
});

Connector.connectToEncryptedPrivateChannel(client, roomName);
});
});

0 comments on commit e16fc07

Please sign in to comment.