diff --git a/README.md b/README.md index df6e1b3..2fb58da 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/README.md b/backend/README.md index 560e7f2..fd3361e 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 diff --git a/backend/api/messaging/index.test.ts b/backend/api/messaging/index.test.ts new file mode 100644 index 0000000..2d43519 --- /dev/null +++ b/backend/api/messaging/index.test.ts @@ -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(); + }); +}); diff --git a/backend/api/messaging/index.ts b/backend/api/messaging/index.ts index ff97a68..a3599bb 100644 --- a/backend/api/messaging/index.ts +++ b/backend/api/messaging/index.ts @@ -10,7 +10,7 @@ const clients = getClientInstance(); router.get( "/get-users-in-channel", - asyncHandler(async (req: Request, res: Response): Promise> => { + asyncHandler(async (req: Request, res: Response): Promise> => { const { channel } = req.query; const { valid } = await channelValid(channel as string); @@ -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); }) diff --git a/backend/socket.io/listeners.test.ts b/backend/socket.io/listeners.test.ts new file mode 100644 index 0000000..542003c --- /dev/null +++ b/backend/socket.io/listeners.test.ts @@ -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 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(); + } + }); +}); diff --git a/backend/socket.io/listeners.ts b/backend/socket.io/listeners.ts index 7990fb1..2debf0d 100644 --- a/backend/socket.io/listeners.ts +++ b/backend/socket.io/listeners.ts @@ -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) || {}; diff --git a/client/src/context/ChatContext.tsx b/client/src/context/ChatContext.tsx index 1fda2bc..54ef7a1 100644 --- a/client/src/context/ChatContext.tsx +++ b/client/src/context/ChatContext.tsx @@ -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); } diff --git a/service/README.md b/service/README.md index 2a4c381..399b07e 100644 --- a/service/README.md +++ b/service/README.md @@ -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: @@ -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 @@ -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 @@ -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` -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` +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. diff --git a/service/src/api/messages.test.ts b/service/src/api/messages.test.ts new file mode 100644 index 0000000..4cfa454 --- /dev/null +++ b/service/src/api/messages.test.ts @@ -0,0 +1,37 @@ +import makeRequest from './client'; +import { getParticipantCount, getUsersInChannel } from './messages'; + +jest.mock('./client', () => ({ __esModule: true, default: jest.fn() })); +const request = makeRequest as jest.Mock; + +describe('participant metadata', () => { + beforeEach(() => request.mockReset()); + + it.each([0, 1, 2])('requests only a count (%i)', async (count) => { + request.mockResolvedValue({ count }); + await expect(getParticipantCount({ channelID: 'room&other=value' })).resolves.toBe(count); + expect(request).toHaveBeenCalledWith( + 'chat/get-users-in-channel?channel=room%26other%3Dvalue&countOnly=true', + { method: 'GET' } + ); + }); + + it('accepts legacy server responses without a second request', async () => { + request.mockResolvedValue([{ uuid: 'alice' }, { uuid: 'bob' }]); + await expect(getParticipantCount({ channelID: 'room' })).resolves.toBe(2); + expect(request).toHaveBeenCalledTimes(1); + }); + + it('preserves the explicit identity-list API', async () => { + const users = [{ uuid: 'alice' }]; + request.mockResolvedValue(users); + await expect(getUsersInChannel({ channelID: 'room' })).resolves.toEqual(users); + expect(request).toHaveBeenCalledWith('chat/get-users-in-channel?channel=room', { method: 'GET' }); + }); + + it('propagates errors without retrying with an identity request', async () => { + request.mockRejectedValue(new Error('Not found')); + await expect(getParticipantCount({ channelID: 'room' })).rejects.toThrow('Not found'); + expect(request).toHaveBeenCalledTimes(1); + }); +}); diff --git a/service/src/api/messages.ts b/service/src/api/messages.ts index 87020fe..216449b 100644 --- a/service/src/api/messages.ts +++ b/service/src/api/messages.ts @@ -3,7 +3,16 @@ import type { TypeUsersInChannel } from '../public/types'; /** List the users currently present in a channel. */ export const getUsersInChannel = async ({ channelID }: { channelID?: string }): Promise => { - return makeRequest(`chat/get-users-in-channel?channel=${channelID}`, { + return makeRequest(`chat/get-users-in-channel?channel=${encodeURIComponent(String(channelID))}`, { method: 'GET' }); }; + +/** Older servers ignore countOnly and return the legacy list instead. */ +export const getParticipantCount = async ({ channelID }: { channelID?: string }): Promise => { + const response = await makeRequest<{ count: number } | TypeUsersInChannel>( + `chat/get-users-in-channel?channel=${encodeURIComponent(String(channelID))}&countOnly=true`, + { method: 'GET' } + ); + return Array.isArray(response) ? response.length : response.count; +}; diff --git a/service/src/public/types.ts b/service/src/public/types.ts index 114f3ed..23f4d30 100644 --- a/service/src/public/types.ts +++ b/service/src/public/types.ts @@ -37,6 +37,8 @@ export interface IChatE2EE { setChannel(roomId: string, secret: string, userId: string, userName?: string): Promise; delete(): Promise; getUsersInChannel(): Promise; + /** Prefer this for presence checks without requesting participant identifiers. */ + getParticipantCount(): Promise; dispose(): void; /** Encrypts `text`/`image` with the invite-derived chat key. This is the only way to send a message. */ encrypt({ image, text }: { image: string, text: string }): { send: () => Promise }; diff --git a/service/src/sdk.test.ts b/service/src/sdk.test.ts index be49e70..e58ad0b 100644 --- a/service/src/sdk.test.ts +++ b/service/src/sdk.test.ts @@ -32,6 +32,7 @@ jest.mock('socket.io-client', () => ({ // --------------------------------------------------------------------------- jest.mock('./api/messages', () => ({ getUsersInChannel: jest.fn().mockResolvedValue([]), + getParticipantCount: jest.fn().mockResolvedValue(0), })); jest.mock('./api/links', () => ({ @@ -50,6 +51,9 @@ jest.mock('./api/links', () => ({ // Import after all mocks are in place // --------------------------------------------------------------------------- import { createChatInstance } from './sdk'; +import { getParticipantCount, getUsersInChannel } from './api/messages'; +import { Logger } from './utils/logger'; +import { WebRTCCall } from './webrtc/webrtcCall'; import { generateInviteSecret, deriveChannelSecrets } from './crypto/inviteCrypto'; import { getEncryptionStrategy, DEFAULT_ENCRYPTION_STRATEGY_ID, NO_ENCRYPTION_STRATEGY_ID, registerEncryptionStrategy, unregisterEncryptionStrategy } from './crypto/registry'; import type { EncryptionEnvelope, EncryptionStrategyFactory } from './crypto/strategy'; @@ -169,6 +173,11 @@ describe('methods called before init() throw descriptive error', () => { await expect(instance.getUsersInChannel()).rejects.toThrow(NOT_INITIALIZED_MSG); }); + it('getParticipantCount() throws', async () => { + const instance = createChatInstance(); + await expect(instance.getParticipantCount()).rejects.toThrow(NOT_INITIALIZED_MSG); + }); + it('encrypt() throws', () => { const instance = createChatInstance(); expect(() => instance.encrypt({ image: '', text: 'hi' })).toThrow(NOT_INITIALIZED_MSG); @@ -204,11 +213,43 @@ describe('setChannel() / isEncrypted()', () => { expect(JSON.stringify(joinPayload)).not.toContain(SECRET); }); + it('does not log room, user, name or invitation material when setting a channel', async () => { + const instance = await buildInitializedInstance(); + const log = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => {}); + try { + await instance.setChannel(ROOM_ID, SECRET, USER_ID, 'private-display-name'); + const output = JSON.stringify(log.mock.calls); + for (const value of [ROOM_ID, SECRET, USER_ID, 'private-display-name']) { + expect(output).not.toContain(value); + } + } finally { + log.mockRestore(); + } + }); + it('rejects when roomId is missing', async () => { const instance = await buildInitializedInstance(); await expect(instance.setChannel('', SECRET, USER_ID)).rejects.toThrow(/roomId.*secret|secret.*roomId/i); }); + describe('count-only presence checks', () => { + it('uses counts for call preconditions without fetching identities', async () => { + const instance = await buildInitializedInstance(); + await instance.setChannel(ROOM_ID, SECRET, USER_ID); + (getUsersInChannel as jest.Mock).mockClear(); + (getParticipantCount as jest.Mock).mockClear(); + const supported = jest.spyOn(WebRTCCall, 'isSupported').mockReturnValue(true); + try { + await expect(instance.startCall()).rejects.toThrow('No user available to accept call'); + expect(getParticipantCount).toHaveBeenCalledWith({ channelID: ROOM_ID }); + expect(getUsersInChannel).not.toHaveBeenCalled(); + } finally { + supported.mockRestore(); + instance.dispose(); + } + }); + }); + it('rejects when secret is missing', async () => { const instance = await buildInitializedInstance(); await expect(instance.setChannel(ROOM_ID, '', USER_ID)).rejects.toThrow(/roomId.*secret|secret.*roomId/i); diff --git a/service/src/sdk.ts b/service/src/sdk.ts index f3a8d78..b3371b8 100644 --- a/service/src/sdk.ts +++ b/service/src/sdk.ts @@ -4,7 +4,7 @@ import { resolveEncryptionStrategyFactory } from './crypto/registry'; import { deriveChannelSecrets } from './crypto/inviteCrypto'; import { ReplayGuard } from './utils/replayGuard'; import { deleteLink, getLink } from './api/links'; -import { getUsersInChannel } from './api/messages'; +import { getParticipantCount, getUsersInChannel } from './api/messages'; import { configType, type IChatE2EE, type ISendMessageReturn, type LinkObjType, type TypeUsersInChannel } from './public/types'; import { SocketInstance, type RawChatMessage, type RawSignalMessage, type SubscriptionType } from './socket/socket'; import { Logger } from './utils/logger'; @@ -153,9 +153,9 @@ class ChatE2EE implements IChatE2EE { * `secret` never leaves this device — only `roomId` and `userId` are * sent to the server. */ - public async setChannel(roomId: string, secret: string, userId: string, userName?: string): Promise { + public async setChannel(roomId: string, secret: string, userId: string, _userName?: string): Promise { this.checkInitialized(); - logger.log(`setChannel(), ${JSON.stringify({ roomId, userId, userName })}`); + logger.log('setChannel()'); if (!roomId || !secret) { throw new Error('setChannel() requires both a roomId and an invitation secret.'); } @@ -204,6 +204,12 @@ class ChatE2EE implements IChatE2EE { return getUsersInChannel({ channelID: this.roomId }); } + public async getParticipantCount(): Promise { + logger.log('getParticipantCount()'); + this.checkInitialized(); + return getParticipantCount({ channelID: this.roomId }); + } + public encrypt({ image, text }: { image: string, text: string }): { send: () => Promise } { logger.log(`encrypt()`); this.checkInitialized(); @@ -416,8 +422,8 @@ class ChatE2EE implements IChatE2EE { } private async assertCallPreconditions(): Promise { - const users = await this.getUsersInChannel(); - if (!users || users.length < 2) { + const count = await this.getParticipantCount(); + if (count < 2) { this.updateCallLifecycle('no-peer'); throw new Error('No user available to accept call'); } diff --git a/service/src/socket/socket.test.ts b/service/src/socket/socket.test.ts index ee7aa5b..34ffc80 100644 --- a/service/src/socket/socket.test.ts +++ b/service/src/socket/socket.test.ts @@ -136,6 +136,26 @@ describe('SocketInstance', () => { createInstance().joinChat(payload); expect(mockSocket.emit).toHaveBeenCalledWith('chat-join', payload); }); + + it('drops runtime extras and does not log participant or room details', () => { + const payload = { channelID: 'chan-1', userID: 'alice', userName: 'Alice', secret: 'invite-secret' }; + createInstance().joinChat(payload); + expect(mockSocket.emit).toHaveBeenCalledWith('chat-join', { channelID: 'chan-1', userID: 'alice' }); + expect(logger.log.mock.calls).toEqual([['Initialized'], ['joinChat()']]); + }); + }); + + it.each(['sendChatMessage', 'sendWebrtcSignal'] as const)('%s only transmits declared envelope headers and preserves custom strategy data', async (method) => { + mockSocket.emit.mockImplementation((_event, _payload, ack) => ack({ id: 5, timestamp: 999, status: 'ok' })); + const data = { customCiphertext: 'opaque', nonce: 'public-nonce' }; + const envelope = { version: 1, strategy: 'custom', data, userName: 'Alice', callId: 'private-call-id' }; + await createInstance()[method](envelope); + expect(mockSocket.emit).toHaveBeenCalledWith( + method === 'sendChatMessage' ? 'chat-message' : 'webrtc-signal', + { envelope: { version: 1, strategy: 'custom', data } }, + expect.any(Function), + ); + expect(mockSocket.emit.mock.calls[0][1].envelope.data).toBe(data); }); describe('sendChatMessage()', () => { diff --git a/service/src/socket/socket.ts b/service/src/socket/socket.ts index c114710..1b6d174 100644 --- a/service/src/socket/socket.ts +++ b/service/src/socket/socket.ts @@ -65,18 +65,18 @@ export class SocketInstance { /** Join a room. Carries no key material — the shared secret never leaves the device. */ public joinChat(payload: chatJoinPayloadType): void { - this.logger.log(`joinChat(), ${JSON.stringify(payload)}`); - this.socket.emit('chat-join', payload); + this.logger.log('joinChat()'); + this.socket.emit('chat-join', { channelID: payload.channelID, userID: payload.userID }); } /** Send an already-sealed chat envelope; resolves with the server-assigned id/timestamp. */ - public sendChatMessage(envelope: EncryptionEnvelope): Promise<{ id: number; timestamp: number }> { - return this.emitWithAck<{ id: number; timestamp: number }>('chat-message', { envelope }); + public sendChatMessage({ version, strategy, data }: EncryptionEnvelope): Promise<{ id: number; timestamp: number }> { + return this.emitWithAck<{ id: number; timestamp: number }>('chat-message', { envelope: { version, strategy, data } }); } /** Send an already-sealed WebRTC signaling envelope. */ - public async sendWebrtcSignal(envelope: EncryptionEnvelope): Promise { - await this.emitWithAck<{ status: string }>('webrtc-signal', { envelope }); + public async sendWebrtcSignal({ version, strategy, data }: EncryptionEnvelope): Promise { + await this.emitWithAck<{ status: string }>('webrtc-signal', { envelope: { version, strategy, data } }); } public dispose(): void {