Skip to content

Commit

Permalink
[IMP] Add per-channel encryption feature
Browse files Browse the repository at this point in the history
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
  • Loading branch information
ThanhDodeurOdoo authored and alexkuhn committed May 7, 2024
1 parent beafcc2 commit 3b3a85b
Show file tree
Hide file tree
Showing 6 changed files with 55 additions and 17 deletions.
8 changes: 6 additions & 2 deletions src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,13 +158,15 @@ export class SfuClient extends EventTarget {
* @param {string} url
* @param {string} jsonWebToken
* @param {Object} [options]
* @param {string} [options.channelUUID]
* @param {[]} [options.iceServers]
*/
async connect(url, jsonWebToken, { iceServers } = {}) {
async connect(url, jsonWebToken, { channelUUID, iceServers } = {}) {
// saving the options for so that the parameters are saved for reconnection attempts
this._url = url.replace(/^http/, "ws"); // makes sure the url is a websocket url
this._jsonWebToken = jsonWebToken;
this._iceServers = iceServers;
this._channelUUID = channelUUID;
this._connectRetryDelay = INITIAL_RECONNECT_DELAY;
this._device = this._createDevice();
await this._connect();
Expand Down Expand Up @@ -393,7 +395,9 @@ export class SfuClient extends EventTarget {
webSocket.addEventListener(
"open",
() => {
webSocket.send(JSON.stringify(this._jsonWebToken));
webSocket.send(
JSON.stringify({ channelUUID: this._channelUUID, jwt: this._jsonWebToken })
);
},
{ once: true }
);
Expand Down
15 changes: 11 additions & 4 deletions src/models/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export class Channel extends EventEmitter {
uuid;
/** @type {string} short of uuid for logging */
name;
/** @type {WithImplicitCoercion<string>} base 64 buffer key */
key;
/** @type {import("mediasoup").types.Router}*/
router;
/** @type {Map<number, Session>} */
Expand All @@ -50,18 +52,19 @@ export class Channel extends EventEmitter {
* @param {string} remoteAddress
* @param {string} issuer
* @param {Object} [options]
* @param {string} [options.key] if the key is set, authentication with this channel uses this key
* @param {boolean} [options.useWebRtc=true] whether to use WebRTC:
* with webRTC: can stream audio/video
* without webRTC: can only use websocket
*/
static async create(remoteAddress, issuer, { useWebRtc = true } = {}) {
static async create(remoteAddress, issuer, { key, useWebRtc = true } = {}) {
const safeIssuer = `${remoteAddress}::${issuer}`;
const oldChannel = Channel.recordsByIssuer.get(safeIssuer);
if (oldChannel) {
logger.verbose(`reusing channel ${oldChannel.uuid}`);
return oldChannel;
}
const options = {};
const options = { key };
if (useWebRtc) {
options.worker = await getWorker();
options.router = await options.worker.createRouter({
Expand All @@ -71,7 +74,9 @@ export class Channel extends EventEmitter {
const channel = new Channel(remoteAddress, options);
Channel.recordsByIssuer.set(safeIssuer, channel);
Channel.records.set(channel.uuid, channel);
logger.info(`created channel ${channel.uuid} for ${safeIssuer}`);
logger.info(
`created channel ${channel.uuid} (${key ? "unique" : "global"} key) for ${safeIssuer}`
);
const onWorkerDeath = () => {
logger.warn(`worker died, closing channel ${channel.uuid}`);
channel.close();
Expand Down Expand Up @@ -111,14 +116,16 @@ export class Channel extends EventEmitter {
/**
* @param {string} remoteAddress
* @param {Object} [options]
* @param {string} [options.key]
* @param {import("mediasoup").types.Worker} [options.worker]
* @param {import("mediasoup").types.Router} [options.router]
*/
constructor(remoteAddress, { worker, router } = {}) {
constructor(remoteAddress, { key, worker, router } = {}) {
super();
const now = new Date();
this.createDate = now.toISOString();
this.remoteAddress = remoteAddress;
this.key = key && Buffer.from(key, "base64");
this.uuid = crypto.randomUUID();
this.name = `${remoteAddress}*${this.uuid.slice(-5)}`;
this.router = router;
Expand Down
5 changes: 3 additions & 2 deletions src/services/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ export function close() {

/**
* @param {string} jsonWebToken
* @param {WithImplicitCoercion<string>} [key] buffer/b64 str
* @returns {Promise<any>} json serialized data
* @throws {AuthenticationError}
*/
export async function verify(jsonWebToken) {
export async function verify(jsonWebToken, key = jwtKey) {
try {
return jwt.verify(jsonWebToken, jwtKey, {
return jwt.verify(jsonWebToken, key, {
algorithms: ["HS256"],
});
} catch {
Expand Down
3 changes: 2 additions & 1 deletion src/services/http.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,15 @@ export async function start({ httpInterface = config.HTTP_INTERFACE, port = conf
callback: async (req, res, { host, protocol, remoteAddress, searchParams }) => {
try {
const jsonWebToken = req.headers.authorization?.split(" ")[1];
/** @type {{ iss: string }} */
/** @type {{ iss: string, key: string || undefined }} */
const claims = await auth.verify(jsonWebToken);
if (!claims.iss) {
logger.warn(`${remoteAddress}: missing issuer claim when creating channel`);
res.statusCode = 403; // forbidden
return res.end();
}
const channel = await Channel.create(remoteAddress, claims.iss, {
key: claims.key,
useWebRtc: searchParams.get("webRTC") !== "false",
});
res.setHeader("Content-Type", "application/json");
Expand Down
37 changes: 30 additions & 7 deletions src/services/ws.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ import { SESSION_CLOSE_CODE } from "#src/models/session.js";
import { Channel } from "#src/models/channel.js";
import { verify } from "#src/services/auth.js";

/**
* @typedef Credentials
* @property {string} channelUUID
* @property {string} jwt
*/

const logger = new Logger("WS");
/** @type {Map<number, import("ws").WebSocket>} */
const unauthenticatedWebSockets = new Map();
Expand Down Expand Up @@ -44,8 +50,12 @@ export async function start(options) {
}, config.timeouts.authentication);
webSocket.once("message", async (message) => {
try {
const jsonWebToken = JSON.parse(message);
const session = await connect(webSocket, jsonWebToken);
/** @type {Credentials | String} can be a string (the jwt) for backwards compatibility with version 1.1 and earlier */
const credentials = JSON.parse(message);
const session = await connect(webSocket, {
channelUUID: credentials?.channelUUID,
jwt: credentials.jwt || credentials,
});
session.remote = remoteAddress;
logger.info(`session [${session.name}] authenticated and created`);
webSocket.send(); // client can start using ws after this message.
Expand Down Expand Up @@ -91,17 +101,30 @@ export function close() {

/**
* @param {import("ws").WebSocket} webSocket
* @param {string} jsonWebToken
* @param {Credentials}
*/
async function connect(webSocket, jsonWebToken) {
async function connect(webSocket, { channelUUID, jwt }) {
let channel = Channel.records.get(channelUUID);
/** @type {{sfu_channel_uuid: string, session_id: number, ice_servers: Object[] }} */
const authResult = await verify(jsonWebToken);
const authResult = await verify(jwt, channel?.key);
const { sfu_channel_uuid, session_id, ice_servers } = authResult;
if (!sfu_channel_uuid || !session_id) {
if (!channelUUID && sfu_channel_uuid) {
// Cases where the channelUUID is not provided in the credentials for backwards compatibility with version 1.1 and earlier.
channel = Channel.records.get(sfu_channel_uuid);
if (channel.key) {
throw new AuthenticationError(
"A channel with a key can only be accessed by providing a channelUUID in the credentials"
);
}
}
if (!channel) {
throw new AuthenticationError(`Channel does not exist`);
}
if (!session_id) {
throw new AuthenticationError("Malformed JWT payload");
}
const bus = new Bus(webSocket, { batchDelay: config.timeouts.busBatch });
const { session } = Channel.join(sfu_channel_uuid, session_id);
const { session } = Channel.join(channel.uuid, session_id);
session.once("close", ({ code }) => {
let wsCloseCode = WS_CLOSE_CODE.CLEAN;
switch (code) {
Expand Down
4 changes: 3 additions & 1 deletion tests/utils/network.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export class LocalNetwork {
async getChannelUUID(useWebRtc = true) {
const jwt = this.makeJwt({
iss: `http://${this.hostname}:${this.port}/`,
key: HMAC_B64_KEY,
});
const response = await fetch(
`http://${this.hostname}:${this.port}/v${http.API_VERSION}/channel?webRTC=${useWebRtc}`,
Expand All @@ -59,7 +60,7 @@ export class LocalNetwork {
headers: {
Authorization: "jwt " + jwt,
},
},
}
);
const result = await response.json();
return result.uuid;
Expand Down Expand Up @@ -104,6 +105,7 @@ export class LocalNetwork {
sfu_channel_uuid: channelUUID,
session_id: sessionId,
}),
{ channelUUID }
);
const channel = Channel.records.get(channelUUID);
await isClientAuthenticated;
Expand Down

0 comments on commit 3b3a85b

Please sign in to comment.