Web Browser Client
Overview
TypeScript/JavaScript WebSocket client for web browser environments using the Web Crypto API (crypto.subtle). Works on Chrome, Firefox, Edge, Safari, and all modern browsers.
- Pure browser APIs -- Uses Web Crypto API (
crypto.subtle) and nativeWebSocket - Zero dependencies -- No external crypto libraries needed
- TypeScript-first -- Full type safety with exported interfaces
- Auto-reconnect -- Exponential backoff with forward secrecy (new ECDH keys per connection)
Two files:
| File | Purpose | Recommended location |
|---|---|---|
signal-crypto.ts | ECDH P-256 key generation, AES-256-GCM encrypt/decrypt | src/socket/crypto/signal-crypto.ts |
encrypted-ws-client.ts | WebSocket client with full lifecycle management | src/socket/encrypted-ws-client.ts |
Dependencies
No external packages required. The following browser APIs are used:
| API | Purpose | Support |
|---|---|---|
crypto.subtle (Web Crypto) | ECDH key generation, AES-256-GCM encrypt/decrypt, HKDF key derivation | Chrome 37+, Firefox 34+, Edge 12+, Safari 11+ |
WebSocket | WebSocket transport | All modern browsers |
TextEncoder / TextDecoder | UTF-8 string encoding | All modern browsers |
btoa / atob | Base64 encoding/decoding | All modern browsers |
Crypto Module (signal-crypto.ts)
Key types and functions:
| Export | Type | Description |
|---|---|---|
IECDHKeyPair | Interface | { keyPair: CryptoKeyPair; publicKeyB64: string } |
generateECDHKeyPair() | Function | Generate ephemeral ECDH P-256 key pair. Private key is non-extractable. Public key exported as raw base64 (65 bytes). |
deriveAESKey(opts) | Function | Derive AES-256-GCM key from ECDH shared secret + HKDF-SHA-256. Requires clientPrivateKey, serverPublicKeyB64, saltB64, ecdhInfo. |
IEncryptedPayload | Interface | { iv: string; ct: string } -- base64-encoded IV (12 bytes) and ciphertext (includes 128-bit auth tag). |
encrypt(opts) | Function | Encrypt plaintext string with AES-256-GCM. Random 12-byte IV per message. |
decrypt(opts) | Function | Decrypt { iv, ct } payload back to plaintext. Throws if auth tag is invalid. |
Handshake flow:
1. generateECDHKeyPair() → ephemeral P-256 key pair
2. Send publicKeyB64 in authenticate → server receives client public key
3. Server sends { serverPublicKey, salt } → client receives in "connected" event
4. deriveAESKey(clientPriv, serverPub, salt, info) → HKDF-SHA-256 → AES-256-GCM key
5. encrypt(plaintext) / decrypt(payload) → per-message encryptionClick to expand full source code
typescript
// ============================================================================
// signal-crypto.ts — ECDH P-256 + AES-256-GCM Crypto Module for Signal
// ============================================================================
// ---------- Base64 Utilities ----------
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
export function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
// ---------- ECDH Key Pair ----------
export interface IECDHKeyPair {
keyPair: CryptoKeyPair;
publicKeyB64: string;
}
/**
* Generate an ephemeral ECDH P-256 key pair.
* Private key is non-extractable for security.
* Public key is exported as raw base64 (65 bytes for uncompressed P-256).
*/
export async function generateECDHKeyPair(): Promise<IECDHKeyPair> {
const keyPair = await crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' },
false, // non-extractable private key
['deriveBits'],
);
const rawPublicKey = await crypto.subtle.exportKey('raw', keyPair.publicKey);
const publicKeyB64 = arrayBufferToBase64(rawPublicKey);
return { keyPair, publicKeyB64 };
}
// ---------- AES Key Derivation ----------
/**
* Derive an AES-256-GCM key from ECDH shared secret + HKDF-SHA-256.
*
* Server and client must use:
* - Same ECDH shared secret (from own private key + peer's public key)
* - Same HKDF salt (server generates, client receives in `connected` event)
* - Same HKDF info string (must match `APP_ENV_WEBSOCKET_ECDH_INFO` on server)
*/
export async function deriveAESKey(opts: {
clientPrivateKey: CryptoKey;
serverPublicKeyB64: string;
saltB64: string;
ecdhInfo: string;
}): Promise<CryptoKey> {
const { clientPrivateKey, serverPublicKeyB64, saltB64, ecdhInfo } = opts;
// 1. Import server's raw public key
const serverPublicKey = await crypto.subtle.importKey(
'raw',
base64ToArrayBuffer(serverPublicKeyB64),
{ name: 'ECDH', namedCurve: 'P-256' },
false,
[],
);
// 2. Derive 256 bits shared secret via ECDH
const sharedBits = await crypto.subtle.deriveBits(
{ name: 'ECDH', public: serverPublicKey },
clientPrivateKey,
256,
);
// 3. Import shared bits as HKDF key material
const hkdfKey = await crypto.subtle.importKey('raw', sharedBits, 'HKDF', false, ['deriveKey']);
// 4. Derive AES-256-GCM key via HKDF-SHA-256
const salt = new Uint8Array(base64ToArrayBuffer(saltB64));
const info = new TextEncoder().encode(ecdhInfo);
const aesKey = await crypto.subtle.deriveKey(
{ name: 'HKDF', hash: 'SHA-256', salt, info },
hkdfKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
);
return aesKey;
}
// ---------- Encrypt / Decrypt ----------
export interface IEncryptedPayload {
iv: string; // 12-byte IV, base64-encoded
ct: string; // AES-GCM ciphertext, base64-encoded (includes 128-bit auth tag)
}
/** Encrypt a plaintext string with AES-256-GCM. Random 12-byte IV per message. */
export async function encrypt(opts: {
plaintext: string;
aesKey: CryptoKey;
}): Promise<IEncryptedPayload> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(opts.plaintext);
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
opts.aesKey,
encoded,
);
return {
iv: arrayBufferToBase64(iv.buffer),
ct: arrayBufferToBase64(ciphertext),
};
}
/** Decrypt a { iv, ct } payload back to plaintext. Throws if auth tag is invalid. */
export async function decrypt(opts: {
payload: IEncryptedPayload;
aesKey: CryptoKey;
}): Promise<string> {
const iv = new Uint8Array(base64ToArrayBuffer(opts.payload.iv));
const ciphertext = base64ToArrayBuffer(opts.payload.ct);
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
opts.aesKey,
ciphertext,
);
return new TextDecoder().decode(decrypted);
}WebSocket Client (EncryptedWebSocketClient)
Production-ready TypeScript WebSocket client. Replaces the old WebSocketClientHelper. Manages the entire connection lifecycle including ECDH handshake, automatic encrypt/decrypt, heartbeat, rooms, and reconnection with forward secrecy.
Key features:
| Feature | Description |
|---|---|
| Forward secrecy | New ECDH key pair for every connection (including reconnects) |
| Auto-reconnect | Exponential backoff with configurable maxRetries |
| Heartbeat | Sends { event: "heartbeat" } every 30s via setInterval |
| Auto-join rooms | Automatically joins rooms after successful authentication |
| Event system | on()/off() handlers for decrypted events + state change callbacks |
Public API:
typescript
class EncryptedWebSocketClient {
// Lifecycle
async connect(): Promise<void>;
disconnect(): void;
destroy(): void;
// Messaging
async send(event: string, data?: unknown): Promise<void>;
// Rooms
joinRooms(rooms: string[]): void;
leaveRooms(rooms: string[]): void;
// Token
updateToken(token: string): void;
// Events
on<T>(event: string, handler: TEventHandler<T>): void;
off<T>(event: string, handler: TEventHandler<T>): void;
offAll(event: string): void;
onStateChange(handler: TStateChangeHandler): void;
// Getters
getState(): TConnectionState;
getClientId(): string | null;
getUserId(): string | null;
isEncrypted(): boolean;
isConnected(): boolean;
}Configuration (IEncryptedWSClientOptions):
typescript
interface IEncryptedWSClientOptions {
url: string; // WebSocket server URL
ecdhInfo: string; // HKDF info string (must match server)
token: string; // JWT Bearer token from @nx/identity
reconnect?: {
enabled?: boolean; // Default: true
maxRetries?: number; // Default: 5
interval?: number; // Default: 3000 (ms)
backoffMultiplier?: number; // Default: 1.5
maxInterval?: number; // Default: 30000 (ms)
};
heartbeatInterval?: number; // Default: 30000 (ms)
autoJoinRooms?: string[]; // Auto-join after auth, re-join on reconnect
logger?: IWSLogger; // Default: console
}Click to expand full source code
typescript
// ============================================================================
// encrypted-ws-client.ts — Production WebSocket Client with ECDH Encryption
// ============================================================================
import {
type IECDHKeyPair,
type IEncryptedPayload,
generateECDHKeyPair,
deriveAESKey,
encrypt,
decrypt,
} from './crypto/signal-crypto';
// ---------- Types ----------
export type TConnectionState =
| 'disconnected'
| 'connecting'
| 'connected'
| 'authenticating'
| 'authenticated'
| 'reconnecting';
export interface IEncryptedWSClientOptions {
/** WebSocket server URL, e.g. 'wss://signal.example.com/stream' */
url: string;
/** HKDF info string — must match APP_ENV_WEBSOCKET_ECDH_INFO on the server exactly */
ecdhInfo: string;
/** JWT Bearer token from @nx/identity */
token: string;
/** Reconnection configuration */
reconnect?: {
enabled?: boolean; // Auto-reconnect on unexpected close. Default: true
maxRetries?: number; // Maximum retry attempts. Default: 5
interval?: number; // Base interval between retries (ms). Default: 3000
backoffMultiplier?: number; // Exponential backoff multiplier. Default: 1.5
maxInterval?: number; // Maximum interval cap (ms). Default: 30000
};
/** Heartbeat interval (ms). Default: 30000 (30s). Server timeout is 90s. */
heartbeatInterval?: number;
/** Rooms to auto-join after authentication. Auto re-joined on reconnect. */
autoJoinRooms?: string[];
/** Logger interface. Default: console */
logger?: IWSLogger;
}
export interface IWSLogger {
info(message: string, ...args: unknown[]): void;
warn(message: string, ...args: unknown[]): void;
error(message: string, ...args: unknown[]): void;
debug(message: string, ...args: unknown[]): void;
}
export type TEventHandler<T = unknown> = (data: T) => void;
export type TStateChangeHandler = (state: TConnectionState) => void;
/** Control-plane events — always sent in plaintext (never encrypted) */
const PLAINTEXT_EVENTS = new Set([
'authenticate', 'connected', 'error', 'heartbeat', 'join', 'leave',
]);
// ---------- Class ----------
export class EncryptedWebSocketClient {
private ws: WebSocket | null = null;
// ECDH state — new key pair per connection (forward secrecy)
private ecdhKeyPair: IECDHKeyPair | null = null;
private aesKey: CryptoKey | null = null;
// Connection state
private state: TConnectionState = 'disconnected';
private clientId: string | null = null;
private userId: string | null = null;
// Reconnection state
private retryCount: number = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private forcedClose: boolean = false;
// Heartbeat
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
// Event system
private eventHandlers: Map<string, Set<TEventHandler>> = new Map();
private stateChangeHandlers: Set<TStateChangeHandler> = new Set();
// Options (see IEncryptedWSClientOptions)
private readonly url: string;
private readonly ecdhInfo: string;
private token: string;
private readonly reconnectEnabled: boolean;
private readonly maxRetries: number;
private readonly baseInterval: number;
private readonly backoffMultiplier: number;
private readonly maxInterval: number;
private readonly heartbeatInterval: number;
private readonly autoJoinRooms: string[];
private readonly logger: IWSLogger;
constructor(opts: IEncryptedWSClientOptions) {
this.url = opts.url;
this.ecdhInfo = opts.ecdhInfo;
this.token = opts.token;
const rc = opts.reconnect ?? {};
this.reconnectEnabled = rc.enabled ?? true;
this.maxRetries = rc.maxRetries ?? 5;
this.baseInterval = rc.interval ?? 3000;
this.backoffMultiplier = rc.backoffMultiplier ?? 1.5;
this.maxInterval = rc.maxInterval ?? 30_000;
this.heartbeatInterval = opts.heartbeatInterval ?? 30_000;
this.autoJoinRooms = opts.autoJoinRooms ?? [];
this.logger = opts.logger ?? console;
}
// ======================== PUBLIC API ====================================
/** Connect to the Signal WebSocket server. */
async connect(): Promise<void> {
if (this.state !== 'disconnected' && this.state !== 'reconnecting') {
this.logger.warn('Already connected or connecting');
return;
}
this.forcedClose = false;
this.setState('connecting');
try {
// 1. Generate new ECDH key pair (forward secrecy — new keys every connection)
this.ecdhKeyPair = await generateECDHKeyPair();
this.logger.debug('Generated ECDH key pair');
// 2. Open WebSocket connection
this.ws = new WebSocket(this.url);
this.ws.onopen = () => this.handleOpen();
this.ws.onmessage = (evt: MessageEvent) => this.handleMessage(evt);
this.ws.onclose = (evt: CloseEvent) => this.handleClose(evt);
this.ws.onerror = (evt: Event) => {
this.logger.error('WebSocket error', evt);
};
} catch (err) {
this.logger.error('Failed to connect', err);
this.setState('disconnected');
this.attemptReconnect();
}
}
/** Disconnect manually. Prevents auto-reconnect. */
disconnect(): void {
this.forcedClose = true;
this.cleanup();
if (this.ws) {
this.ws.close(1000, 'Client disconnect');
this.ws = null;
}
this.setState('disconnected');
}
/** Send an encrypted message. Automatically encrypts with AES-256-GCM. */
async send(event: string, data?: unknown): Promise<void> {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket is not connected');
}
// Control-plane events are sent in plaintext
if (PLAINTEXT_EVENTS.has(event)) {
this.ws.send(JSON.stringify({ event, data }));
return;
}
// Application events must be encrypted
if (!this.aesKey) {
throw new Error('Cannot send — encryption not established. Wait for connected event.');
}
const plaintext = JSON.stringify({ event, data });
const encrypted = await encrypt({ plaintext, aesKey: this.aesKey });
this.ws.send(JSON.stringify({
event: 'encrypted',
data: encrypted,
}));
this.logger.debug(`Sent encrypted [${event}]`);
}
/** Join one or more rooms. */
joinRooms(rooms: string[]): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
this.logger.warn('Cannot join rooms — not connected');
return;
}
this.ws.send(JSON.stringify({ event: 'join', data: { rooms } }));
this.logger.debug('Join rooms:', rooms);
}
/** Leave one or more rooms. */
leaveRooms(rooms: string[]): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
this.logger.warn('Cannot leave rooms — not connected');
return;
}
this.ws.send(JSON.stringify({ event: 'leave', data: { rooms } }));
this.logger.debug('Leave rooms:', rooms);
}
/** Update the JWT token (e.g. after refresh). Used on the next reconnect. */
updateToken(token: string): void {
this.token = token;
}
// ---------- Event Registration ----------
/** Register a handler for decrypted events. Handler receives the decrypted `data` payload. */
on<T = unknown>(event: string, handler: TEventHandler<T>): void {
if (!this.eventHandlers.has(event)) {
this.eventHandlers.set(event, new Set());
}
this.eventHandlers.get(event)!.add(handler as TEventHandler);
}
/** Unregister a specific handler. */
off<T = unknown>(event: string, handler: TEventHandler<T>): void {
this.eventHandlers.get(event)?.delete(handler as TEventHandler);
}
/** Unregister all handlers for a specific event. */
offAll(event: string): void {
this.eventHandlers.delete(event);
}
/** Register a connection state change handler. */
onStateChange(handler: TStateChangeHandler): void {
this.stateChangeHandlers.add(handler);
}
// ---------- Getters ----------
getState(): TConnectionState { return this.state; }
getClientId(): string | null { return this.clientId; }
getUserId(): string | null { return this.userId; }
isEncrypted(): boolean { return this.aesKey !== null; }
isConnected(): boolean { return this.state === 'authenticated'; }
// ======================== PRIVATE ======================================
/** Set state and notify all state change handlers. */
private setState(newState: TConnectionState): void {
const prev = this.state;
this.state = newState;
if (prev !== newState) {
this.logger.debug(`State: ${prev} → ${newState}`);
for (const handler of this.stateChangeHandlers) {
try { handler(newState); } catch (e) { this.logger.error('State handler error', e); }
}
}
}
/** Emit a decrypted event to registered handlers. */
private emit(event: string, data: unknown): void {
const handlers = this.eventHandlers.get(event);
if (handlers) {
for (const handler of handlers) {
try { handler(data); } catch (e) { this.logger.error(`Handler error [${event}]`, e); }
}
}
}
/** Handle WebSocket open — send authenticate with JWT + ECDH public key. */
private handleOpen(): void {
this.setState('authenticating');
this.logger.debug('WebSocket open — sending authenticate');
this.ws!.send(JSON.stringify({
event: 'authenticate',
data: {
type: 'Bearer',
token: this.token,
clientPublicKey: this.ecdhKeyPair!.publicKeyB64,
},
}));
}
/** Handle incoming WebSocket messages. */
private async handleMessage(evt: MessageEvent): Promise<void> {
try {
const { event, data } = JSON.parse(evt.data as string);
switch (event) {
case 'connected':
await this.handleConnected(data);
break;
case 'encrypted':
await this.handleEncryptedMessage(data);
break;
case 'error':
this.logger.error('Server error:', data);
this.emit('error', data);
break;
default:
// Other plaintext events
this.logger.debug(`Received plaintext [${event}]`);
this.emit(event, data);
break;
}
} catch (err) {
this.logger.error('Failed to process message', err);
}
}
/**
* Handle the `connected` event — ECDH handshake.
* Server sends: { id, userId, time, serverPublicKey, salt }
*/
private async handleConnected(data: {
id: string;
userId: string;
time: string;
serverPublicKey?: string;
salt?: string;
}): Promise<void> {
this.clientId = data.id;
this.userId = data.userId;
this.logger.info(`Authenticated: clientId=${data.id}, userId=${data.userId}`);
// Derive AES key from server's public key + salt
if (data.serverPublicKey && data.salt) {
this.aesKey = await deriveAESKey({
clientPrivateKey: this.ecdhKeyPair!.keyPair.privateKey,
serverPublicKeyB64: data.serverPublicKey,
saltB64: data.salt,
ecdhInfo: this.ecdhInfo,
});
this.logger.info('Encryption established — all messages will be encrypted');
} else {
this.logger.warn('Server did not provide serverPublicKey/salt — encryption NOT available');
}
// Start heartbeat
this.startHeartbeat();
// Auto-join rooms
if (this.autoJoinRooms.length > 0) {
this.joinRooms(this.autoJoinRooms);
}
// Reset retry count on successful authentication
this.retryCount = 0;
this.setState('authenticated');
this.emit('connected', data);
}
/** Decrypt an incoming encrypted message and emit the inner event. */
private async handleEncryptedMessage(data: IEncryptedPayload): Promise<void> {
if (!this.aesKey) {
this.logger.error('Received encrypted message but no AES key available');
return;
}
try {
const plaintext = await decrypt({ payload: data, aesKey: this.aesKey });
const inner = JSON.parse(plaintext) as { event: string; data: unknown };
this.logger.debug(`Decrypted [${inner.event}]`);
this.emit(inner.event, inner.data);
} catch (err) {
this.logger.error('Failed to decrypt message', err);
}
}
/** Handle WebSocket close. Attempt reconnect if not a forced close. */
private handleClose(evt: CloseEvent): void {
this.logger.info(`WebSocket closed: code=${evt.code}, reason=${evt.reason}`);
this.cleanup();
if (!this.forcedClose) {
this.attemptReconnect();
} else {
this.setState('disconnected');
}
}
/** Start heartbeat — send every 30s to prevent idle timeout (server timeout: 90s). */
private startHeartbeat(): void {
this.stopHeartbeat();
this.heartbeatTimer = setInterval(() => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ event: 'heartbeat' }));
}
}, this.heartbeatInterval);
}
/** Stop heartbeat timer. */
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
/**
* Attempt reconnection with exponential backoff.
* Each reconnect generates a NEW ECDH key pair (forward secrecy).
*/
private attemptReconnect(): void {
if (!this.reconnectEnabled) {
this.setState('disconnected');
return;
}
if (this.retryCount >= this.maxRetries) {
this.logger.error(`Max retries (${this.maxRetries}) exceeded — giving up`);
this.setState('disconnected');
this.emit('reconnect_failed', { retries: this.retryCount });
return;
}
this.retryCount++;
const delay = Math.min(
this.baseInterval * Math.pow(this.backoffMultiplier, this.retryCount - 1),
this.maxInterval,
);
this.logger.info(`Reconnecting in ${delay}ms (attempt ${this.retryCount}/${this.maxRetries})`);
this.setState('reconnecting');
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, delay);
}
/** Clean up connection state. Delete AES key + ECDH key pair (forward secrecy). */
private cleanup(): void {
this.stopHeartbeat();
this.aesKey = null;
this.ecdhKeyPair = null;
this.clientId = null;
this.userId = null;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
/** Full cleanup — destroy all state and clear all event handlers. */
destroy(): void {
this.disconnect();
this.eventHandlers.clear();
this.stateChangeHandlers.clear();
}
}Usage Example
typescript
const client = new EncryptedWebSocketClient({
url: 'wss://signal.example.com/stream',
ecdhInfo: 'nx-signal-e2e',
token: jwtToken,
autoJoinRooms: ['ws-default', 'ws-orders', 'ws-notifications'],
});
// Listen for events
client.on('payment.success', (data) => {
console.log('Payment succeeded:', data);
});
client.on('reconnect_failed', () => {
showToast('Connection lost. Please check your network and refresh.');
});
// Connect
await client.connect();
// Send encrypted message
await client.send('order.create', { items: [...] });
// Update token after refresh
client.updateToken(newJwtToken);
// Cleanup on unmount
client.destroy();Browser-Specific Notes
| Topic | Details |
|---|---|
| HTTPS required | Web Crypto API (crypto.subtle) is only available in secure contexts (HTTPS or localhost). |
| Non-extractable keys | CryptoKey objects created with extractable: false cannot be serialized, logged, or transferred. This is by design for security. |
| WebSocket limits | Browsers limit concurrent WebSocket connections (typically 6-30 per domain). One Signal connection per tab is recommended. |
| Tab visibility | When a tab is backgrounded, setInterval may be throttled to 1s minimum. Heartbeat (30s interval) is unaffected, but real-time message delivery may be slightly delayed. |
| Page unload | Call client.disconnect() or client.destroy() on beforeunload or component unmount to cleanly close the connection. |
| Token storage | Store JWT tokens securely. httpOnly cookies preferred, sessionStorage acceptable. Avoid localStorage for sensitive tokens. |