Encryption Details
Signal uses the ECDH helper from IGNIS Framework for all cryptographic operations. The helper wraps the Web Crypto API (crypto.subtle) and provides a high-level interface for key exchange and authenticated encryption.
1. Cryptographic Parameters
| Parameter | Value |
|---|---|
| Curve | ECDH P-256 (secp256r1) |
| Key Derivation | HKDF-SHA-256 |
| Cipher | AES-256-GCM |
| IV Size | 12 bytes (96 bits), randomly generated per message |
| Tag Length | 128 bits |
| HKDF Salt | 32 bytes — random by default (see Salt Behavior) |
| HKDF Info | Configurable via APP_ENV_WEBSOCKET_ECDH_INFO |
| Key Export Format | Raw public key, base64-encoded (65 bytes for uncompressed P-256) |
2. IGNIS ECDH API
The ECDH class is instantiated in NxWebSocketComponent with an algorithm and an HKDF info string:
// packages/signal/src/components/websocket.component.ts
private readonly ecdh = new ECDH({
algorithm: 'ecdh-p256',
hkdfInfo: applicationEnvironment.get<string>(EnvironmentKeys.APP_ENV_WEBSOCKET_ECDH_INFO),
});| Method | Parameters | Returns | Description |
|---|---|---|---|
generateKeyPair() | — | { keyPair, publicKeyB64 } | Generate an ephemeral ECDH P-256 key pair. publicKeyB64 is the raw public key encoded as base64. |
importPublicKey(opts) | { rawKeyB64 } | CryptoKey | Import a peer's base64-encoded raw public key for use in key derivation. |
deriveAESKey(opts) | { privateKey, peerPublicKey, salt? } | { key: CryptoKey, salt: string } | Derive an AES-256-GCM key via ECDH shared secret + HKDF-SHA-256. Returns the key and the base64-encoded salt used. If salt is not provided, a random 32-byte salt is generated. |
encrypt(opts) | { message, secret, opts? } | { iv, ct } | Encrypt a plaintext string. Returns base64-encoded IV (12 bytes) and ciphertext. Supports optional additionalData for AEAD. |
decrypt(opts) | { message: { iv, ct }, secret, opts? } | string | Decrypt a { iv, ct } payload back to plaintext. Supports optional additionalData for AEAD. |
Key properties:
- All methods are async (Web Crypto API requirement)
- Ephemeral key pairs provide forward secrecy — compromising one session does not affect others
- AES-GCM provides authenticated encryption — any tampering is detected on decrypt
- The
hkdfInfostring prevents cross-context key reuse between different applications - Compatible with Bun, Browser, and Node.js runtimes via
crypto.subtle
3. HKDF Salt Behavior
In @venizia/ignis, deriveAESKey() generates a random 32-byte salt when no salt parameter is provided. The salt is returned as part of the result ({ key, salt }).
For the server and client to derive the same AES key, they must use the same HKDF salt. The current approach:
Salt exchange: The server generates a random salt during deriveAESKey(), and the IGNIS WebSocketServerHelper includes it as salt in the connected event alongside serverPublicKey. The client uses this server-provided salt during its own HKDF key derivation.
Server: deriveAESKey({ privateKey, peerPublicKey }) → { key, salt }
↓ salt sent in connected event
Client: HKDF(sharedBits, salt=data.salt, info=ECDH_INFO) → same AES keyThe exact server-side code in the handshake handler:
// packages/signal/src/components/websocket.component.ts — bindHandshakeHandler()
const { keyPair, publicKeyB64: serverPublicKey } = await this.ecdh.generateKeyPair();
const clientPub = await this.ecdh.importPublicKey({ rawKeyB64: clientPubB64 });
const { key: aesKey, salt } = await this.ecdh.deriveAESKey({
privateKey: keyPair.privateKey,
peerPublicKey: clientPub,
});
this._clientAesKeys.set(clientId, aesKey);
return { serverPublicKey, salt };IMPORTANT
The APP_ENV_WEBSOCKET_ECDH_INFO value on the server and the HKDF info string on the client must match exactly. Mismatched values produce different AES keys and decryption will fail.
4. Encrypted Message Format
All application messages after handshake are wrapped by the outbound transformer:
// packages/signal/src/components/websocket.component.ts — bindOutboundTransformer()
const plaintext = JSON.stringify({ event, data });
const encrypted = await this.ecdh.encrypt({ message: plaintext, secret: aesKey });
return { event: WebSocketEvents.ENCRYPTED, data: encrypted };Wire format:
{
"event": "encrypted",
"data": {
"iv": "<base64-encoded 12-byte IV>",
"ct": "<base64-encoded AES-GCM ciphertext>"
}
}The plaintext inside ct (after decryption) is a JSON string:
{
"event": "<actual-event-name>",
"data": { ... }
}5. Plaintext Events (Not Encrypted)
The outbound transformer explicitly skips encryption for connected and error events:
// packages/signal/src/components/websocket.component.ts — bindOutboundTransformer()
if (event === WebSocketEvents.CONNECTED || event === WebSocketEvents.ERROR) {
return null; // null = send as-is (no encryption)
}These control-plane events are always sent in plaintext:
| Event | Direction | Purpose |
|---|---|---|
authenticate | Client → Server | JWT token + ECDH public key |
connected | Server → Client | Authentication confirmation + serverPublicKey + salt |
error | Server → Client | Error notification |
heartbeat | Client → Server | Keep-alive ping |
join | Client → Server | Join rooms |
leave | Client → Server | Leave rooms |
6. Inbound Message Decryption
The message handler validates the encrypted payload structure before attempting decryption:
// packages/signal/src/components/websocket.component.ts — bindMessageHandler()
const encryptedPayload = message.data as IECDHEncryptedPayload | undefined;
if (!encryptedPayload?.iv || !encryptedPayload?.ct) {
logger.warn('Malformed encrypted message | id: %s', clientId);
return;
}
const plaintext = await this.ecdh.decrypt({ message: encryptedPayload, secret: aesKey });
const decryptedMessage = JSON.parse(plaintext) as IWebSocketMessage;NOTE
The message handler currently only logs the decrypted message. Application-level message routing (e.g., forwarding to other services) is not yet implemented in the message handler. The primary message flow is server → client (via REST API or cross-service emitter), not client → server.
7. Key Lifecycle
| Phase | Action |
|---|---|
| Client connects | Client generates ECDH P-256 key pair, sends clientPublicKey in authenticate |
| Server handshake | Server generates ephemeral ECDH key pair, derives AES key, stores in _clientAesKeys map |
| Active session | All outbound messages encrypted via outbound transformer, all inbound messages decrypted via message handler |
| Client disconnects | clientDisconnectedFn deletes AES key from _clientAesKeys map |
| Reconnect | Client generates new ECDH key pair (forward secrecy), server derives new AES key with new random salt |