From b7149019131949adf678cc02c35d03f81e7a6bb5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:25:53 +0000 Subject: [PATCH 1/4] Redesign encryption: invite-secret HKDF envelopes, socket-bound relay, remove RSA/PIN/encoded-transforms Co-authored-by: muke1908 <20297989+muke1908@users.noreply.github.com> --- README.md | 21 +- backend/README.md | 44 ++- backend/api/call/session.ts | 44 --- backend/api/chatHash/index.ts | 31 +- backend/api/chatHash/utils/link.test.ts | 10 +- backend/api/chatHash/utils/link.ts | 14 +- backend/api/chatHash/utils/pin.test.js | 24 -- backend/api/chatHash/utils/pin.ts | 30 -- backend/api/index.ts | 4 +- backend/api/messaging/index.ts | 93 +---- backend/api/messaging/types.ts | 15 - backend/db/const.ts | 1 - backend/socket.io/index.ts | 24 +- backend/socket.io/listeners.ts | 125 +++++- backend/socket.io/rateLimiter.test.ts | 66 ++++ backend/socket.io/rateLimiter.ts | 51 +++ client/README.md | 6 +- client/app.ts | 339 ----------------- client/src/App.tsx | 8 +- .../components/ChatContainer/ChatHeader.tsx | 2 +- .../SetupOverlay/CreateHashView.tsx | 22 +- .../SetupOverlay/InitialActions.tsx | 8 +- .../components/SetupOverlay/JoinHashView.tsx | 37 +- .../components/SetupOverlay/SetupOverlay.tsx | 58 +-- client/src/components/common/Button.tsx | 2 + client/src/components/common/Input.tsx | 2 + client/src/context/ChatContext.tsx | 42 +- client/src/hooks/useUrlHash.ts | 26 +- client/src/types/index.ts | 18 +- client/src/utils/urlHash.ts | 65 +++- e2e/join-session.spec.ts | 99 +++-- jest.config.js | 4 + service/README.md | 211 ++--------- service/build.js | 42 +- service/jest.config.js | 6 +- service/src/api/links.ts | 35 +- service/src/api/messages.ts | 17 +- service/src/api/publicKey.ts | 24 -- service/src/api/webrtcSession.ts | 23 -- service/src/crypto/base64url.ts | 33 ++ service/src/crypto/crypto.test.ts | 358 ------------------ service/src/crypto/cryptoAES.ts | 89 ----- service/src/crypto/cryptoRSA.ts | 123 ------ service/src/crypto/encryptionFactory.ts | 93 ----- service/src/crypto/inviteCrypto.test.ts | 120 ++++++ service/src/crypto/inviteCrypto.ts | 65 ++++ service/src/crypto/secureEnvelope.test.ts | 87 +++++ service/src/crypto/secureEnvelope.ts | 88 +++++ service/src/keyExchange/keyExchangeManager.ts | 84 ---- service/src/public/types.ts | 37 +- service/src/sdk.test.ts | 296 +++++++++------ service/src/sdk.ts | 249 ++++++------ service/src/socket/socket.test.ts | 101 +++-- service/src/socket/socket.ts | 100 +++-- service/src/utils/replayGuard.test.ts | 52 +++ service/src/utils/replayGuard.ts | 35 ++ .../encodedTransformWorkerFactory.ts | 7 - service/src/webrtc/encodedTransform.test.ts | 184 --------- service/src/webrtc/encodedTransform.ts | 145 ------- service/src/webrtc/encodedTransform.worker.ts | 82 ---- .../webrtc/encodedTransformWorkerFactory.ts | 16 - service/src/webrtc/frameCodec.test.ts | 121 ------ service/src/webrtc/frameCodec.ts | 46 --- service/src/webrtc/frameData.ts | 16 - service/src/webrtc/peer.test.ts | 111 ++---- service/src/webrtc/peer.ts | 73 +--- service/src/webrtc/types.ts | 12 - service/src/webrtc/webrtcCall.test.ts | 10 +- service/src/webrtc/webrtcCall.ts | 19 +- 69 files changed, 1626 insertions(+), 2919 deletions(-) delete mode 100644 backend/api/call/session.ts delete mode 100644 backend/api/chatHash/utils/pin.test.js delete mode 100644 backend/api/chatHash/utils/pin.ts create mode 100644 backend/socket.io/rateLimiter.test.ts create mode 100644 backend/socket.io/rateLimiter.ts delete mode 100644 client/app.ts delete mode 100644 service/src/api/publicKey.ts delete mode 100644 service/src/api/webrtcSession.ts create mode 100644 service/src/crypto/base64url.ts delete mode 100644 service/src/crypto/crypto.test.ts delete mode 100644 service/src/crypto/cryptoAES.ts delete mode 100644 service/src/crypto/cryptoRSA.ts delete mode 100644 service/src/crypto/encryptionFactory.ts create mode 100644 service/src/crypto/inviteCrypto.test.ts create mode 100644 service/src/crypto/inviteCrypto.ts create mode 100644 service/src/crypto/secureEnvelope.test.ts create mode 100644 service/src/crypto/secureEnvelope.ts delete mode 100644 service/src/keyExchange/keyExchangeManager.ts create mode 100644 service/src/utils/replayGuard.test.ts create mode 100644 service/src/utils/replayGuard.ts delete mode 100644 service/src/webrtc/__mocks__/encodedTransformWorkerFactory.ts delete mode 100644 service/src/webrtc/encodedTransform.test.ts delete mode 100644 service/src/webrtc/encodedTransform.ts delete mode 100644 service/src/webrtc/encodedTransform.worker.ts delete mode 100644 service/src/webrtc/encodedTransformWorkerFactory.ts delete mode 100644 service/src/webrtc/frameCodec.test.ts delete mode 100644 service/src/webrtc/frameCodec.ts delete mode 100644 service/src/webrtc/frameData.ts diff --git a/README.md b/README.md index 9967f12e..cfa9c80e 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,7 @@ Demo: https://chat-e2ee-2.azurewebsites.net ## Features 1. :negative_squared_cross_mark: No login/signup - the end users **don't identify** themselves. -2. :closed_lock_with_key: End-to-end encrypted Audio-Call (Experimental - added on [19th September, 2024](https://github.com/muke1908/chat-e2ee/commit/efae545c4c378dd7cae3c133843c1d58fded8a56)). -:warning: Note that Audio encryption in webrtc call is done diffrently, please refer [Wiki](https://github.com/muke1908/chat-e2ee/wiki/End%E2%80%90to%E2%80%90end-encryption-in-Webrtc-audio-call). It internally uses RTCRtpSender API: `createEncodedStreams` that has [limited Support](https://caniuse.com/mdn-api_rtcrtpsender_createencodedstreams) +2. :closed_lock_with_key: Audio calls, signaled over an end-to-end encrypted channel (invite-derived AES-GCM key + HKDF-SHA256). Media itself relies on WebRTC's standard mandatory DTLS-SRTP transport encryption — there is no custom per-frame encryption layer or encoded-transform capability gate any more, so calls work in any standards-compliant WebRTC browser. 4. :no_entry_sign: Data is **not** stored on any remote server, encrypted data is just relayed to other users, the data can't be decrypted by any man in the middle. **No history** i.e. once chat is closed the data is not recoverable, however encrypted data can be found on memory trace. [Read More](https://github.com/muke1908/chat-e2ee/wiki/How-and-when-your-data-can-be-compromised%3F) ## :star: JS SDK @@ -34,22 +33,20 @@ For installation instruction, go to [developer section](https://github.com/muke1 ### How to initiate chat -1. Generate a unique link. -2. Share the link or PIN with the person you want to chat with. +1. Generate a unique invitation link. +2. Share the link with the person you want to chat with. 3. Start chatting. -4. The messages are end-to-end encrypted; therefore, no one can decrypt your message other than you. +4. Messages and WebRTC call signaling are end-to-end encrypted; no one but the two participants can decrypt them. **How the encryption works** -1. Alice and Bob generate a public and private key pair. -2. Alice and Bob share their public keys with each other. -3. Alice encrypts her message with Bob's public key and sends it to Bob. -4. Bob receives the encrypted message and decrypts it with his private key. +1. The device creating the room generates a 256-bit secret locally and never sends it anywhere. It is only carried in the invitation link's URL fragment — `#room=&secret=` — which browsers never transmit as part of an HTTP request. +2. Both participants derive the same pair of AES-256-GCM keys from that shared secret via HKDF-SHA256: one key for chat messages, one for WebRTC signaling (offer/answer/ICE candidates), so a compromise of one cannot be used to attack the other. +3. Every message/signal is sealed into a versioned, room-bound AEAD envelope before it ever reaches the server. The server relays this opaque envelope between the two sockets in the room — it cannot read, modify, or replay it into another room without the receiver rejecting it outright (there is no plaintext fallback). -In this way, no one else can decrypt the message because your private key is never exposed/shared to the internet. -More detailed explanation: https://www.youtube.com/watch?v=GSIDS_lvRv4&t=1s +In this way, no one else can decrypt anything because the secret is never exposed to, or stored by, the server. -> We are using browser [window.crypto library](https://developer.mozilla.org/en-US/docs/Web/API/crypto_property) for encryption. +> We are using the browser [window.crypto library](https://developer.mozilla.org/en-US/docs/Web/API/crypto_property) (AES-GCM + HKDF-SHA256) for encryption. --- diff --git a/backend/README.md b/backend/README.md index 6464028b..89229e48 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,11 +1,43 @@ ### APIs ```endpoint: /api/``` -| url | method | payload | filename | description | -| -------------------------------- | -------- | ------------------------------ | ------------------------- | --------------------------------------------- | -| `/chat-link` | `POST` | `{token}` | `/api/index.js` | to generate unique link to start chat session | -| `/chat-link/status/:channel` | `GET` | | `/api/index.js` | to check if a channel is valid | -| `/chat/message` | `POST` | `{ channel, sender, message }` | `/api/messaging/index.js` | to send a message to a specific channel | -| `/chat-link/:channel` | `DELETE` | | `/api/index.js` | to delete a channel | +| url | method | payload | filename | description | +| -------------------------------- | -------- | ------------------------------- | -------------------------------- | --------------------------------------------- | +| `/chat-link` | `POST` | | `/api/chatHash/index.ts` | generate a new public room id (no PIN) | +| `/chat-link/status/:channel` | `GET` | | `/api/chatHash/index.ts` | check if a channel is valid | +| `/chat-link/:channel` | `DELETE` | | `/api/chatHash/index.ts` | delete a channel | +| `/chat/get-users-in-channel` | `GET` | | `/api/messaging/index.ts` | list users currently present in a channel | + +--- + +### Socket.io events + +Chat messages and WebRTC signaling are **not** sent over REST any more — they +are relayed over the socket connection established at `chat-join`, using the +identity (`userID`/`channelID`) bound to that socket, never a client-supplied +`sender`/`channel` field. Every payload the server relays for these two +events is an **opaque, versioned AEAD envelope** (`{ v, room, iv, ct }`); the +server never decrypts or inspects its contents. + +| event (client → server) | payload | ack | description | +| ------------------------ | ----------------------- | --------------------------------------- | --------------------------------------------------- | +| `chat-join` | `{ userID, channelID }` | — | join a room (max 2 participants); no key material | +| `chat-message` | `{ envelope }` | `{ id, timestamp }` or `{ error }` | relay an opaque chat envelope to the other peer | +| `webrtc-signal` | `{ envelope }` | `{ status: 'ok' }` or `{ error }` | relay an opaque WebRTC signaling envelope | +| `received` | `{ id }` | — | acknowledge delivery of a chat message | + +| event (server → client) | payload | description | +| ---------------------------------- | -------------------------------------------------- | ----------------------------------------- | +| `on-alice-join` | `null` | the other participant joined | +| `on-alice-disconnect` | `null` | the other participant disconnected | +| `chat-message` | `{ id, timestamp, sender, envelope }` | an incoming chat envelope | +| `webrtc-session-description` | `{ envelope }` | an incoming WebRTC signaling envelope | +| `delivered` | `id` | your message was delivered | +| `limit-reached` | `null` | the room already has 2 participants | + +Both `chat-message` and `webrtc-signal` are rate-limited per socket (token +bucket) and size-checked (rejecting oversized payloads) before being +relayed; `initSocket()` also caps the transport-level packet size via +Socket.IO's `maxHttpBufferSize`. --- diff --git a/backend/api/call/session.ts b/backend/api/call/session.ts deleted file mode 100644 index d49ac2a7..00000000 --- a/backend/api/call/session.ts +++ /dev/null @@ -1,44 +0,0 @@ -import express, { Request, Response } from 'express'; -import asyncHandler from '../../middleware/asyncHandler'; -import { WebrtcSessionResponse } from '../messaging/types'; -import channelValid from '../chatHash/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, signal, sender, channel } = req.body; - const sessionSignal = signal || description; - - if (!sessionSignal) { - 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, sessionSignal); - return res.send({ status: "ok" }); - }) - ); - - export default router; \ No newline at end of file diff --git a/backend/api/chatHash/index.ts b/backend/api/chatHash/index.ts index 4f541d9a..7bd4dddf 100644 --- a/backend/api/chatHash/index.ts +++ b/backend/api/chatHash/index.ts @@ -9,43 +9,14 @@ import generateHash from './utils/link'; const router = express.Router({ mergeParams: true }); -const generateUniqueHash = async (): Promise => { - const link = generateHash(); - - // This ensures, PINs won't clash each other - // Best case loop is not even executed - // worst case, loop can take 2 or more iterations - const pinExists = await db.findOneFromDB({ pin: link.pin }, LINK_COLLECTION); - if (pinExists) { - return generateUniqueHash(); - } - return link; -}; - router.post( "/", asyncHandler(async (req, res) => { - const link = await generateUniqueHash(); + const link = generateHash(); await db.insertInDb(link, LINK_COLLECTION); return res.send(link); }) ); -router.get( - "/:pin", - asyncHandler(async (req, res) => { - const { pin } = req.params; - if (!pin) { - return res.sendStatus(404).send("Invalid pin"); - } - const link = await db.findOneFromDB({ pin: pin.toUpperCase() }, LINK_COLLECTION); - const currentTime = new Date().getTime(); - const invalidLink = !link || currentTime - link.pinCreatedAt > 30 * 60 * 1000; - if (invalidLink) { - return res.sendStatus(404).send("Invalid pin"); - } - return res.send(link); - }) -); router.get( "/status/:channel", asyncHandler(async (req, res) => { diff --git a/backend/api/chatHash/utils/link.test.ts b/backend/api/chatHash/utils/link.test.ts index 621c4fc2..1a82f530 100644 --- a/backend/api/chatHash/utils/link.test.ts +++ b/backend/api/chatHash/utils/link.test.ts @@ -1,26 +1,20 @@ import { v4 } from 'uuid'; import generateLink from './link'; -import { generatePIN } from './pin'; jest.mock('uuid', () => ({ v4: jest.fn().mockReturnValue('hash'), })); -jest.mock('./pin', () => ({ - generatePIN: jest.fn().mockReturnValue('1234'), -})); - test('chat link generation', () => { const generatedLink = generateLink(); expect(generatedLink).toMatchObject({ hash: 'hash', expired: false, deleted: false, - pin: '1234', - pinCreatedAt: expect.any(Number), }); + expect(generatedLink).not.toHaveProperty('pin'); + expect(generatedLink).not.toHaveProperty('pinCreatedAt'); - expect(generatePIN).toBeCalledTimes(1); expect(v4).toBeCalledTimes(1); }); diff --git a/backend/api/chatHash/utils/link.ts b/backend/api/chatHash/utils/link.ts index a85a1048..2ab65414 100644 --- a/backend/api/chatHash/utils/link.ts +++ b/backend/api/chatHash/utils/link.ts @@ -1,17 +1,21 @@ import { v4 as uuidv4 } from 'uuid'; -import { generatePIN } from './pin'; const { CHAT_LINK_DOMAIN } = process.env; -const PIN_LENGTH = 4; export type LinkType = { hash: string, expired: boolean, deleted: boolean, - pin: string, - pinCreatedAt: number, } +/** + * Generates a new public room id. + * + * There is no PIN any more: joining requires the invitation fragment + * (`#room=&secret=<...>`), which carries a 256-bit secret generated + * entirely on the client and never sent to this server. A short, guessable + * PIN would have defeated that guarantee. + */ const generateHash = (): LinkType => { const hash = uuidv4(); @@ -24,8 +28,6 @@ const generateHash = (): LinkType => { hash, expired: false, deleted: false, - pin: generatePIN(hash, PIN_LENGTH), - pinCreatedAt: new Date().getTime() }; }; diff --git a/backend/api/chatHash/utils/pin.test.js b/backend/api/chatHash/utils/pin.test.js deleted file mode 100644 index d13627cc..00000000 --- a/backend/api/chatHash/utils/pin.test.js +++ /dev/null @@ -1,24 +0,0 @@ -import { generatePIN } from './pin'; - -describe('generatePIN', () => { - it('should generate a PIN of length 4 given a UUID', () => { - const uuid = 'e2ee1234-abcd-5678-efgh-9012ijkl3456'; - const pin = generatePIN(uuid); - expect(pin.length).toBe(4); - }); - - it('should generate a PIN of specified length given a UUID and pinLength', () => { - const uuid = 'e2ee1234-abcd-5678-efgh-9012ijkl3456'; - const pinLength = 6; - const pin = generatePIN(uuid, pinLength); - expect(pin.length).toBe(pinLength); - }); - - it('should generate unique PINs for different UUIDs', () => { - const uuid1 = 'e2ee1234-abcd-5678-efgh-9012ijkl3456'; - const uuid2 = 'e2ee5678-mnop-9012-qrst-1234uvwx5678'; - const pin1 = generatePIN(uuid1); - const pin2 = generatePIN(uuid2); - expect(pin1).not.toBe(pin2); - }); -}); diff --git a/backend/api/chatHash/utils/pin.ts b/backend/api/chatHash/utils/pin.ts deleted file mode 100644 index cfdb9937..00000000 --- a/backend/api/chatHash/utils/pin.ts +++ /dev/null @@ -1,30 +0,0 @@ -import crypto from 'crypto'; - -const base36map = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; - -export const generatePIN = (uuid: string, pinLength = 4): string => { - /* - This function generates a unique PIN given the UUID. The parameters are: - uuid => A string which can be uuid (the chat-hash in this case) - pinLength => length of the unique PIN to generate, default is 4 - */ - - //generate MD5 hash in hex representation - const md5HashInt = parseInt(crypto.createHash("sha256").update(uuid).digest("hex"), 16); - - //get mod 36 values - const rems = []; - let n = md5HashInt; - while (n > 0) { - rems.push(n % 36); - n = Math.floor(n / 36); - } - - //randomly map K indexes to characters in base36map - const randomChars = []; - for (let i = 0; i < pinLength; i++) { - randomChars.push(base36map[rems[crypto.randomInt(0, 37)]]); - } - - return randomChars.join(""); -}; diff --git a/backend/api/index.ts b/backend/api/index.ts index 057d53db..e5b3b3e8 100644 --- a/backend/api/index.ts +++ b/backend/api/index.ts @@ -2,7 +2,6 @@ import express, { Request, Response } from 'express'; import chatHashController from './chatHash'; import chatController from './messaging'; -import sessionController from './call/session'; const router = express.Router({ mergeParams: true }); @@ -12,6 +11,5 @@ router.get("/", async (req: Request, res: Response) => { router.use("/chat", chatController); router.use("/chat-link", chatHashController); -router.use("/session", sessionController); -export default router; \ No newline at end of file +export default router; diff --git a/backend/api/messaging/index.ts b/backend/api/messaging/index.ts index 25ffb67a..ff97a68b 100644 --- a/backend/api/messaging/index.ts +++ b/backend/api/messaging/index.ts @@ -1,104 +1,13 @@ import express, { Request, Response } from 'express'; -import db from '../../db'; -import { PUBLIC_KEY_COLLECTION } from '../../db/const'; import asyncHandler from '../../middleware/asyncHandler'; -import { SOCKET_TOPIC, socketEmit } from '../../socket.io'; import getClientInstance from '../../socket.io/clients'; import channelValid from '../chatHash/utils/validateChannel'; -import { - ChatMessageType, GetPublicKeyResponse, MessageResponse, SharePublicKeyResponse, UsersInChannelResponse -} from './types'; +import { UsersInChannelResponse } from './types'; const router = express.Router({ mergeParams: true }); const clients = getClientInstance(); -router.post( - "/message", - asyncHandler(async (req: Request, res: Response): Promise> => { - const { message, sender, channel, image } = req.body; - - if (!message) { - 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; - } - - const id = new Date().valueOf(); - const timestamp = new Date().valueOf(); - const dataToPublish: ChatMessageType = { - channel, - sender, - message, - id, - timestamp - }; - - if (image) { - return res.status(400).send({ message: "Image not supported" }); - } - const receiverSid = clients.getSIDByIDs(receiver, channel).sid; - socketEmit(SOCKET_TOPIC.CHAT_MESSAGE, receiverSid, dataToPublish); - return res.send({ message: "message sent", id, timestamp }); - }) -); - -router.post( - "/share-public-key", - asyncHandler(async (req: Request, res: Response): Promise> => { - const { aesKey, publicKey, sender, channel } = req.body; - - const { valid } = await channelValid(channel); - if (!valid) { - return res.sendStatus(404); - } - const existing = await db.findOneFromDB<{ aesKey: string | null }>({ channel, user: sender }, PUBLIC_KEY_COLLECTION); - if (existing) { - if (existing.aesKey) { - return res.status(409).send({ error: "Key already registered for this session" }); - } - // First call registered publicKey with aesKey: null; this call adds the encrypted AES key - await db.updateOneFromDb({ channel, user: sender }, { aesKey }, PUBLIC_KEY_COLLECTION); - return res.send({ status: "ok" }); - } - await db.insertInDb({ aesKey, publicKey, user: sender, channel }, PUBLIC_KEY_COLLECTION); - return res.send({ status: "ok" }); - }) -); - -router.get( - "/get-public-key", - asyncHandler(async (req: Request, res: Response): Promise> => { - const { userId, channel } = req.query; - - const { valid } = await channelValid(channel as string); - - if (!valid) { - return res.sendStatus(404); - } - 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 || { - publicKey: null, - aesKey: null - }); - }) -); - router.get( "/get-users-in-channel", asyncHandler(async (req: Request, res: Response): Promise> => { diff --git a/backend/api/messaging/types.ts b/backend/api/messaging/types.ts index 7b427f01..c37f7595 100644 --- a/backend/api/messaging/types.ts +++ b/backend/api/messaging/types.ts @@ -1,17 +1,2 @@ // 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 }[] - - -// socket.emit -export type ChatMessageType = { - channel: string, - sender: string, - message: string, - id: number, - timestamp: number, - image?: string -} \ No newline at end of file diff --git a/backend/db/const.ts b/backend/db/const.ts index 36c059a1..a6c5a537 100644 --- a/backend/db/const.ts +++ b/backend/db/const.ts @@ -1,2 +1 @@ export const LINK_COLLECTION = 'links'; -export const PUBLIC_KEY_COLLECTION = 'public_keys'; diff --git a/backend/socket.io/index.ts b/backend/socket.io/index.ts index b419ddf8..9e08c596 100644 --- a/backend/socket.io/index.ts +++ b/backend/socket.io/index.ts @@ -1,5 +1,4 @@ import { Server, Socket } from "socket.io"; -import { ChatMessageType } from "../api/messaging/types"; import connectionListener from "./listeners"; export interface CustomSocket extends Socket { @@ -7,6 +6,9 @@ export interface CustomSocket extends Socket { channelID: string } +/** Opaque, versioned AEAD envelope — the server never inspects its contents. */ +export type WireEnvelope = { v: number, room: string, iv: string, ct: string }; + let io: Server = null; export enum SOCKET_TOPIC { CHAT_MESSAGE = 'chat-message', @@ -19,24 +21,32 @@ export enum SOCKET_TOPIC { } type emitDataTypes = { - [SOCKET_TOPIC.CHAT_MESSAGE]: ChatMessageType, + // `sender`/`id`/`timestamp` are assigned by the server from the + // authenticated socket, never taken from client input. `envelope` is + // opaque — the server relays it verbatim. + [SOCKET_TOPIC.CHAT_MESSAGE]: { id: number, timestamp: number, sender: string, envelope: WireEnvelope }, [SOCKET_TOPIC.LIMIT_REACHED]: null, - [SOCKET_TOPIC.DELIVERED]: string, + [SOCKET_TOPIC.DELIVERED]: string | number, [SOCKET_TOPIC.ON_ALICE_DISCONNECTED]: null, - [SOCKET_TOPIC.ON_ALICE_JOIN]: { - publicKey: string - }, + // No key material is exchanged any more — this is purely a presence signal. + [SOCKET_TOPIC.ON_ALICE_JOIN]: null, [SOCKET_TOPIC.MESSAGE]: string, + [SOCKET_TOPIC.WEBRTC_SESSION_DESCRIPTION]: { envelope: WireEnvelope }, [key: string]: unknown, } +/** Bounds the size of any single socket.io packet at the transport level, ahead of any application-level checks. */ +const MAX_HTTP_BUFFER_SIZE = 64 * 1024; + export const initSocket = (server) => { if (io) { return io; } io = new Server(server, { - allowEIO3: true, cors: { + allowEIO3: true, + maxHttpBufferSize: MAX_HTTP_BUFFER_SIZE, + cors: { origin: "*", credentials: true } diff --git a/backend/socket.io/listeners.ts b/backend/socket.io/listeners.ts index 2cbfe8d2..7990fb1d 100644 --- a/backend/socket.io/listeners.ts +++ b/backend/socket.io/listeners.ts @@ -1,11 +1,48 @@ import getClientInstance from "./clients"; import channelValid from "../api/chatHash/utils/validateChannel"; -import { socketEmit, SOCKET_TOPIC , CustomSocket} from "./index"; +import { socketEmit, SOCKET_TOPIC, CustomSocket, WireEnvelope } from "./index"; +import { RateLimiter } from "./rateLimiter"; const clients = getClientInstance(); + +/** Generous enough for SDP/ICE candidates and chat text, but bounds abusive payloads. */ +const MAX_ENVELOPE_BYTES = 32 * 1024; +/** Burst of 40 messages, refilling at 10/s — plenty for normal signaling/chat traffic. */ +const rateLimiter = new RateLimiter({ capacity: 40, refillPerSecond: 10 }); + +type Ack = (response: Record) => void; +const noop: Ack = () => undefined; + +const isPayloadTooLarge = (payload: unknown): boolean => { + try { + return Buffer.byteLength(JSON.stringify(payload ?? {})) > MAX_ENVELOPE_BYTES; + } catch { + return true; + } +}; + +/** + * Resolves the socket id of "the other participant" in `socket`'s channel, + * using the identity bound to the socket at `chat-join` time — never a + * client-supplied `sender`/`channel` field. This is what makes the relay + * "opaque and bound to the socket/room": a connected client can only ever + * act as itself, and only within the room it actually joined. + */ +const findPeerSid = (socket: CustomSocket): string | undefined => { + if (!socket.userID || !socket.channelID) { + return undefined; + } + const receiverId = clients.getReceiverIDBySenderID(socket.userID, socket.channelID); + return receiverId ? clients.getSIDByIDs(receiverId, socket.channelID)?.sid : undefined; +}; + const connectionListener = (socket: CustomSocket, io) => { socket.on("chat-join", async (data) => { - const { userID, channelID, publicKey } = data; + const { userID, channelID } = data || {}; + if (!userID || !channelID) { + console.error("chat-join missing userID/channelID"); + return; + } const { valid } = await channelValid(channelID); if (!valid) { @@ -15,39 +52,99 @@ const connectionListener = (socket: CustomSocket, io) => { const usersInChannel = clients.getClientsByChannel(channelID) || {}; const userCount = Object.keys(usersInChannel).length; - const receiverSocket = io.sockets.sockets[socket.id]; - if (userCount === 2 && receiverSocket) { + if (userCount === 2) { socketEmit(SOCKET_TOPIC.LIMIT_REACHED, socket.id, null); - receiverSocket.disconnect(); + socket.disconnect(); return; } clients.setClientToChannel(userID, channelID, socket.id); socket.channelID = channelID; socket.userID = userID; - // share the public key to the receiver if present - const receiverId = Object.keys(usersInChannel).find(user => user !== userID); + + // Notify the other participant (if any) that someone joined. No key + // material is exchanged here any more — participants already share the + // invite secret out of band, and derive their keys from it locally. + const receiverId = clients.getReceiverIDBySenderID(userID, channelID); const receiver = receiverId && clients.getSIDByIDs(receiverId, channelID); if (receiver) { - socketEmit(SOCKET_TOPIC.ON_ALICE_JOIN, receiver.sid, { publicKey }); + socketEmit(SOCKET_TOPIC.ON_ALICE_JOIN, receiver.sid, null); } }); - socket.on("received", ({ channel, sender, id }) => { - const { sid } = clients.getSIDByIDs(sender, channel); - socketEmit(SOCKET_TOPIC.DELIVERED, sid, id); + socket.on("chat-message", (payload: { envelope: WireEnvelope }, ack: Ack = noop) => { + if (!socket.userID || !socket.channelID) { + ack({ error: "Join a channel before sending messages." }); + return; + } + if (!rateLimiter.consume(socket.id)) { + ack({ error: "Rate limit exceeded." }); + return; + } + if (isPayloadTooLarge(payload)) { + ack({ error: "Payload too large." }); + return; + } + const receiverSid = findPeerSid(socket); + if (!receiverSid) { + ack({ error: "No receiver is in the channel." }); + return; + } + + const id = Date.now(); + const timestamp = id; + socketEmit(SOCKET_TOPIC.CHAT_MESSAGE, receiverSid, { + id, + timestamp, + sender: socket.userID, + envelope: payload?.envelope, + }); + ack({ id, timestamp }); + }); + + socket.on("webrtc-signal", (payload: { envelope: WireEnvelope }, ack: Ack = noop) => { + if (!socket.userID || !socket.channelID) { + ack({ error: "Join a channel before signaling." }); + return; + } + if (!rateLimiter.consume(socket.id)) { + ack({ error: "Rate limit exceeded." }); + return; + } + if (isPayloadTooLarge(payload)) { + ack({ error: "Payload too large." }); + return; + } + const receiverSid = findPeerSid(socket); + if (!receiverSid) { + ack({ error: "No receiver is in the channel." }); + return; + } + + socketEmit(SOCKET_TOPIC.WEBRTC_SESSION_DESCRIPTION, receiverSid, { + envelope: payload?.envelope, + }); + ack({ status: "ok" }); + }); + + socket.on("received", ({ id }: { id: string | number }) => { + const receiverSid = findPeerSid(socket); + if (receiverSid) { + socketEmit(SOCKET_TOPIC.DELIVERED, receiverSid, id); + } }); socket.on("disconnect", () => { const { channelID, userID } = socket; + rateLimiter.reset(socket.id); if (!(channelID && userID)) { return; } try { - const receiver = clients.getSIDByIDs(userID, channelID); + const receiver = findPeerSid(socket); + clients.deleteClient(userID, channelID); if (receiver) { - clients.deleteClient(userID, channelID); - socketEmit(SOCKET_TOPIC.ON_ALICE_DISCONNECTED, receiver.sid, null); + socketEmit(SOCKET_TOPIC.ON_ALICE_DISCONNECTED, receiver, null); } } catch (err) { // eslint-disable-next-line no-console diff --git a/backend/socket.io/rateLimiter.test.ts b/backend/socket.io/rateLimiter.test.ts new file mode 100644 index 00000000..50b42868 --- /dev/null +++ b/backend/socket.io/rateLimiter.test.ts @@ -0,0 +1,66 @@ +import { RateLimiter } from './rateLimiter'; + +describe('RateLimiter', () => { + let now: number; + + beforeEach(() => { + now = 1_000_000; + jest.spyOn(Date, 'now').mockImplementation(() => now); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('allows consumption up to the bucket capacity', () => { + const limiter = new RateLimiter({ capacity: 3, refillPerSecond: 1 }); + expect(limiter.consume('a')).toBe(true); + expect(limiter.consume('a')).toBe(true); + expect(limiter.consume('a')).toBe(true); + }); + + it('rejects consumption once the bucket is empty', () => { + const limiter = new RateLimiter({ capacity: 2, refillPerSecond: 1 }); + expect(limiter.consume('a')).toBe(true); + expect(limiter.consume('a')).toBe(true); + expect(limiter.consume('a')).toBe(false); + }); + + it('refills tokens over time', () => { + const limiter = new RateLimiter({ capacity: 2, refillPerSecond: 1 }); + expect(limiter.consume('a')).toBe(true); + expect(limiter.consume('a')).toBe(true); + expect(limiter.consume('a')).toBe(false); + + now += 1000; // 1 second later -> +1 token + expect(limiter.consume('a')).toBe(true); + expect(limiter.consume('a')).toBe(false); + }); + + it('never refills beyond capacity', () => { + const limiter = new RateLimiter({ capacity: 2, refillPerSecond: 100 }); + limiter.consume('a'); + now += 10_000; // huge elapsed time + expect(limiter.consume('a')).toBe(true); + expect(limiter.consume('a')).toBe(true); + expect(limiter.consume('a')).toBe(false); + }); + + it('tracks each key independently', () => { + const limiter = new RateLimiter({ capacity: 1, refillPerSecond: 1 }); + expect(limiter.consume('a')).toBe(true); + expect(limiter.consume('b')).toBe(true); + expect(limiter.consume('a')).toBe(false); + expect(limiter.consume('b')).toBe(false); + }); + + it('reset() forgets a key, restoring a full bucket on next use', () => { + const limiter = new RateLimiter({ capacity: 1, refillPerSecond: 1 }); + expect(limiter.consume('a')).toBe(true); + expect(limiter.consume('a')).toBe(false); + + limiter.reset('a'); + + expect(limiter.consume('a')).toBe(true); + }); +}); diff --git a/backend/socket.io/rateLimiter.ts b/backend/socket.io/rateLimiter.ts new file mode 100644 index 00000000..2229a70b --- /dev/null +++ b/backend/socket.io/rateLimiter.ts @@ -0,0 +1,51 @@ +export interface RateLimiterOptions { + /** Maximum number of tokens a bucket can hold (i.e. the allowed burst size). */ + capacity: number; + /** Tokens added back to a bucket per second. */ + refillPerSecond: number; +} + +interface Bucket { + tokens: number; + lastRefill: number; +} + +/** + * Minimal in-memory token-bucket rate limiter, keyed by an arbitrary string + * (e.g. a socket id). No external dependency — tokens are refilled lazily, + * based on elapsed wall-clock time, whenever `consume()` is called. + * + * Used to bound how many signaling/chat envelopes a single socket connection + * may relay per second, independent of (and in addition to) the per-message + * size check in the socket listeners. + */ +export class RateLimiter { + private buckets: Map = new Map(); + + constructor(private options: RateLimiterOptions) {} + + /** Attempts to consume `cost` tokens from `key`'s bucket. Returns whether it was allowed. */ + public consume(key: string, cost = 1): boolean { + const now = Date.now(); + let bucket = this.buckets.get(key); + if (!bucket) { + bucket = { tokens: this.options.capacity, lastRefill: now }; + this.buckets.set(key, bucket); + } + + const elapsedSeconds = Math.max(0, now - bucket.lastRefill) / 1000; + bucket.tokens = Math.min(this.options.capacity, bucket.tokens + elapsedSeconds * this.options.refillPerSecond); + bucket.lastRefill = now; + + if (bucket.tokens < cost) { + return false; + } + bucket.tokens -= cost; + return true; + } + + /** Forgets a key's bucket entirely (e.g. on disconnect), so memory doesn't grow unbounded. */ + public reset(key: string): void { + this.buckets.delete(key); + } +} diff --git a/client/README.md b/client/README.md index 89647bd7..7bf0e8b9 100644 --- a/client/README.md +++ b/client/README.md @@ -94,7 +94,7 @@ App - ✅ Glass-morphism UI design - ✅ Mobile-responsive layout - ✅ Native share API integration -- ✅ URL hash auto-population for channel joining +- ✅ Invitation-link (`#room=...&secret=...`) auto-population for channel joining ## 🔒 Security & Backend Integration @@ -142,8 +142,8 @@ This is a pure UI layer refactoring from vanilla TypeScript to React.js: - [ ] Join existing channel flow - [ ] Send/receive messages in real-time - [ ] Audio call initiation and termination -- [ ] Copy hash functionality -- [ ] URL hash auto-population +- [ ] Copy invitation link functionality +- [ ] Invitation-link auto-population - [ ] Peer detection and status indicators - [ ] Mobile responsiveness - [ ] Message animations diff --git a/client/app.ts b/client/app.ts deleted file mode 100644 index 214a01eb..00000000 --- a/client/app.ts +++ /dev/null @@ -1,339 +0,0 @@ -import { createChatInstance, utils } from '@chat-e2ee/service'; - -// State -let chat: any = null; -let userId: string = ''; -let channelHash: string = ''; -let privateKey: string = ''; - -// DOM Elements -// DOM Elements -const setupOverlay = document.getElementById('setup-overlay')!; -const initialActions = document.getElementById('initial-actions')!; -const createHashView = document.getElementById('create-hash-view')!; -const joinHashView = document.getElementById('join-hash-view')!; -const finalActions = document.getElementById('final-actions')!; - -const showCreateBtn = document.getElementById('show-create-hash') as HTMLButtonElement; -const showJoinBtn = document.getElementById('show-join-hash') as HTMLButtonElement; -const backBtn = document.getElementById('back-btn') as HTMLButtonElement; -const copyHashBtn = document.getElementById('copy-hash-btn') as HTMLButtonElement; - -const generatedHashDisplay = document.getElementById('generated-hash-display') as HTMLInputElement; -const hashInput = document.getElementById('channel-hash') as HTMLInputElement; -const joinBtn = document.getElementById('join-btn') as HTMLButtonElement; -const setupStatus = document.getElementById('setup-status')!; - -const chatContainer = document.getElementById('chat-container')!; -const messagesArea = document.getElementById('messages-area')!; -const msgInput = document.getElementById('msg-input') as HTMLInputElement; -const sendBtn = document.getElementById('send-btn') as HTMLButtonElement; -const startCallBtn = document.getElementById('start-call-btn') as HTMLButtonElement; -const chatHeader = document.querySelector('header')!; -const participantInfo = document.getElementById('participant-info')!; -const headerHashDisplay = document.getElementById('channel-hash-display')!; -const headerHashText = document.getElementById('header-hash')!; -const copyHeaderHashBtn = document.getElementById('copy-header-hash') as HTMLButtonElement; -const shareBtn = document.getElementById('share-btn') as HTMLButtonElement; - -// Call Elements -const callOverlay = document.getElementById('call-overlay')!; -const callStatusText = document.getElementById('call-status')!; -const endCallBtn = document.getElementById('end-call-btn') as HTMLButtonElement; -const callDuration = document.getElementById('call-duration')!; - -// Audio notification -function playBeep() { - try { - const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext; - if (!AudioContextClass) return; - const ctx = new AudioContextClass(); - const oscillator = ctx.createOscillator(); - const gainNode = ctx.createGain(); - oscillator.connect(gainNode); - gainNode.connect(ctx.destination); - oscillator.type = 'sine'; - oscillator.frequency.setValueAtTime(880, ctx.currentTime); - gainNode.gain.setValueAtTime(0.3, ctx.currentTime); - gainNode.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.4); - oscillator.start(ctx.currentTime); - oscillator.stop(ctx.currentTime + 0.4); - } catch (err) { - console.warn('Audio notification not available:', err); - } -} - -// Initialize Chat -async function initChat() { - try { - setupStatus.textContent = 'Initializing secure keys...'; - chat = createChatInstance({ baseUrl: process.env.CHATE2EE_API_URL || 'http://localhost:3001' }); - await chat.init(); - - const keys = chat.getKeyPair(); - privateKey = keys.privateKey; - setupStatus.textContent = ''; - - // Check for URL hash on load - handleUrlHash(); - } catch (err) { - console.error('Init error:', err); - setupStatus.textContent = 'Initialization failed. Refresh and try again.'; - } -} - -// UI Navigation -function showView(view: 'initial' | 'create' | 'join') { - initialActions.classList.add('hidden'); - createHashView.classList.add('hidden'); - joinHashView.classList.add('hidden'); - finalActions.classList.add('hidden'); - setupStatus.textContent = ''; - - if (view === 'initial') { - initialActions.classList.remove('hidden'); - } else if (view === 'create') { - createHashView.classList.remove('hidden'); - finalActions.classList.remove('hidden'); - } else if (view === 'join') { - joinHashView.classList.remove('hidden'); - finalActions.classList.remove('hidden'); - hashInput.focus(); - } -} - -showCreateBtn.addEventListener('click', async () => { - showView('create'); - try { - generatedHashDisplay.value = 'Generating...'; - const linkObj = await chat.getLink(); - generatedHashDisplay.value = linkObj.hash; - channelHash = linkObj.hash; - } catch (err) { - setupStatus.textContent = 'Failed to generate hash.'; - } -}); - -showJoinBtn.addEventListener('click', () => { - showView('join'); -}); - -backBtn.addEventListener('click', () => { - showView('initial'); - channelHash = ''; - hashInput.value = ''; - userId = ''; -}); - -copyHashBtn.addEventListener('click', () => { - navigator.clipboard.writeText(generatedHashDisplay.value); - const originalText = setupStatus.textContent; - setupStatus.textContent = 'Hash copied to clipboard!'; - setTimeout(() => setupStatus.textContent = originalText, 2000); -}); - -copyHeaderHashBtn.addEventListener('click', () => { - navigator.clipboard.writeText(window.location.href); - const originalText = setupStatus.textContent; - setupStatus.textContent = 'URL copied to clipboard!'; - setTimeout(() => setupStatus.textContent = originalText, 2000); -}); - -if ('share' in navigator) { - shareBtn.classList.remove('hidden'); - shareBtn.addEventListener('click', () => { - navigator.share({ - title: 'Chat E2EE', - text: 'Join my end-to-end encrypted chat', - url: window.location.href, - }).catch(() => { /* user cancelled or share failed */ }); - }); -} - -async function checkExistingUsers() { - try { - const users = await chat.getUsersInChannel(); - if (users && users.length > 1) { - playBeep(); - chatHeader.classList.add('active'); - participantInfo.textContent = 'Peer is already here. Communication is encrypted.'; - } - } catch (err) { - console.error('Error checking users:', err); - } -} - -function updateUrlHash(hash: string) { - if (hash) { - window.location.hash = hash; - } -} - -function handleUrlHash() { - const hash = window.location.hash.replace('#', ''); - if (hash && hash.length > 5) { - hashInput.value = hash; - showView('join'); - } -} - -joinBtn.addEventListener('click', async () => { - // Determine which hash to use - const enteredHash = hashInput.value.trim(); - const finalHash = enteredHash || channelHash; - - if (!finalHash) { - setupStatus.textContent = 'Please enter or generate a hash.'; - return; - } - - // Auto-generate User ID - if (!userId) { - userId = (utils as any).generateUUID(); - } - - try { - joinBtn.disabled = true; - setupStatus.textContent = 'Connecting...'; - await chat.setChannel(finalHash, userId); - - // Update UI with Hash - headerHashText.textContent = finalHash; - headerHashDisplay.classList.remove('hidden'); - updateUrlHash(finalHash); - - setupOverlay.classList.add('hidden'); - chatContainer.classList.remove('hidden'); - - setupChatListeners(); - await checkExistingUsers(); - } catch (err) { - console.error('Join error:', err); - setupStatus.textContent = 'Failed to connect.'; - joinBtn.disabled = false; - } -}); - -function setupChatListeners() { - chat.on('on-alice-join', () => { - playBeep(); - chatHeader.classList.add('active'); - participantInfo.textContent = 'Peer joined. Communication is encrypted.'; - }); - - chat.on('on-alice-disconnect', () => { - chatHeader.classList.remove('active'); - participantInfo.textContent = 'Peer disconnected.'; - }); - - chat.on('chat-message', async (msg: any) => { - const plainText = await (utils as any).decryptMessage(msg.message, privateKey); - appendMessage(msg.sender, plainText, 'received'); - }); - - chat.on('call-added', (call: any) => { - showCallOverlay('Incoming Call...'); - setupCallListeners(call); - }); -} - -// Messaging -async function sendMessage() { - const text = msgInput.value.trim(); - if (!text) return; - - msgInput.value = ''; - appendMessage(userId, text, 'sent'); - - try { - await chat.encrypt({ text }).send(); - } catch (err) { - console.error('Send error:', err); - } -} - -sendBtn.addEventListener('click', sendMessage); -msgInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter') sendMessage(); -}); - -function appendMessage(sender: string, text: string, type: 'sent' | 'received') { - const msgEl = document.createElement('div'); - msgEl.className = `message ${type}`; - - const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - - msgEl.innerHTML = ` -
${text}
-
- ${sender} - ${time} -
- `; - - messagesArea.appendChild(msgEl); - messagesArea.scrollTop = messagesArea.scrollHeight; -} - -// Calling -let callTimer: any = null; -let callStartTime: number = 0; - -startCallBtn.addEventListener('click', async () => { - try { - const call = await chat.startCall(); - showCallOverlay('Calling...'); - setupCallListeners(call); - } catch (err: any) { - alert(err.message); - } -}); - -function setupCallListeners(call: any) { - call.on('state-changed', (state: string) => { - callStatusText.textContent = state.charAt(0).toUpperCase() + state.slice(1); - - if (state === 'connected') { - startTimer(); - } - - if (state === 'closed' || state === 'failed') { - hideCallOverlay(); - stopTimer(); - } - }); - - endCallBtn.onclick = async () => { - await call.endCall(); - hideCallOverlay(); - stopTimer(); - }; -} - -function startTimer() { - stopTimer(); - callStartTime = Date.now(); - callTimer = setInterval(() => { - const seconds = Math.floor((Date.now() - callStartTime) / 1000); - const m = Math.floor(seconds / 60).toString().padStart(2, '0'); - const s = (seconds % 60).toString().padStart(2, '0'); - callDuration.textContent = `${m}:${s}`; - }, 1000); -} - -function stopTimer() { - if (callTimer) clearInterval(callTimer); - callDuration.textContent = '00:00'; -} - -function showCallOverlay(status: string) { - callOverlay.classList.remove('hidden'); - callStatusText.textContent = status; -} - -function hideCallOverlay() { - callOverlay.classList.add('hidden'); -} - -// Start -initChat(); diff --git a/client/src/App.tsx b/client/src/App.tsx index 13b00b9d..3d47110b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -6,7 +6,7 @@ import React, { useEffect, useState } from 'react'; import { useChat } from './context/ChatContext'; import { SetupOverlay } from './components/SetupOverlay/SetupOverlay'; import { ChatContainer } from './components/ChatContainer/ChatContainer'; -import { updateUrlHash } from './utils/urlHash'; +import { updateUrlInvite } from './utils/urlHash'; import './styles/global.css'; const AppContent: React.FC = () => { @@ -22,11 +22,11 @@ const AppContent: React.FC = () => { }); }, [initializeChat]); - const handleSetupComplete = async (hash: string) => { + const handleSetupComplete = async (roomId: string, secret: string) => { try { setError(''); - await joinChannel(hash); - updateUrlHash(hash); + await joinChannel(roomId, secret); + updateUrlInvite(roomId, secret); setShowSetup(false); } catch (err) { setError((err as any).message || 'Failed to connect. Please try again.'); diff --git a/client/src/components/ChatContainer/ChatHeader.tsx b/client/src/components/ChatContainer/ChatHeader.tsx index 5a29a752..7e217171 100644 --- a/client/src/components/ChatContainer/ChatHeader.tsx +++ b/client/src/components/ChatContainer/ChatHeader.tsx @@ -71,7 +71,7 @@ const handleDelete = async () => { {hashCopied && Copied!} )} -

+

{isConnected ? 'Peer joined. Communication is encrypted.' : 'Waiting for someone to join...'}

diff --git a/client/src/components/SetupOverlay/CreateHashView.tsx b/client/src/components/SetupOverlay/CreateHashView.tsx index 5d52baa4..c64716bb 100644 --- a/client/src/components/SetupOverlay/CreateHashView.tsx +++ b/client/src/components/SetupOverlay/CreateHashView.tsx @@ -1,5 +1,10 @@ /** - * Create hash view component + * Create-invite view component. + * + * Displays the shareable invitation link + * (`#room=&secret=`). The secret is generated on + * this device and is only ever carried in the link's URL fragment — never + * sent to the server. */ import React, { useState } from 'react'; @@ -9,14 +14,14 @@ import { CopyIcon } from '../common/icons'; import './CreateHashView.css'; interface CreateHashViewProps { - hash: string; + inviteLink: string; onCopyClick: () => void; onBack: () => void; onNext: () => void; } export const CreateHashView: React.FC = ({ - hash, + inviteLink, onCopyClick, onBack, onNext, @@ -32,10 +37,11 @@ export const CreateHashView: React.FC = ({ return (
- +
{ }} placeholder="Generating..." readOnly @@ -44,7 +50,7 @@ export const CreateHashView: React.FC = ({ variant="secondary" size="small" onClick={handleCopy} - title="Copy Hash" + title="Copy Invitation Link" > @@ -53,10 +59,10 @@ export const CreateHashView: React.FC = ({
- -
diff --git a/client/src/components/SetupOverlay/InitialActions.tsx b/client/src/components/SetupOverlay/InitialActions.tsx index 35c4ba37..2727927e 100644 --- a/client/src/components/SetupOverlay/InitialActions.tsx +++ b/client/src/components/SetupOverlay/InitialActions.tsx @@ -13,7 +13,7 @@ interface InitialActionsProps { export const InitialActions: React.FC = ({ onCreateClick, onJoinClick }) => { return ( -
+
{/* 1. Added a custom heading/subtext to notice right away */}

@@ -21,12 +21,12 @@ export const InitialActions: React.FC = ({ onCreateClick, o

- -
); diff --git a/client/src/components/SetupOverlay/JoinHashView.tsx b/client/src/components/SetupOverlay/JoinHashView.tsx index 68bcd029..0342e983 100644 --- a/client/src/components/SetupOverlay/JoinHashView.tsx +++ b/client/src/components/SetupOverlay/JoinHashView.tsx @@ -1,5 +1,9 @@ /** - * Join hash view component + * Join-by-invite view component. + * + * Accepts a full invitation link/fragment (`#room=...&secret=...`) rather + * than a bare room id — joining requires the secret, which never touches + * the server. */ import React, { useEffect } from 'react'; @@ -9,41 +13,42 @@ import { useUrlHash } from '../../hooks/useUrlHash'; import './JoinHashView.css'; interface JoinHashViewProps { - hash: string; - onHashChange: (hash: string) => void; + inviteInput: string; + onInviteInputChange: (value: string) => void; onBack: () => void; onJoin: () => void; } export const JoinHashView: React.FC = ({ - hash, - onHashChange, + inviteInput, + onInviteInputChange, onBack, onJoin, }) => { - const { hash: urlHash } = useUrlHash(); + const { invite } = useUrlHash(); - // Auto-populate from URL if available + // Auto-populate from the URL invite fragment if available useEffect(() => { - if (urlHash && !hash) { - onHashChange(urlHash); + if (invite && !inviteInput) { + onInviteInputChange(`room=${invite.roomId}&secret=${invite.secret}`); } - }, [urlHash, hash, onHashChange]); + }, [invite, inviteInput, onInviteInputChange]); return (
- -
diff --git a/client/src/components/SetupOverlay/SetupOverlay.tsx b/client/src/components/SetupOverlay/SetupOverlay.tsx index 89dc2a0a..196ec390 100644 --- a/client/src/components/SetupOverlay/SetupOverlay.tsx +++ b/client/src/components/SetupOverlay/SetupOverlay.tsx @@ -4,13 +4,14 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useChat } from '../../context/ChatContext'; +import { parseInviteInput } from '../../utils/urlHash'; import { InitialActions } from './InitialActions'; import { CreateHashView } from './CreateHashView'; import { JoinHashView } from './JoinHashView'; import './SetupOverlay.css'; interface SetupOverlayProps { - onSetupComplete: (hash: string) => Promise; + onSetupComplete: (roomId: string, secret: string) => Promise; isHidden: boolean; } @@ -19,29 +20,29 @@ type ViewType = 'initial' | 'create' | 'join' | 'deleted'; export const SetupOverlay: React.FC = ({ onSetupComplete, isHidden }) => { const { createNewChannel } = useChat(); const [view, setView] = useState('initial'); - const [generatedHash, setGeneratedHash] = useState(''); - const [joinHash, setJoinHash] = useState(''); + const [invite, setInvite] = useState<{ roomId: string; secret: string; link: string } | null>(null); + const [joinInput, setJoinInput] = useState(''); const [status, setStatus] = useState(''); const [, setIsLoading] = useState(false); - // Generate hash when entering create view - const generateHash = useCallback(async () => { + // Generate the invitation (room id from the server + a locally generated secret) when entering create view + const generateInvite = useCallback(async () => { try { - setStatus('Generating secure hash...'); - const hash = await createNewChannel(); - setGeneratedHash(hash); + setStatus('Generating a secure invitation...'); + const created = await createNewChannel(); + setInvite({ roomId: created.roomId, secret: created.secret, link: created.absoluteLink || created.link }); setStatus(''); } catch (err) { - setStatus('Failed to generate hash. Please try again.'); - console.error('Hash generation error:', err); + setStatus('Failed to generate invitation. Please try again.'); + console.error('Invite generation error:', err); } }, [createNewChannel]); useEffect(() => { - if (view === 'create' && !generatedHash) { - generateHash(); + if (view === 'create' && !invite) { + generateInvite(); } - }, [view, generatedHash, generateHash]); + }, [view, invite, generateInvite]); const handleCreateClick = () => { setView('create'); @@ -53,24 +54,26 @@ export const SetupOverlay: React.FC = ({ onSetupComplete, isH const handleBack = () => { setView('initial'); - setGeneratedHash(''); - setJoinHash(''); + setInvite(null); + setJoinInput(''); setStatus(''); }; const handleCopyHash = () => { - navigator.clipboard.writeText(generatedHash); + if (invite) { + navigator.clipboard.writeText(invite.link); + } }; const handleCreateNext = async () => { - if (!generatedHash) { - setStatus('Please generate a hash first.'); + if (!invite) { + setStatus('Please generate an invitation first.'); return; } try { setIsLoading(true); setStatus('Connecting...'); - await onSetupComplete(generatedHash); + await onSetupComplete(invite.roomId, invite.secret); } catch (err) { setStatus('Failed to connect. Please try again.'); console.error('Setup error:', err); @@ -80,20 +83,21 @@ export const SetupOverlay: React.FC = ({ onSetupComplete, isH }; const handleJoinNext = async () => { - if (!joinHash.trim()) { - setStatus('Please enter a hash.'); + const parsed = parseInviteInput(joinInput); + if (!parsed) { + setStatus('Please enter a valid invitation link.'); return; } try { setIsLoading(true); setStatus('Connecting...'); - await onSetupComplete(joinHash); + await onSetupComplete(parsed.roomId, parsed.secret); } catch (err: any) { if (err.message === 'CHANNEL_DELETED') { setView('deleted'); setStatus(''); } else { - setStatus('Failed to join channel. Please check the hash and try again.'); + setStatus('Failed to join channel. Please check the invitation link and try again.'); } console.error('Join error:', err); } finally { @@ -116,7 +120,7 @@ export const SetupOverlay: React.FC = ({ onSetupComplete, isH {view === 'create' && ( = ({ onSetupComplete, isH {view === 'join' && ( @@ -142,7 +146,7 @@ export const SetupOverlay: React.FC = ({ onSetupComplete, isH
)} - {status &&
{status}
} + {status &&
{status}
}
); diff --git a/client/src/components/common/Button.tsx b/client/src/components/common/Button.tsx index eca433dc..5b5b773b 100644 --- a/client/src/components/common/Button.tsx +++ b/client/src/components/common/Button.tsx @@ -15,6 +15,7 @@ export const Button: React.FC = ({ children, className = '', title, + id, }) => { const baseClass = 'btn'; const variantClass = `btn--${variant}`; @@ -25,6 +26,7 @@ export const Button: React.FC = ({ return (