Chi tiết mã hóa
Signal sử dụng ECDH helper từ IGNIS Framework cho tất cả các thao tác mật mã. Helper này bọc Web Crypto API (crypto.subtle) và cung cấp giao diện cấp cao cho trao đổi khóa và mã hóa có xác thực.
1. Tham số mật mã
| Tham số | Giá trị |
|---|---|
| Đường cong | ECDH P-256 (secp256r1) |
| Dẫn xuất khóa | HKDF-SHA-256 |
| Mã hóa | AES-256-GCM |
| Kích thước IV | 12 bytes (96 bits), tạo ngẫu nhiên cho mỗi tin nhắn |
| Độ dài Tag | 128 bits |
| HKDF Salt | 32 bytes — ngẫu nhiên mặc định (xem Hành vi Salt) |
| HKDF Info | Cấu hình qua APP_ENV_WEBSOCKET_ECDH_INFO |
| Định dạng xuất khóa | Public key dạng raw, mã hóa base64 (65 bytes cho P-256 không nén) |
2. IGNIS ECDH API
Class ECDH được khởi tạo trong NxWebSocketComponent với thuật toán và chuỗi info HKDF:
// 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),
});| Phương thức | Tham số | Trả về | Mô tả |
|---|---|---|---|
generateKeyPair() | — | { keyPair, publicKeyB64 } | Tạo cặp khóa ECDH P-256 tạm thời. publicKeyB64 là public key dạng raw mã hóa base64. |
importPublicKey(opts) | { rawKeyB64 } | CryptoKey | Import public key base64 của đối tác để sử dụng trong dẫn xuất khóa. |
deriveAESKey(opts) | { privateKey, peerPublicKey, salt? } | { key: CryptoKey, salt: string } | Dẫn xuất khóa AES-256-GCM qua ECDH shared secret + HKDF-SHA-256. Trả về khóa và salt base64. Nếu không cung cấp salt, salt 32 bytes ngẫu nhiên sẽ được tạo. |
encrypt(opts) | { message, secret, opts? } | { iv, ct } | Mã hóa chuỗi plaintext. Trả về IV (12 bytes) và ciphertext dạng base64. Hỗ trợ additionalData cho AEAD. |
decrypt(opts) | { message: { iv, ct }, secret, opts? } | string | Giải mã payload { iv, ct } về plaintext. Hỗ trợ additionalData cho AEAD. |
Đặc tính chính:
- Tất cả phương thức đều async (yêu cầu Web Crypto API)
- Cặp khóa tạm thời cung cấp forward secrecy — xâm phạm một phiên không ảnh hưởng đến các phiên khác
- AES-GCM cung cấp mã hóa có xác thực — mọi sự giả mạo được phát hiện khi giải mã
- Chuỗi
hkdfInfongăn tái sử dụng khóa xuyên ngữ cảnh giữa các ứng dụng khác nhau - Tương thích với Bun, Browser, và Node.js qua
crypto.subtle
3. Hành vi HKDF Salt
Trong @venizia/ignis, deriveAESKey() tạo salt 32 bytes ngẫu nhiên khi không cung cấp tham số salt. Salt được trả về như một phần của kết quả ({ key, salt }).
Để server và client dẫn xuất cùng một khóa AES, chúng phải sử dụng cùng HKDF salt. Cách tiếp cận hiện tại:
Trao đổi salt: Server tạo salt ngẫu nhiên trong deriveAESKey(), và IGNIS WebSocketServerHelper bao gồm nó dưới dạng salt trong sự kiện connected cùng với serverPublicKey. Client sử dụng salt do server cung cấp này trong quá trình dẫn xuất khóa HKDF.
Server: deriveAESKey({ privateKey, peerPublicKey }) → { key, salt }
↓ salt gửi trong sự kiện connected
Client: HKDF(sharedBits, salt=data.salt, info=ECDH_INFO) → cùng khóa AESMã nguồn phía server trong 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
Giá trị APP_ENV_WEBSOCKET_ECDH_INFO trên server và chuỗi info HKDF trên client phải khớp chính xác. Giá trị không khớp sẽ tạo ra khóa AES khác nhau và giải mã sẽ thất bại.
4. Định dạng tin nhắn đã mã hóa
Tất cả tin nhắn ứng dụng sau handshake được bọc bởi 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 };Định dạng trên đường truyền:
{
"event": "encrypted",
"data": {
"iv": "<IV 12 bytes mã hóa base64>",
"ct": "<AES-GCM ciphertext mã hóa base64>"
}
}Plaintext bên trong ct (sau khi giải mã) là chuỗi JSON:
{
"event": "<tên-sự-kiện-thực>",
"data": { ... }
}5. Sự kiện Plaintext (Không mã hóa)
Outbound transformer bỏ qua mã hóa rõ ràng cho sự kiện connected và error:
// packages/signal/src/components/websocket.component.ts — bindOutboundTransformer()
if (event === WebSocketEvents.CONNECTED || event === WebSocketEvents.ERROR) {
return null; // null = gửi nguyên trạng (không mã hóa)
}Các sự kiện control-plane luôn được gửi dạng plaintext:
| Sự kiện | Hướng | Mục đích |
|---|---|---|
authenticate | Client → Server | JWT token + ECDH public key |
connected | Server → Client | Xác nhận xác thực + serverPublicKey + salt |
error | Server → Client | Thông báo lỗi |
heartbeat | Client → Server | Ping giữ kết nối |
join | Client → Server | Tham gia phòng |
leave | Client → Server | Rời phòng |
6. Giải mã tin nhắn đến
Message handler xác thực cấu trúc payload đã mã hóa trước khi giải mã:
// 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
Message handler hiện tại chỉ ghi log tin nhắn đã giải mã. Định tuyến tin nhắn ở cấp ứng dụng (ví dụ: chuyển tiếp đến dịch vụ khác) chưa được triển khai trong message handler. Luồng tin nhắn chính là server → client (qua REST API hoặc emitter liên dịch vụ), không phải client → server.
7. Vòng đời khóa
| Giai đoạn | Hành động |
|---|---|
| Client kết nối | Client tạo cặp khóa ECDH P-256, gửi clientPublicKey trong authenticate |
| Server handshake | Server tạo cặp khóa ECDH tạm thời, dẫn xuất khóa AES, lưu vào _clientAesKeys map |
| Phiên hoạt động | Tất cả tin nhắn gửi đi được mã hóa qua outbound transformer, tất cả tin nhắn đến được giải mã qua message handler |
| Client ngắt kết nối | clientDisconnectedFn xóa khóa AES khỏi _clientAesKeys map |
| Kết nối lại | Client tạo cặp khóa ECDH mới (forward secrecy), server dẫn xuất khóa AES mới với salt ngẫu nhiên mới |