Skip to content

Commit 3b3a85b

Browse files
ThanhDodeurOdooalexkuhn
authored andcommitted
[IMP] Add per-channel encryption feature
This commit adds per-channel encryption to prevent users from signing JWTs for channels that they did not create. This features works by allowing an additional `key` claim in the JWT used to request a room. When this claim is set, the JWTs used to authenticate users are expected to be signed by this new key and not the "global" SFU key (`AUTH_KEY` global variable). This change is useful for cases like Odoo.SH where multiple parties can use the same SFU with the same credentials. task-3861455
1 parent beafcc2 commit 3b3a85b

File tree

6 files changed

+55
-17
lines changed

6 files changed

+55
-17
lines changed

src/client.js

+6-2
Original file line numberDiff line numberDiff line change
@@ -158,13 +158,15 @@ export class SfuClient extends EventTarget {
158158
* @param {string} url
159159
* @param {string} jsonWebToken
160160
* @param {Object} [options]
161+
* @param {string} [options.channelUUID]
161162
* @param {[]} [options.iceServers]
162163
*/
163-
async connect(url, jsonWebToken, { iceServers } = {}) {
164+
async connect(url, jsonWebToken, { channelUUID, iceServers } = {}) {
164165
// saving the options for so that the parameters are saved for reconnection attempts
165166
this._url = url.replace(/^http/, "ws"); // makes sure the url is a websocket url
166167
this._jsonWebToken = jsonWebToken;
167168
this._iceServers = iceServers;
169+
this._channelUUID = channelUUID;
168170
this._connectRetryDelay = INITIAL_RECONNECT_DELAY;
169171
this._device = this._createDevice();
170172
await this._connect();
@@ -393,7 +395,9 @@ export class SfuClient extends EventTarget {
393395
webSocket.addEventListener(
394396
"open",
395397
() => {
396-
webSocket.send(JSON.stringify(this._jsonWebToken));
398+
webSocket.send(
399+
JSON.stringify({ channelUUID: this._channelUUID, jwt: this._jsonWebToken })
400+
);
397401
},
398402
{ once: true }
399403
);

src/models/channel.js

+11-4
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export class Channel extends EventEmitter {
3737
uuid;
3838
/** @type {string} short of uuid for logging */
3939
name;
40+
/** @type {WithImplicitCoercion<string>} base 64 buffer key */
41+
key;
4042
/** @type {import("mediasoup").types.Router}*/
4143
router;
4244
/** @type {Map<number, Session>} */
@@ -50,18 +52,19 @@ export class Channel extends EventEmitter {
5052
* @param {string} remoteAddress
5153
* @param {string} issuer
5254
* @param {Object} [options]
55+
* @param {string} [options.key] if the key is set, authentication with this channel uses this key
5356
* @param {boolean} [options.useWebRtc=true] whether to use WebRTC:
5457
* with webRTC: can stream audio/video
5558
* without webRTC: can only use websocket
5659
*/
57-
static async create(remoteAddress, issuer, { useWebRtc = true } = {}) {
60+
static async create(remoteAddress, issuer, { key, useWebRtc = true } = {}) {
5861
const safeIssuer = `${remoteAddress}::${issuer}`;
5962
const oldChannel = Channel.recordsByIssuer.get(safeIssuer);
6063
if (oldChannel) {
6164
logger.verbose(`reusing channel ${oldChannel.uuid}`);
6265
return oldChannel;
6366
}
64-
const options = {};
67+
const options = { key };
6568
if (useWebRtc) {
6669
options.worker = await getWorker();
6770
options.router = await options.worker.createRouter({
@@ -71,7 +74,9 @@ export class Channel extends EventEmitter {
7174
const channel = new Channel(remoteAddress, options);
7275
Channel.recordsByIssuer.set(safeIssuer, channel);
7376
Channel.records.set(channel.uuid, channel);
74-
logger.info(`created channel ${channel.uuid} for ${safeIssuer}`);
77+
logger.info(
78+
`created channel ${channel.uuid} (${key ? "unique" : "global"} key) for ${safeIssuer}`
79+
);
7580
const onWorkerDeath = () => {
7681
logger.warn(`worker died, closing channel ${channel.uuid}`);
7782
channel.close();
@@ -111,14 +116,16 @@ export class Channel extends EventEmitter {
111116
/**
112117
* @param {string} remoteAddress
113118
* @param {Object} [options]
119+
* @param {string} [options.key]
114120
* @param {import("mediasoup").types.Worker} [options.worker]
115121
* @param {import("mediasoup").types.Router} [options.router]
116122
*/
117-
constructor(remoteAddress, { worker, router } = {}) {
123+
constructor(remoteAddress, { key, worker, router } = {}) {
118124
super();
119125
const now = new Date();
120126
this.createDate = now.toISOString();
121127
this.remoteAddress = remoteAddress;
128+
this.key = key && Buffer.from(key, "base64");
122129
this.uuid = crypto.randomUUID();
123130
this.name = `${remoteAddress}*${this.uuid.slice(-5)}`;
124131
this.router = router;

src/services/auth.js

+3-2
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,13 @@ export function close() {
2222

2323
/**
2424
* @param {string} jsonWebToken
25+
* @param {WithImplicitCoercion<string>} [key] buffer/b64 str
2526
* @returns {Promise<any>} json serialized data
2627
* @throws {AuthenticationError}
2728
*/
28-
export async function verify(jsonWebToken) {
29+
export async function verify(jsonWebToken, key = jwtKey) {
2930
try {
30-
return jwt.verify(jsonWebToken, jwtKey, {
31+
return jwt.verify(jsonWebToken, key, {
3132
algorithms: ["HS256"],
3233
});
3334
} catch {

src/services/http.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,15 @@ export async function start({ httpInterface = config.HTTP_INTERFACE, port = conf
5252
callback: async (req, res, { host, protocol, remoteAddress, searchParams }) => {
5353
try {
5454
const jsonWebToken = req.headers.authorization?.split(" ")[1];
55-
/** @type {{ iss: string }} */
55+
/** @type {{ iss: string, key: string || undefined }} */
5656
const claims = await auth.verify(jsonWebToken);
5757
if (!claims.iss) {
5858
logger.warn(`${remoteAddress}: missing issuer claim when creating channel`);
5959
res.statusCode = 403; // forbidden
6060
return res.end();
6161
}
6262
const channel = await Channel.create(remoteAddress, claims.iss, {
63+
key: claims.key,
6364
useWebRtc: searchParams.get("webRTC") !== "false",
6465
});
6566
res.setHeader("Content-Type", "application/json");

src/services/ws.js

+30-7
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ import { SESSION_CLOSE_CODE } from "#src/models/session.js";
99
import { Channel } from "#src/models/channel.js";
1010
import { verify } from "#src/services/auth.js";
1111

12+
/**
13+
* @typedef Credentials
14+
* @property {string} channelUUID
15+
* @property {string} jwt
16+
*/
17+
1218
const logger = new Logger("WS");
1319
/** @type {Map<number, import("ws").WebSocket>} */
1420
const unauthenticatedWebSockets = new Map();
@@ -44,8 +50,12 @@ export async function start(options) {
4450
}, config.timeouts.authentication);
4551
webSocket.once("message", async (message) => {
4652
try {
47-
const jsonWebToken = JSON.parse(message);
48-
const session = await connect(webSocket, jsonWebToken);
53+
/** @type {Credentials | String} can be a string (the jwt) for backwards compatibility with version 1.1 and earlier */
54+
const credentials = JSON.parse(message);
55+
const session = await connect(webSocket, {
56+
channelUUID: credentials?.channelUUID,
57+
jwt: credentials.jwt || credentials,
58+
});
4959
session.remote = remoteAddress;
5060
logger.info(`session [${session.name}] authenticated and created`);
5161
webSocket.send(); // client can start using ws after this message.
@@ -91,17 +101,30 @@ export function close() {
91101

92102
/**
93103
* @param {import("ws").WebSocket} webSocket
94-
* @param {string} jsonWebToken
104+
* @param {Credentials}
95105
*/
96-
async function connect(webSocket, jsonWebToken) {
106+
async function connect(webSocket, { channelUUID, jwt }) {
107+
let channel = Channel.records.get(channelUUID);
97108
/** @type {{sfu_channel_uuid: string, session_id: number, ice_servers: Object[] }} */
98-
const authResult = await verify(jsonWebToken);
109+
const authResult = await verify(jwt, channel?.key);
99110
const { sfu_channel_uuid, session_id, ice_servers } = authResult;
100-
if (!sfu_channel_uuid || !session_id) {
111+
if (!channelUUID && sfu_channel_uuid) {
112+
// Cases where the channelUUID is not provided in the credentials for backwards compatibility with version 1.1 and earlier.
113+
channel = Channel.records.get(sfu_channel_uuid);
114+
if (channel.key) {
115+
throw new AuthenticationError(
116+
"A channel with a key can only be accessed by providing a channelUUID in the credentials"
117+
);
118+
}
119+
}
120+
if (!channel) {
121+
throw new AuthenticationError(`Channel does not exist`);
122+
}
123+
if (!session_id) {
101124
throw new AuthenticationError("Malformed JWT payload");
102125
}
103126
const bus = new Bus(webSocket, { batchDelay: config.timeouts.busBatch });
104-
const { session } = Channel.join(sfu_channel_uuid, session_id);
127+
const { session } = Channel.join(channel.uuid, session_id);
105128
session.once("close", ({ code }) => {
106129
let wsCloseCode = WS_CLOSE_CODE.CLEAN;
107130
switch (code) {

tests/utils/network.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export class LocalNetwork {
5151
async getChannelUUID(useWebRtc = true) {
5252
const jwt = this.makeJwt({
5353
iss: `http://${this.hostname}:${this.port}/`,
54+
key: HMAC_B64_KEY,
5455
});
5556
const response = await fetch(
5657
`http://${this.hostname}:${this.port}/v${http.API_VERSION}/channel?webRTC=${useWebRtc}`,
@@ -59,7 +60,7 @@ export class LocalNetwork {
5960
headers: {
6061
Authorization: "jwt " + jwt,
6162
},
62-
},
63+
}
6364
);
6465
const result = await response.json();
6566
return result.uuid;
@@ -104,6 +105,7 @@ export class LocalNetwork {
104105
sfu_channel_uuid: channelUUID,
105106
session_id: sessionId,
106107
}),
108+
{ channelUUID }
107109
);
108110
const channel = Channel.records.get(channelUUID);
109111
await isClientAuthenticated;

0 commit comments

Comments
 (0)