Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 9 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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=<public-room-id>&secret=<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 envelope before it ever reaches the server. The server relays this opaque envelope between the two sockets in the room — it cannot read or modify it, and the receiver rejects outright (no plaintext fallback) anything that doesn't match the expected protocol version or encryption strategy.

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.

---

Expand Down
44 changes: 38 additions & 6 deletions backend/README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,43 @@
### APIs
```endpoint: /api/<path>```

| 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 envelope** (`{ version, strategy, data }`); 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`.

---
44 changes: 0 additions & 44 deletions backend/api/call/session.ts

This file was deleted.

31 changes: 1 addition & 30 deletions backend/api/chatHash/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,49 +3,20 @@
import db from '../../db';
import { LINK_COLLECTION } from '../../db/const';
import asyncHandler from '../../middleware/asyncHandler';
import { LinkType } from './utils/link';

Check warning on line 6 in backend/api/chatHash/index.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unused import of 'LinkType'.

See more on https://sonarcloud.io/project/issues?id=muke1908_chat-e2ee&issues=AaBNiJ5Vrl9RvYuOjxra&open=AaBNiJ5Vrl9RvYuOjxra&pullRequest=488
import channelValid, { CHANNEL_STATE } from './utils/validateChannel';
import generateHash from './utils/link';

const router = express.Router({ mergeParams: true });

const generateUniqueHash = async (): Promise<LinkType> => {
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<LinkType>({ 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<LinkType>({ 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) => {
Expand Down
10 changes: 2 additions & 8 deletions backend/api/chatHash/utils/link.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
14 changes: 8 additions & 6 deletions backend/api/chatHash/utils/link.ts
Original file line number Diff line number Diff line change
@@ -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=<hash>&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();

Expand All @@ -24,8 +28,6 @@ const generateHash = (): LinkType => {
hash,
expired: false,
deleted: false,
pin: generatePIN(hash, PIN_LENGTH),
pinCreatedAt: new Date().getTime()
};
};

Expand Down
24 changes: 0 additions & 24 deletions backend/api/chatHash/utils/pin.test.js

This file was deleted.

30 changes: 0 additions & 30 deletions backend/api/chatHash/utils/pin.ts

This file was deleted.

4 changes: 1 addition & 3 deletions backend/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand All @@ -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;
export default router;
Loading