diff --git a/backend/api/call/session.ts b/backend/api/call/session.ts new file mode 100644 index 00000000..326d8301 --- /dev/null +++ b/backend/api/call/session.ts @@ -0,0 +1,43 @@ +import express, { Request, Response } from 'express'; +import asyncHandler from '../../middleware/asyncHandler'; +import { WebrtcSessionResponse } from '../messaging/types'; +import channelValid from '../chatLink/utils/validateChannel'; +import getClientInstance from '../../socket.io/clients'; +import { SOCKET_TOPIC, socketEmit } from '../../socket.io'; +const router = express.Router({ mergeParams: true }); + +const clients = getClientInstance(); + +router.post( + "/", + asyncHandler(async (req: Request, res: Response): Promise> => { + const { description, sender, channel } = req.body; + + if (!description) { + return res.send(400); + } + + const { valid } = await channelValid(channel); + + if (!valid) { + return res.sendStatus(404); + } + + if (!clients.isSenderInChannel(channel, sender)) { + console.error('Sender is not in channel'); + return res.status(401).send({ error: "Permission denied" }); + } + + const receiver = clients.getReceiverIDBySenderID(sender, channel); + if(!receiver) { + console.error('No receiver is in the channel'); + return res.status(406).send({ error: "No user available to accept call" }); + } + + const receiverSid = clients.getSIDByIDs(receiver, channel).sid; + socketEmit(SOCKET_TOPIC.WEBRTC_SESSION_DESCRIPTION, receiverSid, description); + return res.send({ status: "ok" }); + }) + ); + + export default router; \ No newline at end of file diff --git a/backend/api/index.ts b/backend/api/index.ts index ff695c79..fcc1b126 100644 --- a/backend/api/index.ts +++ b/backend/api/index.ts @@ -2,6 +2,7 @@ import express, { Request, Response } from 'express'; import chatLinkController from './chatLink'; import chatController from './messaging'; +import sessionController from './call/session'; const router = express.Router({ mergeParams: true }); @@ -11,5 +12,6 @@ router.get("/", async (req: Request, res: Response) => { router.use("/chat", chatController); router.use("/chat-link", chatLinkController); +router.use("/session", sessionController); export default router; diff --git a/backend/api/messaging/index.ts b/backend/api/messaging/index.ts index 028e1bfc..b3bbf8ce 100644 --- a/backend/api/messaging/index.ts +++ b/backend/api/messaging/index.ts @@ -2,7 +2,6 @@ import express, { Request, Response } from 'express'; import db from '../../db'; import { PUBLIC_KEY_COLLECTION } from '../../db/const'; -import uploadImage from '../../external/uploadImage'; import asyncHandler from '../../middleware/asyncHandler'; import { SOCKET_TOPIC, socketEmit } from '../../socket.io'; import getClientInstance from '../../socket.io/clients'; @@ -27,16 +26,13 @@ router.post( if (!valid) { return res.sendStatus(404); } - const usersInChannel = clients.getClientsByChannel(channel); - const usersInChannelArr = Object.keys(usersInChannel); - const ifSenderIsInChannel = usersInChannelArr.find((u) => u === sender); - if (!ifSenderIsInChannel) { + if (!clients.isSenderInChannel(channel, sender)) { console.error('Sender is not in channel'); return res.status(401).send({ error: "Permission denied" }); } - const receiver = usersInChannelArr.find((u) => u !== sender); + const receiver = clients.getReceiverIDBySenderID(sender, channel); if(!receiver) { console.error('No receiver is in the channel'); return; @@ -53,10 +49,9 @@ router.post( }; if (image) { - const { imageurl } = await uploadImage(image); - dataToPublish.image = imageurl; + return res.status(400).send({ message: "Image not supported" }); } - const receiverSid = usersInChannel[receiver].sid; + const receiverSid = clients.getSIDByIDs(receiver, channel).sid; socketEmit(SOCKET_TOPIC.CHAT_MESSAGE, receiverSid, dataToPublish); return res.send({ message: "message sent", id, timestamp }); }) @@ -65,14 +60,14 @@ router.post( router.post( "/share-public-key", asyncHandler(async (req: Request, res: Response): Promise> => { - const { publicKey, sender, channel } = req.body; + const { aesKey, publicKey, sender, channel } = req.body; const { valid } = await channelValid(channel); if (!valid) { return res.sendStatus(404); } // TODO: do not store if already exists - await db.insertInDb({ publicKey, user: sender, channel }, PUBLIC_KEY_COLLECTION); + await db.insertInDb({ aesKey, publicKey, user: sender, channel }, PUBLIC_KEY_COLLECTION); return res.send({ status: "ok" }); }) ); @@ -90,7 +85,8 @@ router.get( const receiverID = clients.getReceiverIDBySenderID(userId as string, channel as string); const data = await db.findOneFromDB({ channel, user: receiverID }, PUBLIC_KEY_COLLECTION); return res.send(data || { - public_key: null + publicKey: null, + aesKey: null }); }) ); diff --git a/backend/api/messaging/types.ts b/backend/api/messaging/types.ts index 1d6a4a64..7b427f01 100644 --- a/backend/api/messaging/types.ts +++ b/backend/api/messaging/types.ts @@ -1,6 +1,7 @@ // router.response export type MessageResponse = { message: string, id: string, timestamp: number } export type SharePublicKeyResponse = { status: string } +export type WebrtcSessionResponse = { status: string } export type GetPublicKeyResponse = { public_key: string } export type UsersInChannelResponse = { uuid: string }[] diff --git a/backend/db/index.ts b/backend/db/index.ts index 5f831ab0..0a07467e 100644 --- a/backend/db/index.ts +++ b/backend/db/index.ts @@ -39,7 +39,7 @@ const findOneFromDB = async(findCondition, collectionName: string): Promise(condition, data, collectionName: string): Promise => { diff --git a/backend/socket.io/clients.ts b/backend/socket.io/clients.ts index e5fc053a..3e38f7b1 100644 --- a/backend/socket.io/clients.ts +++ b/backend/socket.io/clients.ts @@ -70,6 +70,12 @@ class Clients implements ClientRecordInterface{ delete this.clientRecord[channelID][userID]; } + isSenderInChannel(channel: string, sender: string): boolean { + const usersInChannel = this.getClientsByChannel(channel); + const usersInChannelArr = Object.keys(usersInChannel); + return !!usersInChannelArr.find((u) => u === sender); + } + } const clientInstance = new Clients(); diff --git a/backend/socket.io/index.ts b/backend/socket.io/index.ts index f298c510..9e02a9a0 100644 --- a/backend/socket.io/index.ts +++ b/backend/socket.io/index.ts @@ -15,6 +15,7 @@ export enum SOCKET_TOPIC { DELIVERED = 'delivered', ON_ALICE_DISCONNECTED = 'on-alice-disconnect', MESSAGE = 'message', + WEBRTC_SESSION_DESCRIPTION = 'webrtc-session-description' } type emitDataTypes = { diff --git a/client/src/components/Button/Style.module.css b/client/src/components/Button/Style.module.css index 55415a8f..c159fdde 100644 --- a/client/src/components/Button/Style.module.css +++ b/client/src/components/Button/Style.module.css @@ -1,5 +1,5 @@ .button { - padding: 10px 20px; + padding: 6px 20px; display: inline-block; border-radius: 3px; border-color: transparent; diff --git a/client/src/components/DeleteChatLink/index.tsx b/client/src/components/DeleteChatLink/index.tsx index 5bda8714..6c884091 100644 --- a/client/src/components/DeleteChatLink/index.tsx +++ b/client/src/components/DeleteChatLink/index.tsx @@ -1,25 +1,15 @@ -import React, { useContext } from "react"; -import { ThemeContext } from "../../ThemeContext"; -import styles from "./Style.module.css"; +import React from "react"; +import Button from "../Button"; const DeleteChatLink = ({ handleDeleteLink }: any) => { - const [darkMode] = useContext(ThemeContext); - const deleteHandler = async () => { if (window.confirm("Delete chat link forever?")) await handleDeleteLink(); }; - return (
-
- Delete -
+
- ); -}; + ) +} export default DeleteChatLink; diff --git a/client/src/components/Messaging/UserStatusInfo.tsx b/client/src/components/Messaging/UserStatusInfo.tsx index c0809146..70821458 100644 --- a/client/src/components/Messaging/UserStatusInfo.tsx +++ b/client/src/components/Messaging/UserStatusInfo.tsx @@ -1,21 +1,56 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import styles from "./styles/UserStatusInfo.module.css"; import ThemeToggle from "../ThemeToggle/index"; import imageRetryIcon from "./assets/image-retry.png"; import DeleteChatLink from "../DeleteChatLink"; +import Button from "../Button"; +import { IChatE2EE } from "@chat-e2ee/service"; export const UserStatusInfo = ({ online, getSetUsers, channelID, - handleDeleteLink + handleDeleteLink, + chate2ee }: { online: any; getSetUsers: any; channelID: any; handleDeleteLink: any; + chate2ee: IChatE2EE }) => { + const [ call, setCall ] = useState(null); const [loading, setLoading] = useState(false); + const [ callState, setCallState ] = useState(undefined); + + useEffect(() => { + chate2ee.onCallAdded((call) => { + setCall(call); + }); + + chate2ee.onCallRemoved(() => { + setCall(null); + }); + + chate2ee.onPCStateChanged((state) => { + setCallState(state); + }); + }, [chate2ee]); + + const makeCall = async () => { + if(call) { + console.error('call is already active'); + return; + } + + const newCall = await chate2ee.startCall(); + setCall(newCall); + } + + const stopCall = async() => { + chate2ee.endCall(); + setCall(null); + } const fetchKeyAgain = async () => { if (loading) return; @@ -26,26 +61,54 @@ export const UserStatusInfo = ({ }; return ( -
- {online ? ( - - Alice {"<"}Online{">"} - - ) : ( -
- Waiting for Alice to join... - retry-icon -
- )} - - -
+ <> + { call && () } +
+ {online ? ( + + {"<"}Online{">"} + + ) : ( +
+ Waiting for Alice to join... + retry-icon +
+ )} + { + online && + } + + +
+ ); }; + + +const CallStatus = ({state}: {state:any}) => { + return( +
Call Status: {state}
+ ) +} + +const CallButton = ({ makeCall, stopCall, call }: { makeCall: any, stopCall: any, call: any }) => { + const callButtonHandler = () => { + if(call) { + stopCall(); + }else { + makeCall(); + } + } + return ( +
+
+ ) +} \ No newline at end of file diff --git a/client/src/components/Messaging/styles/UserStatusInfo.module.css b/client/src/components/Messaging/styles/UserStatusInfo.module.css index 1f92312d..49aacc5a 100644 --- a/client/src/components/Messaging/styles/UserStatusInfo.module.css +++ b/client/src/components/Messaging/styles/UserStatusInfo.module.css @@ -1,14 +1,24 @@ +.callStatusBar { + position: fixed; + background: #4caf50; + width: 100%; + left: 0; + padding: 2px; + font-size: 12px; + color: white; + bottom: 0; +} .userInfo { padding: 15px 0px 15px 0px; font-weight: 400; - display: grid; - grid-template-columns: 2fr auto auto; + display: flex; + flex-direction: row; grid-column-gap: 15px; - align-content: center; align-items: center; } .userInfoOnline { color: #4caf50; + flex: 1; } @media screen and (max-width: 640px) { @@ -29,6 +39,7 @@ .userOnlineWaiting { display: flex; align-items: center; + flex: 1; } .retryImageIcon { diff --git a/client/src/pages/messaging/index.tsx b/client/src/pages/messaging/index.tsx index 93cad566..7d1f633e 100644 --- a/client/src/pages/messaging/index.tsx +++ b/client/src/pages/messaging/index.tsx @@ -29,7 +29,6 @@ setConfig({ } const chate2ee = createChatInstance(); - type messageObj = { body?: string; image?: string; @@ -266,6 +265,7 @@ const Chat = () => { getSetUsers={getSetUsers} channelID={channelID} handleDeleteLink={handleDeleteLink} + chate2ee={chate2ee} />
diff --git a/service/package.json b/service/package.json index 86479929..c3566a5f 100644 --- a/service/package.json +++ b/service/package.json @@ -1,6 +1,6 @@ { "name": "@chat-e2ee/service", - "version": "1.5.0", + "version": "1.6.0", "description": "SDK to create realtime messaging with chat-e2ee", "main": "dist/bundle.js", "author": "Mukesh", @@ -14,10 +14,10 @@ }, "scripts": { "build": "webpack --mode production", - "watch": "concurrently \"webpack --watch\" \"npm run watch-dts\"", + "build:dev": "webpack", + "watch": "webpack --watch", "publish-sdk": "npm publish", - "test": "jest", - "watch-dts": "../node_modules/.bin/nodemon --watch src/public/types.ts --exec \"npm run dts\"" + "test": "jest" }, "dependencies": { "socket.io-client": "^4.6.1" diff --git a/service/src/crypto.test.ts b/service/src/crypto.test.ts index 54b38e2a..657a8cdb 100644 --- a/service/src/crypto.test.ts +++ b/service/src/crypto.test.ts @@ -1,4 +1,4 @@ -import { cryptoUtils } from "./crypto"; +import { cryptoUtils } from "./cryptoRSA"; describe('cryptoUtils', () => { const mockBase64String = 'ZW5jcnlwdGVkLXRleHQ='; // decoded = encrypted-text diff --git a/service/src/cryptoAES.ts b/service/src/cryptoAES.ts new file mode 100644 index 00000000..20a17095 --- /dev/null +++ b/service/src/cryptoAES.ts @@ -0,0 +1,86 @@ +/** + * Symmetric key encryption used for encrypting Audio/Video data + */ +export class AesGcmEncryption { + private aesKeyLocal?: CryptoKey; + private aesKeyRemote?: CryptoKey; + + public async int(): Promise { + if(this.aesKeyLocal) { + return this.aesKeyLocal; + } + const key = await window.crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ); + + this.aesKeyLocal = key; + return this.aesKeyLocal; + } + + public getRemoteAesKey(): CryptoKey { + if(!this.aesKeyRemote) { + throw new Error("AES key from remote not set."); + } + return this.aesKeyRemote; + } + + /** + * To Do: + * this key is plain text, can be used to decrypt data. + * Should not be transmitted over network. + * Use cryptoUtils to encrypt the key and exchange. + */ + public async getRawAesKeyToExport(): Promise { + if(!this.aesKeyLocal) { + throw new Error('AES key not generated'); + } + const jsonWebKey = await crypto.subtle.exportKey("jwk", this.aesKeyLocal); + return JSON.stringify(jsonWebKey); + } + + public async setRemoteAesKey(key: string): Promise { + const jsonWebKey = JSON.parse(key); + this.aesKeyRemote = await crypto.subtle.importKey( + "jwk", + jsonWebKey, + { name: "AES-GCM" }, + true, // Key is usable for decryption + ["decrypt"] // Usage options for the key + ); + + } + + public async encryptData(data: ArrayBuffer) { + // Generate an Initialization Vector (IV) for AES-GCM (12 bytes) + const iv = crypto.getRandomValues(new Uint8Array(12)); + // Encrypt the frame data using AES-GCM + const encryptedData = await crypto.subtle.encrypt( + { + name: "AES-GCM", + iv: iv + }, + this.aesKeyLocal, // Symmetric key for encryption + data // The frame data to be encrypted + ); + + + return { encryptedData: new Uint8Array(encryptedData) , iv }; + } + + public async decryptData(data: Uint8Array, iv: Uint8Array): Promise { + if(!this.aesKeyRemote) { + throw new Error('Remote AES key not set.') + } + return crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: iv + }, + this.aesKeyRemote, // Symmetric key for decryption + data // The encrypted frame data + ); + } + +} \ No newline at end of file diff --git a/service/src/crypto.ts b/service/src/cryptoRSA.ts similarity index 100% rename from service/src/crypto.ts rename to service/src/cryptoRSA.ts diff --git a/service/src/public/types.ts b/service/src/public/types.ts index 952440e3..a030a3e0 100644 --- a/service/src/public/types.ts +++ b/service/src/public/types.ts @@ -1,4 +1,7 @@ -export type SocketListenerType = "limit-reached" | "delivered" | "on-alice-join" | "on-alice-disconnect" | "chat-message"; +import { SocketListenerTypeInternal } from "../socket/socket"; +import { E2ECall } from "../webrtc"; + +export type SocketListenerType = Omit; export type LinkObjType = { hash: string, link: string, @@ -10,7 +13,7 @@ export type LinkObjType = { } export interface ISendMessageReturn { id: string, timestamp: string }; -export interface IGetPublicKeyReturn { publicKey: string}; +export interface IGetPublicKeyReturn { publicKey: string, aesKey: string}; export type TypeUsersInChannel = { "uuid":string }[]; export interface IChatE2EE { @@ -25,6 +28,13 @@ export interface IChatE2EE { dispose(): void; encrypt({ image, text }): { send: () => Promise }; on(listener: SocketListenerType, callback: (...args: any) => void): void; + // webrtc call + startCall(): Promise; + endCall(): void; + onPCStateChanged(cb: (state: RTCPeerConnectionState) => void) : void; + onCallAdded(cb: (call: E2ECall) => void): void, + onCallRemoved(cb: () => void): void + activeCall: E2ECall | null } export interface IUtils { @@ -41,8 +51,3 @@ export type configType = { } export type SetConfigType = (config: Partial) => void; -export declare const createChatInstance: () => IChatE2EE; -export declare const utils: IUtils; -export declare const setConfig: SetConfigType; - - diff --git a/service/src/publicKey.ts b/service/src/publicKey.ts index 53f197f8..eb8422e5 100644 --- a/service/src/publicKey.ts +++ b/service/src/publicKey.ts @@ -1,10 +1,11 @@ import makeRequest from './makeRequest'; import { IGetPublicKeyReturn } from './public/types'; -export const sharePublicKey = ({ publicKey, sender, channelId }) => { +export const sharePublicKey = ({ aesKey, publicKey, sender, channelId }) => { return makeRequest('chat/share-public-key', { method: 'POST', body: { + aesKey, publicKey, sender, channel: channelId @@ -13,7 +14,7 @@ export const sharePublicKey = ({ publicKey, sender, channelId }) => { }; export const getPublicKey = ({ userId, channelId }): Promise => { - return makeRequest(`chat/get-public-key/?userId=${userId}&channel=${channelId}`, { + return makeRequest(`chat/get-public-key/?userId=${userId}&channel=${channelId}&timeStamp=${Date.now()}`, { method: 'GET' }); }; diff --git a/service/src/sdk.ts b/service/src/sdk.ts index 6f93f23f..7f510375 100644 --- a/service/src/sdk.ts +++ b/service/src/sdk.ts @@ -1,5 +1,6 @@ +import { AesGcmEncryption } from './cryptoAES'; import { setConfig } from './configContext'; -import { cryptoUtils as _cryptoUtils } from './crypto'; +import { cryptoUtils as _cryptoUtils } from './cryptoRSA'; import deleteLink from './deleteLink'; import getLink from './getLink'; import getUsersInChannel from './getUsersInChannel'; @@ -10,6 +11,7 @@ import { SocketInstance, SubscriptionContextType } from './socket/socket'; import { Logger } from './utils/logger'; export { setConfig } from './configContext'; import { generateUUID } from './utils/uuid'; +import { WebRTCCall, E2ECall } from './webrtc'; export const utils = { decryptMessage: (ciphertext: string, privateKey: string) => _cryptoUtils.decryptMessage(ciphertext, privateKey), @@ -35,42 +37,104 @@ class ChatE2EE implements IChatE2EE { private privateKey?: string; private publicKey?: string; - + private receiverPublicKey?: string; private subscriptions = new Map(); private socket: SocketInstance; private subscriptionLogger = logger.createChild('Subscription'); + private callLogger = logger.createChild('Call'); private initialized = false; + private call?: WebRTCCall; + private onCallAddedHandler?: (call: E2ECall) => void; + private onCallRemovedHandler?: () => void; + private onPCStateChangedHandler?: (state: RTCPeerConnectionState) => void; + + private iceCandidates = []; + + private symEncryption = new AesGcmEncryption(); + private onPcConnectionChanged(state: RTCPeerConnectionState): void { + this.onPCStateChangedHandler(state) + if(state === 'failed' || state === 'closed') { + this.callLogger.log(`Ending call, RTCPeerConnectionState: ${state}`); + this.endCall(); + } + } constructor(config?: Partial) { config && setConfig(config); } public async init(): Promise { const initLogger = logger.createChild('Init'); + const evetLogger = logger.createChild('Events'); initLogger.log(`Started.`); this.createSocketSubcription(); const { privateKey, publicKey } = await _cryptoUtils.generateKeypairs(); + this.privateKey = privateKey; this.publicKey = publicKey; + this.on('on-alice-join', () => { - initLogger.log("Receiver connected."); + evetLogger.log("Receiver connected."); this.getPublicKey(initLogger); }) this.on("on-alice-disconnect", () => { - initLogger.log("Receiver disconnected"); + evetLogger.log("Receiver disconnected"); this.receiverPublicKey = null; }); + /** + * Related to webrtc connection, + * Move it to WebRTC class? + */ + this.on('webrtc-session-description', (data) => { + evetLogger.log("New session description"); + if(data.type === 'offer') { + evetLogger.log("New offer"); + this.call = this.getWebRtcCall(); + this.onCallAddedHandler?.(this.activeCall); + this.call.signal(data); + + // add ICE from buffer + this.iceCandidates.forEach((ice) => { + this.call.signal(ice); + }) + this.iceCandidates = []; + + }else if(data.type === 'answer') { + evetLogger.log("New answer"); + this.call.signal(data); + }else if(data.type === 'candidate') { + evetLogger.log('ICE Candidate received.'); + if(!this.call) { + evetLogger.log("call not created yet, storing ICE candidate"); + this.iceCandidates.push(data); + }else { + this.call.signal(data); + } + } + }); + + + initLogger.log(`Initializing AES Encryption for webrtc`); + await this.symEncryption.int(); + initLogger.log(`Initialized AES Encryption for webrtc`); initLogger.log(`Finished.`); this.initialized = true; } + public get activeCall(): E2ECall | null { + if(!this.call) { + return null; + } + return new E2ECall(this.call); + } + public async getLink(): Promise { logger.log('getLink()'); return getLink(); @@ -82,7 +146,10 @@ class ChatE2EE implements IChatE2EE { this.channelId = channelId; this.userId = userId; this.userName = userName; - await sharePublicKey({ publicKey: this.publicKey, sender: this.userId, channelId: this.channelId}); + + const aesPlain = await this.symEncryption.getRawAesKeyToExport(); + + await sharePublicKey({ aesKey: aesPlain, publicKey: this.publicKey, sender: this.userId, channelId: this.channelId}); this.socket.joinChat({ publicKey: this.publicKey, userID: this.userId, channelID: this.channelId}) await this.getPublicKey(logger); return; @@ -158,12 +225,45 @@ class ChatE2EE implements IChatE2EE { } } + public async startCall(): Promise { + if(!WebRTCCall.isSupported()) { + throw new Error('createEncodedStreams not supported.'); + } + if(this.call) { + throw new Error('Call already active'); + } + const call = new E2ECall(this.getWebRtcCall()); + await call.startCall(); + return call; + } + + public async endCall(): Promise { + this.call?.endCall(); + this.call = null; + this.onCallRemovedHandler?.(); + } + + public onCallAdded(cb: (call: E2ECall) => void): void { + this.onCallAddedHandler = cb + } + + public onCallRemoved(cb: () => void): void { + this.onCallRemovedHandler = cb + } + + public onPCStateChanged(cb: (state: RTCPeerConnectionState) => void) : void { + this.onPCStateChangedHandler = cb; + } + //get receiver public key private async getPublicKey(logger: Logger): Promise { logger.log(`getPublicKey()`); const receiverPublicKey = await getPublicKey({ userId: this.userId, channelId: this.channelId }); logger.log(`setPublicKey() - ${!!receiverPublicKey?.publicKey}`); this.receiverPublicKey = receiverPublicKey?.publicKey; + if(receiverPublicKey.aesKey) { + await this.symEncryption.setRemoteAesKey(receiverPublicKey.aesKey) + } return; } @@ -177,6 +277,18 @@ class ChatE2EE implements IChatE2EE { throw new Error('ChatE2EE is not initialized, call init()'); } } + + private getWebRtcCall(): WebRTCCall { + this.checkInitialized(); + this.call = new WebRTCCall( + this.onPcConnectionChanged.bind(this), + this.symEncryption, + this.userId, + this.channelId, + this.callLogger, + ); + return this.call; + } } export * from './public/types'; \ No newline at end of file diff --git a/service/src/socket/socket.ts b/service/src/socket/socket.ts index 343b46ee..433e1b23 100644 --- a/service/src/socket/socket.ts +++ b/service/src/socket/socket.ts @@ -2,17 +2,18 @@ import socketIOClient, { Socket } from 'socket.io-client'; import { Logger } from '../utils/logger'; import { chatJoinPayloadType } from '../sdk'; import { configContext } from '../configContext'; -import { SocketListenerType } from '../public/types'; +export type SocketListenerTypeInternal = "limit-reached" | "delivered" | "on-alice-join" | "on-alice-disconnect" | "chat-message" | "webrtc-session-description"; -export type SubscriptionType = Map void>>; +export type SubscriptionType = Map void>>; export type SubscriptionContextType = () => SubscriptionType; -const SOCKET_LISTENERS: Record = { +const SOCKET_LISTENERS: Record = { 'LIMIT_REACHED': "limit-reached", 'DELIVERED': "delivered", 'ON_ALICE_JOIN': "on-alice-join", 'ON_ALICE_DISCONNECT': "on-alice-disconnect", - 'CHAT_MESSAGE': "chat-message" + 'CHAT_MESSAGE': "chat-message", + "WEBRTC_SESSION_DESCRIPTION": "webrtc-session-description" } const getBaseURL = (): string => { @@ -35,6 +36,7 @@ export class SocketInstance { this.handler(SOCKET_LISTENERS.CHAT_MESSAGE, args); this.markDelivered(args[0]); }); + this.socket.on(SOCKET_LISTENERS.WEBRTC_SESSION_DESCRIPTION, (...args) => this.handler(SOCKET_LISTENERS.WEBRTC_SESSION_DESCRIPTION, args)) logger.log('Initiialized'); } @@ -49,7 +51,7 @@ export class SocketInstance { this.socket.disconnect(); } - private handler(listener: SocketListenerType, args) { + private handler(listener: SocketListenerTypeInternal, args) { const loggerWithCount = this.eventHandlerLogger.count(); loggerWithCount.log(`handler called for ${listener}`); const callbacks = this.subscriptionContext().get(listener); diff --git a/service/src/utils/logger.ts b/service/src/utils/logger.ts index c476fe99..465e3be8 100644 --- a/service/src/utils/logger.ts +++ b/service/src/utils/logger.ts @@ -22,7 +22,7 @@ export class Logger { return new Logger(`${this.name}`, [...this.childs, name]); } - public log(...args: any[]) { + public log(...args: any[]): void { if(this.disableLog) { // Logs are disabled and will not be printed // set disableLog: false in configContext to enable logs diff --git a/service/src/webrtc.ts b/service/src/webrtc.ts new file mode 100644 index 00000000..fd328b6d --- /dev/null +++ b/service/src/webrtc.ts @@ -0,0 +1,292 @@ +import { AesGcmEncryption } from "./cryptoAES"; +import { Logger } from "./utils/logger"; +import { webrtcSession } from "./webrtcSession"; + +interface SignalData { + type: RTCSdpType; + sdp: string; +} + +export class WebRTCCall { + private callStateChangeCallback?: (state: RTCPeerConnectionState) => void; + private peer: Peer; + + public static isSupported(): boolean { + return !!(RTCRtpSender.prototype as any).createEncodedStreams; + } + + constructor(onPcConnectionChanged: (state: RTCPeerConnectionState) => void, encryption: AesGcmEncryption, sender: string, channel: string, private logger: Logger) { + this.logger.log('Creating WebRTCCall'); + this.peer = new Peer( + onPcConnectionChanged, + encryption, + sender, + channel, + this.logger.createChild('Peer') + ); + } + + public get callState(): RTCPeerConnectionState { + return this.peer.callState; + } + + async startCall(): Promise { + this.logger.log('startCall'); + return this.peer.createAndSendOffer(); + } + + public endCall(): void { + this.logger.log('endCall'); + this.peer?.dispose(); + } + + public signal(data: SignalData): void { + this.logger.log('handling signal data'); + if(!this.peer) { + throw new Error('No peer connection'); + } + this.peer.signal(data); + } +} + +class Peer { + private state: RTCPeerConnectionState; + private pc: RTCPeerConnection; + + private audioEl?: HTMLAudioElement; + private audioStream?: MediaStream; + + private localStreamAcquisatonPromise?: Promise + constructor( + private onPcConnectionChanged: (state: RTCPeerConnectionState) => void, + private encryption: AesGcmEncryption, + private sender: string, + private channel: string, + private logger: Logger + ) { + this.pc = new (RTCPeerConnection as any)({ + encodedInsertableStreams: true, + iceServers: [ + { urls: "stun:stun.l.google.com:19302" }, + { urls: "stun:stun.l.google.com:5349" }, + { urls: "stun:stun1.l.google.com:3478" }, + { urls: "stun:stun1.l.google.com:5349" }, + { urls: "stun:stun2.l.google.com:19302" }, + { urls: "stun:stun2.l.google.com:5349" }, + { urls: "stun:stun3.l.google.com:3478" }, + { urls: "stun:stun3.l.google.com:5349" }, + { urls: "stun:stun4.l.google.com:19302" }, + { urls: "stun:stun4.l.google.com:5349" } + ] + }); + + this.pc.onconnectionstatechange = () => { + this.logger.log('Peer Connection State: ', this.pc.connectionState); + this.state = this.pc.connectionState; + this.onPcConnectionChanged(this.state); + }; + + this.pc.onicecandidate = (event) => { + if (event.candidate) { + this.logger.log('ICE Candidate (Caller) gathered.'); + webrtcSession({ + description: { + candidate: event.candidate, + type: 'candidate' + }, + sender: this.sender, + channelId: this.channel + }); + } + }; + + this.pc.ontrack = (event) => { + event.streams[0].getAudioTracks().forEach(() => { + this.logger.log('Adding remote audio track'); + this.applyDecryption('audio', event.receiver); + this.appendAudioStreamToDom(event.streams[0], 'remote'); + }) + }; + + this.state = this.pc.connectionState; + this.localStreamAcquisatonPromise = this.addLocalAudioTracks(); + } + + public get callState(): RTCPeerConnectionState { + return this.state; + } + + public async createAndSendOffer() { + await this.localStreamAcquisatonPromise; + this.logger.log('createAndSendOffer'); + // await this.addLocalAudioTracks(); + const offer = await this.pc.createOffer(); + await this.pc.setLocalDescription(offer); + await webrtcSession({ + description: offer, + sender: this.sender, + channelId: this.channel + }); + + } + + + public async signal(data: SignalData) { + if (data.type === 'offer') { + await this.localStreamAcquisatonPromise; + this.logger.log('Signal, offer'); + await this.pc.setRemoteDescription(new RTCSessionDescription(data)); + const answer = await this.pc.createAnswer(); + await this.pc.setLocalDescription(answer); + await webrtcSession({ + description: answer, + sender: this.sender, + channelId: this.channel + }); + } else if (data.type === 'answer') { + this.logger.log('Signal, answer'); + await this.pc.setRemoteDescription(new RTCSessionDescription(data)); + } else if ((data as any).type === 'candidate') { + this.logger.log('Signal, candidate'); + const candidate = new RTCIceCandidate((data as any).candidate); + this.pc.addIceCandidate(candidate).catch(e => console.error('Error adding ICE candidate:', e)); + } + } + + public dispose(): void { + if(this.audioStream) { + this.audioStream.getTracks().forEach(track => { + track.stop() ; + }); + this.audioStream = null; + } + if(this.audioEl) { + this.audioEl.srcObject = null; + this.audioEl = null; + } + this.logger.log('Dispose'); + this.pc.close(); + } + + private async addLocalAudioTracks(): Promise { + this.logger.log('addLocalAudioTracks, adding local track to Peer Connection'); + this.audioStream = await this.getAudioStream(); + this.audioStream.getTracks().forEach(track => this.pc.addTrack(track, this.audioStream)); + this.applyEncryption('audio'); + } + + /* + private async addLocalVideoTracks(): Promise { + this.logger.log('addLocalTracks'); + // const stream = await this.getAudioStream(); + const stream = await this.getVideoStream(); + this.appenVideoStreamToDom(stream, 'local'); + stream.getTracks().forEach(track => this.pc.addTrack(track, stream)); + this.applyEncryption('video'); + } + */ + + private async getAudioStream(): Promise { + this.logger.log('getAudioStream'); + return navigator.mediaDevices.getUserMedia({ audio: true, video: false }); + } + + private async getVideoStream(): Promise { + this.logger.log('getAudioStream'); + return navigator.mediaDevices.getUserMedia({ video: true, audio: false }); + } + + private async appendAudioStreamToDom(stream: MediaStream, tag: string): Promise { + this.logger.log('Adding remote audio track'); + this.audioEl = document.createElement('audio'); + this.audioEl.setAttribute('autoplay', 'true'); + this.audioEl.setAttribute('id', tag); + this.audioEl.srcObject = stream; + + try { + await this.audioEl.play(); + }catch(err) { + this.logger.log(err); + this.audioEl.setAttribute('controls', 'true'); + setTimeout(() => { + this.logger.log('Scheduling delay play'); + this.audioEl.play(); + }, 1000) + } + document.body.appendChild(this.audioEl); + } + + private appenVideoStreamToDom(stream: MediaStream, tag: string): void { + this.logger.log('Adding remote video track'); + const videoEl = document.createElement('video'); + document.body.appendChild(videoEl); + videoEl.setAttribute('controls', 'true'); + videoEl.setAttribute('autoplay', 'true'); + videoEl.setAttribute('id', tag); + videoEl.srcObject = stream + } + + private applyDecryption(mediaType: 'audio' | 'video', receiver: RTCRtpReceiver): void { + const transformer = new TransformStream({ + transform: async (chunk: RTCEncodedAudioFrame, controller) => { + + try { + const data = new Uint8Array(chunk.data); + const iv = data.slice(0, 12); // Assuming 12-byte IV + const encryptedData = data.slice(12); + + const decryptedData = await this.encryption.decryptData(encryptedData, iv); + chunk.data = decryptedData; + controller.enqueue(chunk); + } catch (error) { + this.logger.log('Decryption error:', error); + } + } + }); + + const receiverStreams = (receiver as any).createEncodedStreams(); + receiverStreams.readable + .pipeThrough(transformer) + .pipeTo(receiverStreams.writable); + } + + private applyEncryption( mediaType: 'audio' | 'video'): void { + const sender = this.pc.getSenders().find(r => r.track.kind === mediaType); + + const transformer = new TransformStream({ + transform: async (chunk: RTCEncodedAudioFrame, controller) => { + try { + const { encryptedData, iv } = await this.encryption.encryptData(chunk.data); + + const combinedData = new Uint8Array(iv.length + encryptedData.byteLength); + combinedData.set(iv, 0); + combinedData.set(encryptedData, iv.length); + + chunk.data = combinedData.buffer; + controller.enqueue(chunk); + } catch (error) { + this.logger.log('Encryption error:', error); + } + } + }); + + const senderStreams = (sender as any).createEncodedStreams(); + senderStreams.readable + .pipeThrough(transformer) + .pipeTo(senderStreams.writable); + } +} + +// Public facing class +export class E2ECall { + constructor(private readonly webRtcCall: WebRTCCall) {} + public get state(): RTCPeerConnectionState { + return this.webRtcCall.callState; + } + public async startCall(): Promise { + return this.webRtcCall.startCall(); + } + public async endCall(): Promise { + return this.webRtcCall.endCall(); + } +} \ No newline at end of file diff --git a/service/src/webrtcSession.ts b/service/src/webrtcSession.ts new file mode 100644 index 00000000..11414136 --- /dev/null +++ b/service/src/webrtcSession.ts @@ -0,0 +1,12 @@ +import makeRequest from "./makeRequest"; + +export const webrtcSession = ({ description, sender, channelId }) => { + return makeRequest('session', { + method: 'POST', + body: { + description, + sender, + channel: channelId + } + }); + }; \ No newline at end of file