Skip to content
Draft
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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ In this way, no one else can decrypt anything because the secret is never expose

> 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.

**Content encryption is not metadata anonymity.** With the secure default, the
relay cannot decrypt chat or signaling contents, but still sees room membership,
participant IDs, event classes, timing and ciphertext sizes. The explicitly
disabled strategy provides no confidentiality. Use HTTPS/WSS to hide application
payloads from passive network observers. See the [metadata inventory and
limitations](backend/README.md#metadata-inventory-and-privacy-limits).

---

### Flow
Expand Down
64 changes: 63 additions & 1 deletion backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,72 @@
| `/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 |
| `/chat/get-users-in-channel` | `GET` | query: `channel`, optional `countOnly=true` | `/api/messaging/index.ts` | legacy `[{uuid}]` list, or minimal `{count}` for presence checks |

---

### Metadata inventory and privacy limits

This inventory covers the bundled client, SDK HTTP helpers and socket transport,
backend relay, and WebRTC signaling. “Visible” below means visible to the server
or TLS terminator. With **HTTPS/WSS**, passive network observers cannot read the
JSON fields/event names; they still see endpoints, connection timing and traffic
sizes. Without TLS, application metadata is exposed to the network too.

| Surface | Before | After / reason retained |
| --- | --- | --- |
| Room creation/status/deletion | Public UUIDv4 room ID (`hash`), `expired`/`deleted` state; room ID in status/delete URLs | Unchanged. Room IDs are already opaque, random UUIDv4 values, not counters; routing and lifecycle checks need them. The server also knows room creation/expiry times. |
| Participant HTTP lookup | `channel` query, response `[{uuid}]`, even when callers only needed presence | Bundled UI and SDK call preconditions request `countOnly=true` and receive only `{count}`. Explicit legacy list calls remain supported. |
| Socket join | `{channelID,userID}` normally, but arbitrary runtime extras were forwarded | SDK explicitly selects only those two fields. IDs remain for routing/participant tracking and compatibility. No username or invitation secret is needed. |
| SDK join/channel logs; server invalid-room log | Room/user IDs and optional display name, or the entire runtime join object | Operation/error names only; these paths no longer copy identifiers into diagnostic logs. |
| Chat upload | `{envelope}`; arbitrary extra outer envelope fields could pass through | SDK selects only `{version,strategy,data}` within `{envelope}`. Custom strategy `data` remains untouched. |
| Chat delivery and acknowledgment | Delivery `{id,timestamp,sender,envelope}`; ack `{id,timestamp}` | Unchanged public contract. Sender IDs and server timestamps remain visible. Numeric message IDs currently equal server time and are not opaque. |
| Signaling upload/delivery | `{envelope}` on distinct `webrtc-signal` / `webrtc-session-description` events | Same envelope-header minimization as chat. Call IDs, detailed types (invite/accept/reject/cancel/timeout/end, offer/answer/ICE), SDP, candidates, reasons, sequence and payload timestamps are already **inside encryption**, not plaintext headers. |
| Envelope contents | Secure default `{version,strategy,data:{iv,ct}}` | Unchanged required protocol/strategy dispatch, public random IV, ciphertext and authentication tag. Ciphertext length remains visible. Custom strategies own their data format/privacy; disabled mode is encoded plaintext. |
| Chat plaintext | Text/image, sequence and client timestamp | Already encrypted with the secure strategy; unchanged. |
| Receipts/presence/errors | `received:{id}`, `delivered:id`; null join/disconnect/capacity payloads; error/status acknowledgments; initial `message:"ping!"` | Unchanged event contracts. Activity/presence and receipt correlation remain observable; shortening names would not hide event classes. |
| Transport/media | Socket.IO session IDs/handshake, IP addresses, connection lifetime, traffic sizes/timing; WebRTC connectivity/media traffic | Unchanged. These are outside message encryption. |

**Compatibility boundaries.** Old clients still receive the original identity
list unless they request a count. New SDKs accept the legacy list from old
servers that ignore `countOnly`, without a second request; the metadata reduction
therefore requires an updated server. The legacy endpoint still exposes IDs to
callers who explicitly request them; this change is minimization, not access
control. Server relay contracts remain unchanged and strategy `data` stays
opaque. Header projection prevents accidental SDK runtime extras, not metadata
deliberately placed inside custom strategy data or sent by non-SDK clients.

**Remaining limits.** This relay must associate sockets with rooms and track
presence to deliver to the other participant. The compatibility API exposes
sender IDs, receipt IDs and timestamps; these are retained for existing
consumers, not claimed to be cryptographically necessary. Use fresh random
participant IDs per room/session, never account IDs, emails or reusable names.
Opaque room IDs prevent easy guessing but do not hide membership from the
relay. HTTP access logs can still contain room IDs; operators should avoid
retaining URLs, identifiers and payloads in proxy/application telemetry.

Even encrypted SDP/ICE does not hide connectivity from the remote peer or
STUN infrastructure. WebRTC uses DTLS-SRTP for media; direct connections can
reveal peer IP addresses. TURN/relay-only operation would require additional
infrastructure and move trust to that relay. Traffic correlation, message
frequency, length classes and call duration are not hidden by E2EE. Hiding them
would require architectural changes, not merely shorter event names.

**Optional padding (not enabled).** A future opt-in, mutually supported
strategy/version could pad serialized plaintext *inside authenticated
encryption*, with validated length framing on decryption. Size buckets hide
exact lengths but reveal buckets; fixed-size messages cost more bandwidth and
may require chunking. Account for base64/envelope overhead within the existing
32 KiB application and 64 KiB transport limits. Never pad IVs or ciphertext
ad hoc or silently change the current plaintext schema. Padding alone does
not hide addresses, timing, presence or frequency; cover traffic/batching
would add bandwidth, latency and browser background-scheduling constraints.

Regression coverage: SDK socket tests enforce exact join/envelope/receipt
fields, SDK tests check encrypted content and identifier-free join logging,
backend listener tests pin relay/ack shapes, and HTTP/helper tests cover
count-only responses, zero/one/two participants and legacy compatibility.

### Socket.io events

Chat messages and WebRTC signaling are **not** sent over REST any more — they
Expand Down
46 changes: 46 additions & 0 deletions backend/api/messaging/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import express from 'express';
import request from 'supertest';
import router from './index';
import channelValid from '../chatHash/utils/validateChannel';

jest.mock('../chatHash/utils/validateChannel', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../socket.io/clients', () => ({
__esModule: true,
default: () => ({ getClientsByChannel: (...args: unknown[]) => mockGetClients(...args) }),
}));
const mockGetClients = jest.fn();
const app = express();
app.use('/api/chat', router);

describe('participant response contracts', () => {
beforeEach(() => {
jest.clearAllMocks();
(channelValid as jest.Mock).mockResolvedValue({ valid: true });
mockGetClients.mockReturnValue({ alice: { sid: 'socket-a' }, bob: { sid: 'socket-b' } });
});

it.each([0, 1, 2])('returns only a count for %i participants', async (count) => {
mockGetClients.mockReturnValue(Object.fromEntries(
['alice', 'bob'].slice(0, count).map(id => [id, { sid: `socket-${id}` }])
));
const response = await request(app).get('/api/chat/get-users-in-channel?channel=room&countOnly=true');
expect(response.status).toBe(200);
expect(response.body).toEqual({ count });
expect(mockGetClients).toHaveBeenCalledWith('room');
});

it.each(['', '&countOnly=false'])('keeps the legacy identity response for old callers (%s)', async (query) => {
const response = await request(app).get(`/api/chat/get-users-in-channel?channel=room${query}`);
expect(response.body).toEqual([{ uuid: 'alice' }, { uuid: 'bob' }]);
});

it('still rejects invalid rooms without looking up participants', async () => {
(channelValid as jest.Mock).mockResolvedValue({ valid: false });
const response = await request(app).get('/api/chat/get-users-in-channel?channel=invalid&countOnly=true');
expect(response.status).toBe(404);
expect(mockGetClients).not.toHaveBeenCalled();
});
});
5 changes: 4 additions & 1 deletion backend/api/messaging/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const clients = getClientInstance();

router.get(
"/get-users-in-channel",
asyncHandler(async (req: Request, res: Response): Promise<Response<UsersInChannelResponse>> => {
asyncHandler(async (req: Request, res: Response): Promise<Response<UsersInChannelResponse | { count: number }>> => {
const { channel } = req.query;

const { valid } = await channelValid(channel as string);
Expand All @@ -20,6 +20,9 @@ router.get(
}

const data = clients.getClientsByChannel(channel as string);
if (req.query.countOnly === 'true') {
return res.send({ count: Object.keys(data || {}).length });
}
const usersInChannel = data ? Object.keys(data).map((userId) => ({ uuid: userId })) : [];
return res.send(usersInChannel);
})
Expand Down
74 changes: 74 additions & 0 deletions backend/socket.io/listeners.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import connectionListener from './listeners';
import { CustomSocket, socketEmit } from './index';
import channelValid from '../api/chatHash/utils/validateChannel';

jest.mock('./index', () => ({
...jest.requireActual('./index'),
socketEmit: jest.fn(),
}));
jest.mock('../api/chatHash/utils/validateChannel', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('./clients', () => ({
__esModule: true,
default: () => ({
getReceiverIDBySenderID: () => 'peer',
getSIDByIDs: () => ({ sid: 'peer-socket' }),
}),
}));

describe('relay metadata contracts', () => {
let handlers: Record<string, (...args: any[]) => any>;
let socket: CustomSocket;

beforeEach(() => {
jest.clearAllMocks();
handlers = {};
socket = {
id: 'sender-socket',
userID: 'sender',
channelID: 'room',
on: jest.fn((event, handler) => { handlers[event] = handler; }),
emit: jest.fn(),
} as unknown as CustomSocket;
connectionListener(socket, {});
});

it.each([
['chat-message', 'chat-message'],
['webrtc-signal', 'webrtc-session-description'],
])('keeps %s relay and acknowledgment payloads minimal', (event, topic) => {
const envelope = { version: 1, strategy: 'custom', data: { opaque: 'ciphertext' } };
const ack = jest.fn();
handlers[event]({ envelope, userName: 'private-name', sender: 'spoofed', channelID: 'other-room', type: 'offer' }, ack);
if (event === 'chat-message') {
expect(socketEmit).toHaveBeenCalledWith(topic, 'peer-socket', {
id: expect.any(Number), timestamp: expect.any(Number), sender: 'sender', envelope,
});
const delivered = (socketEmit as jest.Mock).mock.calls[0][2];
expect(ack).toHaveBeenCalledWith({ id: delivered.id, timestamp: delivered.timestamp });
} else {
expect(socketEmit).toHaveBeenCalledWith(topic, 'peer-socket', { envelope });
expect(ack).toHaveBeenCalledWith({ status: 'ok' });
}
expect((socketEmit as jest.Mock).mock.calls[0][2].envelope).toBe(envelope);
});

it('relays only the receipt id', () => {
handlers.received({ id: 42, sender: 'unnecessary', timestamp: 123 });
expect(socketEmit).toHaveBeenCalledWith('delivered', 'peer-socket', 42);
});

it('does not log an invalid room identifier', async () => {
(channelValid as jest.Mock).mockResolvedValue({ valid: false });
const log = jest.spyOn(console, 'error').mockImplementation(() => {});
try {
await handlers['chat-join']({ userID: 'private-user', channelID: 'private-room' });
expect(log).toHaveBeenCalledWith('Invalid channelID');
expect(log).toHaveBeenCalledTimes(1);
} finally {
log.mockRestore();
}
});
});
2 changes: 1 addition & 1 deletion backend/socket.io/listeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const connectionListener = (socket: CustomSocket, io) => {

const { valid } = await channelValid(channelID);
if (!valid) {
console.error("Invalid channelID - ", channelID);
console.error("Invalid channelID");
return;
}
const usersInChannel = clients.getClientsByChannel(channelID) || {};
Expand Down
4 changes: 2 additions & 2 deletions client/src/context/ChatContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -276,8 +276,8 @@ export const ChatProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
// Check for existing users
const checkExistingUsers = async (chatInstance: IChatE2EE) => {
try {
const users = await chatInstance.getUsersInChannel();
if (users && users.length > 1) {
const count = await chatInstance.getParticipantCount();
if (count > 1) {
playBeep();
setIsConnected(true);
}
Expand Down
24 changes: 21 additions & 3 deletions service/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ There is no key exchange handshake and no PIN. Instead:
3. Every chat message and WebRTC signal (offer/answer/ICE candidate/call control) is sealed into a versioned, strategy-tagged envelope (`{ version, strategy, data }`) before it ever reaches the socket. `ChatE2EE` — never the strategy itself — checks the protocol version and strategy id on receipt, and rejects (drops) anything that doesn't match the active strategy instance for that channel; there is no fallback to a different strategy or envelope version. The server only ever relays this opaque envelope between the two sockets in a room — it cannot read, modify, or replay it elsewhere. Any failure to open an envelope (wrong secret, unsupported version, unexpected strategy, tampered ciphertext) or a replayed/duplicate sequence number causes the message to be dropped outright; there is **no plaintext fallback** — not even when the configured strategy is the explicit "disabled" one (see below).
4. Audio call media itself relies on WebRTC's mandatory DTLS-SRTP transport encryption. There is no custom per-frame encryption layered on top, and therefore no encoded-transform capability gate — calls work in any standards-compliant WebRTC browser.

E2EE protects content, not all metadata. See the [before/after metadata inventory,
remaining exposure and padding trade-offs](../backend/README.md#metadata-inventory-and-privacy-limits).
Use HTTPS/WSS and fresh random participant IDs per room/session; never use an
email, account ID or reusable username as `userId`. The optional `userName`
argument is retained for compatibility but is neither transmitted nor logged.

## Encryption strategies

The SDK never hard-codes a specific cryptographic primitive, and an `EncryptionStrategy` is entirely application-agnostic: it knows nothing about rooms, users, chat, signaling, WebRTC, payload shapes, sessions, or key exchange. `ChatE2EE` owns all of that — routing, JSON<->bytes serialization, and replay/protocol validation — around two independent strategy *instances* it creates and drives itself (one for chat, one for signaling), selected through a small global registry/factory. This means:
Expand Down Expand Up @@ -87,6 +93,11 @@ An unknown strategy id throws immediately from `createChatInstance()` — there
| `decrypt(envelope)` | Opens/validates an envelope, returning the original bytes. Must throw — never fall back — on any incompatibility (wrong strategy/version, failed auth tag, malformed shape). |
| `destroy()` | Synchronously releases any key material/state held by the instance. |

The SDK transmits only the declared envelope headers (`version`, `strategy`,
`data`), dropping extra top-level runtime properties. Strategy-specific fields
must live in `data`, which remains opaque and is forwarded without modification;
custom strategies are responsible for its confidentiality and metadata surface.

Registry helpers exported alongside `createChatInstance`: `registerEncryptionStrategy(id, factory, { override? })`, `unregisterEncryptionStrategy(id)`, `hasEncryptionStrategy(id)`, `listEncryptionStrategyIds()`, `getEncryptionStrategy(id)` (creates and returns a fresh instance; throws a descriptive error for an unknown id).

## Quick Start
Expand All @@ -109,13 +120,13 @@ await chat.init();
// Guest 1: create a room. `secret` is generated locally and must be shared
// out of band (e.g. via `link`/`absoluteLink`) — never send it to your own backend.
const { hash: roomId, secret, absoluteLink } = await chat.getLink();
const userId = 'user-1';
const userId = crypto.randomUUID();
await chat.setChannel(roomId, secret, userId);

// share `absoluteLink` (or `roomId` + `secret` separately) with Guest 2 out of band

// Guest 2: join using the same roomId + secret parsed from the invitation link
await chat.setChannel(roomId, secret, 'user-2');
await chat.setChannel(roomId, secret, crypto.randomUUID());
```

### 3. Send and receive messages
Expand Down Expand Up @@ -186,7 +197,14 @@ Returns `true` once `setChannel()` has resolved *and* the configured strategy ac
Seals `text`/`image` into an envelope via the configured chat encryption strategy instance (AES-GCM AEAD by default) and delivers it over the socket. This is the only way to send a message — there is no unencrypted `sendMessage()` any more.

#### `await getUsersInChannel(): Promise<TypeUsersInChannel>`
Returns a list of users currently connected to the active channel.
Returns the legacy list of participant IDs currently connected to the active channel.
Prefer `getParticipantCount()` when identities are not needed.

#### `await getParticipantCount(): Promise<number>`
Requests only `{count}` for the active channel; used by the UI's presence check
and SDK's call preconditions. The SDK also accepts legacy list responses from
older servers, so mixed-version deployments still work (but do not gain the
count-only privacy reduction until the server is upgraded).

#### `dispose(): void`
Closes socket connections, clears event listeners, and resets the instance state.
Expand Down
Loading
Loading