Hướng dẫn tích hợp Client
Phần này cung cấp hướng dẫn production-ready để kết nối đến Signal WebSocket server. Có hai loại client riêng biệt — chúng không chia sẻ bất kỳ code nào với nhau:
| Client | Ngôn ngữ | Crypto | WebSocket | Hướng dẫn |
|---|---|---|---|---|
| Web Browser | TypeScript/JavaScript | Web Crypto API (crypto.subtle) | window.WebSocket | Web Browser Guide |
| Tauri Android | Rust (main process) | p256 + aes-gcm + hkdf crates | tokio-tungstenite | Tauri Android Guide |
Xem thêm: Tích hợp liên dịch vụ & Kiểm thử
Vòng đời kết nối
Các bước chi tiết:
| # | Giai đoạn | Mô tả | Timeout |
|---|---|---|---|
| 1 | CONNECTING | new WebSocket(url) mở kết nối TCP | Mặc định trình duyệt (~30s) |
| 2 | CONNECTED | onopen kích hoạt. Client tạo cặp khóa ECDH P-256. | -- |
| 3 | AUTHENTICATING | Client gửi authenticate với { type: "Bearer", token, clientPublicKey } | Server đóng sau 5 giây nếu không nhận authenticate |
| 4 | ECDH Handshake | Server xác thực JWT, tạo cặp khóa ECDH riêng, dẫn xuất khóa AES-256-GCM với HKDF salt ngẫu nhiên 32 bytes | -- |
| 5 | AUTHENTICATED | Server gửi connected { id, userId, time, serverPublicKey, salt }. Client import public key server, dẫn xuất cùng khóa AES sử dụng salt của server. | -- |
| 6 | Encrypted | Tất cả tin nhắn tiếp theo bọc trong { event: "encrypted", data: { iv, ct } }. Heartbeat mỗi 30s (plaintext). | Heartbeat timeout: 90 giây |
| 7 | Disconnect | onclose kích hoạt. Khóa AES bị xóa. Client có thể tự động kết nối lại (cặp khóa ECDH mới mỗi lần = forward secrecy). | -- |
Hành vi kết nối lại
Khi kết nối WebSocket rớt bất ngờ, client tự động kết nối lại với forward secrecy:
| Bước | Mô tả |
|---|---|
| 1 | Kết nối rớt → onclose kích hoạt |
| 2 | cleanup() — xóa khóa AES, cặp khóa ECDH, dừng heartbeat |
| 3 | Chờ interval * backoffMultiplier^(retryCount-1) ms (exponential backoff) |
| 4 | connect() — tạo cặp khóa ECDH mới (forward secrecy) |
| 5 | Mở WebSocket mới, gửi authenticate với cùng JWT token + clientPublicKey mới |
| 6 | Server tạo cặp khóa ECDH mới, dẫn xuất khóa AES mới với salt ngẫu nhiên mới |
| 7 | Client dẫn xuất khóa AES mới từ public key server mới + salt mới |
| 8 | Auto-join các phòng đã cấu hình — các phòng tự động được đăng ký lại |
| 9 | retryCount reset về 0 khi xác thực thành công |
Ví dụ exponential backoff (mặc định: interval=3000, multiplier=1.5, max=30000):
| Lần thử # | Độ trễ |
|---|---|
| 1 | 3.000 ms |
| 2 | 4.500 ms |
| 3 | 6.750 ms |
| 4 | 10.125 ms |
| 5 | 15.187 ms |
| 6+ | 30.000 ms (giới hạn) |
Sau maxRetries (mặc định: 5) lần thất bại liên tiếp, client emit reconnect_failed và dừng. Ứng dụng có thể lắng nghe:
client.on('reconnect_failed', () => {
showToast('Mất kết nối. Vui lòng kiểm tra mạng và làm mới trang.');
});Mẫu nhắn tin theo phòng
Phòng cho phép gửi tin nhắn có mục tiêu. Server tự động mã hóa tin nhắn cho từng client qua outbound transformer.
| Phòng | Mục đích | Ai tham gia |
|---|---|---|
ws-default | Thông báo chung | Tất cả client đã xác thực |
ws-orders | Cập nhật trạng thái đơn hàng | POS client, admin dashboard |
ws-notifications | Push notifications | Tất cả client |
ws-payment | Sự kiện thanh toán (thành công, thất bại, hoàn tiền) | POS client |
ws-test-payment | Kiểm thử thanh toán | Chỉ development |
Mẫu: Đăng ký khi mount, hủy khi unmount
// Join rooms when the component mounts
useEffect(() => {
if (client?.isConnected()) {
client.joinRooms(['ws-orders']);
}
return () => {
client?.leaveRooms(['ws-orders']);
};
}, [client?.isConnected()]);Mẫu: Auto-join qua cấu hình
// Các phòng chỉ định khi khởi tạo tự động join khi connect và re-join khi reconnect
const client = new EncryptedWebSocketClient({
url: '...',
ecdhInfo: '...',
token: '...',
autoJoinRooms: ['ws-default', 'ws-orders', 'ws-notifications'],
});Ví dụ REST API
REST API được các dịch vụ backend sử dụng để gửi tin nhắn đến WebSocket client. Server xử lý mã hóa tự động — người gọi REST gửi plaintext, outbound transformer mã hóa cho từng client đích.
Kiểm tra trạng thái Server (không cần xác thực)
curl http://localhost:3500/v1/api/socket/websocket/clients/status
# Phản hồi: { "isReady": true, "clientCount": 5 }Liệt kê Client đang kết nối
curl http://localhost:3500/v1/api/socket/websocket/clients \
-H "Authorization: Bearer <jwt-token>"
# Phản hồi: { "clients": [{ "id": "abc", "state": "authenticated", "encrypted": true, ... }] }Broadcast đến tất cả Client
curl -X POST http://localhost:3500/v1/api/socket/websocket/clients/broadcast \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <jwt-token>" \
-d '{ "topic": "order.updated", "data": { "orderId": "12345" } }'Gửi đến một phòng
curl -X POST http://localhost:3500/v1/api/socket/websocket/clients/rooms/ws-orders/send \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <jwt-token>" \
-d '{ "topic": "order.new", "data": { "orderId": "67890" } }'Gửi đến Client cụ thể
curl -X POST http://localhost:3500/v1/api/socket/websocket/clients/{clientId}/send \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <jwt-token>" \
-d '{ "topic": "notification", "data": { "message": "Đơn hàng của bạn đã sẵn sàng" } }'Buộc ngắt kết nối Client
curl -X POST http://localhost:3500/v1/api/socket/websocket/clients/{clientId}/disconnect \
-H "Authorization: Bearer <jwt-token>"Gỡ lỗi & Xử lý sự cố
| Triệu chứng | Nguyên nhân | Cách sửa |
|---|---|---|
Nhận connected nhưng không mã hóa | Server gửi connected không có serverPublicKey / salt | Kiểm tra requireEncryption: true trong NxWebSocketComponent. Đảm bảo APP_ENV_WEBSOCKET_ECDH_INFO được đặt trên server. |
| Giải mã thất bại sau handshake thành công | ECDH info không khớp giữa client và server | Xác nhận VITE_SIGNAL_ECDH_INFO khớp chính xác APP_ENV_WEBSOCKET_ECDH_INFO (phân biệt hoa thường). |
| Kết nối đóng ngay (code 4004) | Thiếu clientPublicKey trong authenticate payload | Đảm bảo generateECDHKeyPair() được gọi trước khi kết nối và publicKeyB64 được gửi đi. |
| Kết nối đóng sau 5 giây (code 4001) | Client không gửi authenticate kịp | Kiểm tra handler onopen gửi authenticate ngay lập tức. |
| Kết nối đóng sau 90 giây | Thiếu heartbeat | Xác nhận heartbeat chạy mỗi 30 giây. Kiểm tra startHeartbeat() được gọi sau connected. |
WebSocket error không chi tiết | Lỗi mạng, server down, hoặc lỗi TLS | Kiểm tra server đang chạy. Với wss://, xác nhận chứng chỉ TLS hợp lệ. Kiểm tra firewall/proxy. |
| Nhận tin nhắn nhưng handler không gọi | Tên sự kiện không khớp | Payload giải mã có { event, data }. Xác nhận tên event khớp server gửi (ví dụ: payment.success chứ không phải payment-success). |
| Vòng lặp reconnect — xác thực xong ngắt ngay | JWT token hết hạn | Gọi client.updateToken(newToken) với token mới trước/trong reconnect. |
Cannot send — encryption not established | Gửi trước khi ECDH handshake hoàn thành | Đợi state === 'authenticated' trước khi gọi send(). Dùng handler sự kiện connected. |
Bật debug logging:
const client = new EncryptedWebSocketClient({
url: '...',
ecdhInfo: '...',
token: '...',
logger: {
info: (...args) => console.log('[Signal]', ...args),
warn: (...args) => console.warn('[Signal]', ...args),
error: (...args) => console.error('[Signal]', ...args),
debug: (...args) => console.debug('[Signal]', ...args),
},
});Lưu ý bảo mật
| Quy tắc | Chi tiết |
|---|---|
| Không bao giờ log khóa AES | Đối tượng CryptoKey không thể trích xuất (non-extractable) theo thiết kế. Không cố serialize hoặc log. |
| Không bao giờ tái sử dụng cặp khóa ECDH | Tạo cặp khóa mới cho mỗi kết nối (bao gồm reconnect). Đảm bảo forward secrecy. |
| Không bao giờ bỏ qua salt | Luôn sử dụng salt từ server trong sự kiện connected. Không có nó, client và server dẫn xuất khóa AES khác nhau. |
| Lưu trữ token | Lưu JWT token an toàn. Browser: httpOnly cookies ưu tiên, sessionStorage chấp nhận được. Tránh localStorage cho token nhạy cảm. Tauri: dùng Tauri secure storage plugin hoặc OS keychain. |
| Xác thực origin | Signal server nên xác thực WebSocket upgrade origins trong production để ngăn cross-site WebSocket hijacking. |
| TLS trong production | Luôn dùng wss:// (WebSocket Secure) trong production. Plain ws:// chỉ chấp nhận cho development local. |
| Dọn dẹp khóa khi ngắt kết nối | EncryptedWebSocketClient.cleanup() đặt aesKey = null và ecdhKeyPair = null mỗi lần ngắt kết nối. Khóa phiên không thể dùng sau ngắt kết nối. |
Tổng kết lưu ý quan trọng
| Chủ đề | Chi tiết |
|---|---|
| ECDH Info phải khớp | Chuỗi HKDF info trên client phải khớp APP_ENV_WEBSOCKET_ECDH_INFO trên server. Giá trị không khớp tạo khóa AES khác nhau và giải mã thất bại. |
| HKDF Salt phải khớp | Client phải sử dụng giá trị salt từ sự kiện connected. Server tạo salt 32 bytes ngẫu nhiên trong deriveAESKey() và bao gồm trong phản hồi handshake. |
| Yêu cầu JWT token | Lấy JWT hợp lệ từ dịch vụ Identity (@nx/identity) trước khi kết nối. Xác thực không có token hợp lệ bị từ chối. |
| Mã hóa bắt buộc | Sau handshake, tất cả tin nhắn ứng dụng phải dùng event: "encrypted". Outbound transformer mã hóa tin nhắn server-to-client tự động. |
| Forward secrecy | Mỗi kết nối tạo cặp khóa ECDH tạm thời mới. Khóa phiên không bao giờ tái sử dụng qua các lần kết nối lại. |
| Heartbeat | Gửi { event: "heartbeat" } mỗi 30 giây để tránh timeout rảnh (mặc định heartbeat timeout: 90 giây). |
| Auth timeout | Client có 5 giây để gửi sự kiện authenticate sau khi kết nối, nếu không bị ngắt. |
| Sự kiện plaintext | authenticate, connected, error, heartbeat, join, leave luôn gửi plaintext. Tất cả sự kiện khác mã hóa. |
| Re-join phòng | Sau khi kết nối lại, phòng phải được join lại. Cấu hình auto-join rooms (autoJoinRooms trong TypeScript, auto_join_rooms trong Rust) xử lý tự động. |
| Refresh token | Gọi client.updateToken(newToken) nếu JWT được refresh. Token mới dùng cho lần reconnect tiếp theo. |