Tauri Android Client
Tổng quan
Kết nối Signal WebSocket được quản lý hoàn toàn trong Rust main process qua Tauri plugin (tauri-plugin-signal). Cách tiếp cận này cung cấp:
- Tồn tại qua WebView recreation — process Rust tồn tại xuyên suốt vòng đời activity Android
- Hiệu năng native — ECDH + AES-256-GCM qua Rust crypto crates (không overhead Web Crypto)
- Chịu lỗi mạng — Logic reconnection ở mức Rust xử lý mạng di động không ổn định
- Bảo mật ở mức process — Private key không bao giờ vượt qua ranh giới Rust <-> WebView IPC
Frontend (React trong WebView) giao tiếp với Rust plugin qua Tauri commands và events.
Dependencies (Cargo.toml)
toml
[dependencies]
tauri = { version = "2" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
tokio = { version = "1", features = ["full"] }
futures-util = { version = "0.3", features = ["sink"] }
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
p256 = { version = "0.13", features = ["ecdh"] }
aes-gcm = "0.10"
hkdf = "0.12"
sha2 = "0.10"
rand = "0.8"
base64 = "0.22"
log = "0.4"Cấu trúc file Plugin
apps/sale-main/src-tauri/tauri-plugin-signal/
├── Cargo.toml # Dependencies (xem trên)
├── build.rs # Đăng ký tên command
├── permissions/
│ └── default.toml # Cấu hình permission
└── src/
├── lib.rs # Khởi tạo plugin + SignalExt trait
├── error.rs # Kiểu lỗi (thiserror + Serialize)
├── models.rs # Serde request/response structs
├── commands.rs # Tauri #[command] handlers
├── crypto.rs # ECDH P-256 + AES-256-GCM (Rust native)
├── client.rs # SignalWebSocketClient (tokio-tungstenite)
├── desktop.rs # Impl dành cho desktop (tùy chọn)
└── mobile.rs # Impl dành cho mobile (Android/iOS)Crypto Module (crypto.rs)
Tương đương Rust của signal-crypto.ts. Sử dụng p256 + aes-gcm + hkdf thay vì Web Crypto API.
Các struct chính:
| Struct | Mô tả |
|---|---|
EcdhKeyPair | Cặp khóa ECDH P-256 tạm thời. generate() tạo cặp mới, derive_aes_key() dẫn xuất khóa AES từ shared secret + HKDF. |
AesSessionKey | Khóa phiên AES-256-GCM. encrypt() mã hóa plaintext với IV ngẫu nhiên 12 bytes, decrypt() giải mã payload { iv, ct }. |
EncryptedPayload | Cấu trúc { iv: String, ct: String } (base64) — tương thích với format server. |
Luồng handshake trong Rust:
1. EcdhKeyPair::generate() → tạo ECDH P-256 ephemeral key pair
2. Gửi public_key_b64 trong authenticate msg → server nhận public key client
3. Server gửi { serverPublicKey, salt } → client nhận trong "connected" event
4. ecdh.derive_aes_key(spk, salt, info) → ECDH → HKDF-SHA-256 → AES-256-GCM key
5. aes_key.encrypt(plaintext) / decrypt(payload) → mã hóa/giải mã tin nhắnNhấn để xem source code đầy đủ
rust
// ============================================================================
// crypto.rs — ECDH P-256 + AES-256-GCM for Signal WebSocket encryption
// ============================================================================
//
// Rust equivalent of signal-crypto.ts (Section 13.2).
// Uses p256 + aes-gcm + hkdf crates instead of Web Crypto API.
//
// The handshake flow:
// 1. EcdhKeyPair::generate() → ephemeral P-256 key pair
// 2. Send public_key_b64 to server → in authenticate message
// 3. Server responds with { serverPublicKey, salt } in "connected" event
// 4. ecdh.derive_aes_key(spk, salt, info) → HKDF-SHA-256 → AES-256-GCM key
// 5. aes_key.encrypt() / decrypt() → per-message encryption
// ============================================================================
use aes_gcm::{
aead::{Aead, KeyInit, OsRng},
Aes256Gcm, Nonce,
};
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use hkdf::Hkdf;
use p256::{ecdh::EphemeralSecret, EncodedPoint, PublicKey};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use crate::error::Error;
// ---------- Encrypted Payload ----------
/// Wire format for encrypted messages — matches the server's { iv, ct } JSON structure.
/// Both fields are base64-encoded.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EncryptedPayload {
/// 12-byte IV (nonce) encoded as base64
pub iv: String,
/// AES-256-GCM ciphertext (includes 128-bit auth tag) encoded as base64
pub ct: String,
}
// ---------- ECDH Key Pair ----------
/// Ephemeral ECDH P-256 key pair.
///
/// A new key pair is generated for **every** connection (including reconnects)
/// to provide forward secrecy. The private key (EphemeralSecret) is consumed
/// during key derivation and cannot be reused.
pub struct EcdhKeyPair {
secret: Option<EphemeralSecret>,
pub public_key_b64: String,
}
impl EcdhKeyPair {
/// Generate a new ephemeral ECDH P-256 key pair.
///
/// The public key is exported as uncompressed point (65 bytes) in base64.
/// The private key is non-extractable by design (EphemeralSecret is consumed on use).
pub fn generate() -> Self {
let secret = EphemeralSecret::random(&mut OsRng);
let public_key = secret.public_key();
let encoded = EncodedPoint::from(public_key);
let public_key_b64 = B64.encode(encoded.as_bytes());
Self {
secret: Some(secret),
public_key_b64,
}
}
/// Derive an AES-256-GCM session key from the ECDH shared secret + HKDF.
///
/// This **consumes** the ephemeral secret (it cannot be used again).
///
/// # Arguments
/// * `server_public_key_b64` — Server's public key from the `connected` event (base64, 65 bytes uncompressed)
/// * `salt_b64` — Server-provided HKDF salt from the `connected` event (base64, 32 bytes)
/// * `ecdh_info` — HKDF info string — must match `APP_ENV_WEBSOCKET_ECDH_INFO` on the server
pub fn derive_aes_key(
&mut self,
server_public_key_b64: &str,
salt_b64: &str,
ecdh_info: &str,
) -> Result<AesSessionKey, Error> {
// Take the ephemeral secret (can only be used once)
let secret = self
.secret
.take()
.ok_or_else(|| Error::Crypto("ECDH secret already consumed".into()))?;
// 1. Import server's public key from base64
let server_pk_bytes = B64
.decode(server_public_key_b64)
.map_err(|e| Error::Crypto(format!("Invalid server public key base64: {e}")))?;
let server_encoded = EncodedPoint::from_bytes(&server_pk_bytes)
.map_err(|e| Error::Crypto(format!("Invalid server public key point: {e}")))?;
let server_public_key = PublicKey::from_encoded_point(&server_encoded)
.map_err(|_| Error::Crypto("Invalid server public key".into()))?;
// 2. ECDH: derive shared secret
let shared_secret = secret.diffie_hellman(&server_public_key);
// 3. HKDF-SHA-256: derive 256-bit AES key from shared secret
let salt_bytes = B64
.decode(salt_b64)
.map_err(|e| Error::Crypto(format!("Invalid salt base64: {e}")))?;
let hkdf = Hkdf::<Sha256>::new(Some(&salt_bytes), shared_secret.raw_secret_bytes());
let mut aes_key_bytes = [0u8; 32];
hkdf.expand(ecdh_info.as_bytes(), &mut aes_key_bytes)
.map_err(|e| Error::Crypto(format!("HKDF expand failed: {e}")))?;
// 4. Create AES-256-GCM cipher from derived key
let cipher = Aes256Gcm::new_from_slice(&aes_key_bytes)
.map_err(|e| Error::Crypto(format!("AES key init failed: {e}")))?;
Ok(AesSessionKey { cipher })
}
}
// ---------- AES Session Key ----------
/// AES-256-GCM session key for encrypting/decrypting messages.
///
/// Created by `EcdhKeyPair::derive_aes_key()` after the ECDH handshake.
/// Dropped on disconnect to ensure session keys are not reused.
pub struct AesSessionKey {
cipher: Aes256Gcm,
}
impl AesSessionKey {
/// Encrypt a plaintext string using AES-256-GCM with a random 12-byte IV.
///
/// Returns an `EncryptedPayload { iv, ct }` with base64-encoded fields,
/// compatible with the server's expected format.
pub fn encrypt(&self, plaintext: &str) -> Result<EncryptedPayload, Error> {
let mut iv_bytes = [0u8; 12];
OsRng.fill_bytes(&mut iv_bytes);
let nonce = Nonce::from_slice(&iv_bytes);
let ciphertext = self
.cipher
.encrypt(nonce, plaintext.as_bytes())
.map_err(|e| Error::Crypto(format!("Encryption failed: {e}")))?;
Ok(EncryptedPayload {
iv: B64.encode(iv_bytes),
ct: B64.encode(ciphertext),
})
}
/// Decrypt an `EncryptedPayload { iv, ct }` back to a plaintext string.
///
/// Returns an error if the auth tag is invalid (tampering detected)
/// or if the base64 decoding fails.
pub fn decrypt(&self, payload: &EncryptedPayload) -> Result<String, Error> {
let iv_bytes = B64
.decode(&payload.iv)
.map_err(|e| Error::Crypto(format!("Invalid IV base64: {e}")))?;
let ct_bytes = B64
.decode(&payload.ct)
.map_err(|e| Error::Crypto(format!("Invalid ciphertext base64: {e}")))?;
let nonce = Nonce::from_slice(&iv_bytes);
let plaintext = self
.cipher
.decrypt(nonce, ct_bytes.as_ref())
.map_err(|e| Error::Crypto(format!("Decryption failed: {e}")))?;
String::from_utf8(plaintext).map_err(|e| Error::Crypto(format!("Invalid UTF-8: {e}")))
}
}WebSocket Client (client.rs)
SignalClient — struct chính quản lý kết nối WebSocket. Chạy trên tokio task, giao tiếp với Tauri commands qua channels.
Tính năng chính:
| Tính năng | Mô tả |
|---|---|
| Forward secrecy | Cặp khóa ECDH mới cho mỗi kết nối (bao gồm reconnect) |
| Auto-reconnect | Exponential backoff với maxRetries có thể cấu hình |
| Heartbeat | Gửi { event: "heartbeat" } mỗi 30s qua tokio interval |
| Auto-join rooms | Tự động join rooms sau khi xác thực thành công |
| Tauri events | Emit signal:* events cho frontend qua app.emit() |
Public API:
rust
impl SignalClient {
pub fn new(config: &SignalPluginConfig) -> Self;
pub async fn connect<R: Runtime>(&self, app: AppHandle<R>, token: String) -> Result<()>;
pub async fn send(&self, event: &str, data: Value) -> Result<()>;
pub async fn join_rooms(&self, rooms: Vec<String>) -> Result<()>;
pub async fn leave_rooms(&self, rooms: Vec<String>) -> Result<()>;
pub async fn disconnect(&self);
pub async fn update_token(&self, token: String);
pub async fn get_state(&self) -> ConnectionState;
pub async fn get_client_id(&self) -> Option<String>;
}Nhấn để xem source code đầy đủ
rust
// ============================================================================
// client.rs — SignalWebSocketClient for Tauri Android
// ============================================================================
//
// Manages the full WebSocket lifecycle:
// - ECDH P-256 handshake (new key pair per connection = forward secrecy)
// - AES-256-GCM encrypt/decrypt for all data messages
// - Heartbeat (30s interval, 90s server timeout)
// - Auto-reconnect with exponential backoff
// - Auto-join rooms after authentication
// - Tauri event emission to the frontend WebView
//
// Architecture:
// SignalClient (public API, Arc-shared state)
// └── connection_loop() — spawned on tokio, owns the WebSocket stream
// ├── send heartbeat every 30s
// ├── receive messages → decrypt → emit Tauri events
// └── on disconnect → cleanup → reconnect with new ECDH keys
// ============================================================================
use std::sync::Arc;
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tauri::{AppHandle, Emitter, Runtime};
use tokio::sync::{watch, Mutex, RwLock};
use tokio::time::{interval, timeout};
use tokio_tungstenite::{connect_async, tungstenite::Message};
use crate::crypto::{AesSessionKey, EcdhKeyPair, EncryptedPayload};
use crate::error::Error;
use crate::models::SignalPluginConfig;
// ---------- Connection State ----------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ConnectionState {
Disconnected,
Connecting,
Connected,
Authenticating,
Authenticated,
Reconnecting,
}
// ---------- SignalClient ----------
pub struct SignalClient {
// Configuration (immutable after construction)
server_url: String,
ecdh_info: String,
auto_join_rooms: Vec<String>,
max_retries: u32,
base_interval_ms: u64,
backoff_multiplier: f64,
max_interval_ms: u64,
heartbeat_interval_ms: u64,
// Mutable state (protected by locks)
state: RwLock<ConnectionState>,
client_id: RwLock<Option<String>>,
token: RwLock<String>,
aes_key: RwLock<Option<Arc<AesSessionKey>>>,
// Channel to signal the connection loop to stop
shutdown_tx: Mutex<Option<watch::Sender<bool>>>,
// Channel to send outbound messages from commands to the connection loop
outbound_tx: Mutex<Option<tokio::sync::mpsc::Sender<String>>>,
}
impl SignalClient {
pub fn new(config: &SignalPluginConfig) -> Self {
Self {
server_url: config.server_url.clone(),
ecdh_info: config.ecdh_info.clone(),
auto_join_rooms: config.auto_join_rooms.clone(),
max_retries: config.max_retries.unwrap_or(20),
base_interval_ms: config.base_interval_ms.unwrap_or(2000),
backoff_multiplier: config.backoff_multiplier.unwrap_or(1.5),
max_interval_ms: config.max_interval_ms.unwrap_or(30_000),
heartbeat_interval_ms: config.heartbeat_interval_ms.unwrap_or(30_000),
state: RwLock::new(ConnectionState::Disconnected),
client_id: RwLock::new(None),
token: RwLock::new(String::new()),
aes_key: RwLock::new(None),
shutdown_tx: Mutex::new(None),
outbound_tx: Mutex::new(None),
}
}
/// Connect to the Signal WebSocket server.
///
/// Spawns a tokio task that owns the WebSocket stream. The task runs
/// the connection loop with auto-reconnect and forward secrecy.
pub async fn connect<R: Runtime>(
&self,
app: AppHandle<R>,
token: String,
) -> Result<(), Error> {
// Store the token
*self.token.write().await = token;
// Create shutdown channel
let (shutdown_tx, shutdown_rx) = watch::channel(false);
*self.shutdown_tx.lock().await = Some(shutdown_tx);
// Create outbound message channel
let (outbound_tx, outbound_rx) = tokio::sync::mpsc::channel::<String>(100);
*self.outbound_tx.lock().await = Some(outbound_tx);
// Clone what the spawned task needs
let server_url = self.server_url.clone();
let ecdh_info = self.ecdh_info.clone();
let auto_join_rooms = self.auto_join_rooms.clone();
let max_retries = self.max_retries;
let base_interval_ms = self.base_interval_ms;
let backoff_multiplier = self.backoff_multiplier;
let max_interval_ms = self.max_interval_ms;
let heartbeat_interval_ms = self.heartbeat_interval_ms;
// Shared state references (via Arc in the actual plugin; simplified here)
let state = &self.state;
let client_id_lock = &self.client_id;
let token_lock = &self.token;
let aes_key_lock = &self.aes_key;
// NOTE: In the actual Tauri plugin, this spawns a detached tokio task.
// The task runs the full connection loop with reconnection logic.
// Simplified pseudocode shown here — see the full plugin for production code.
info!("[Signal] Starting connection to {}", server_url);
// The connection loop runs until shutdown or max retries exceeded.
// Each iteration:
// 1. Generate new ECDH key pair (forward secrecy)
// 2. Connect WebSocket
// 3. Send authenticate { type: "Bearer", token, clientPublicKey }
// 4. Wait for "connected" event → derive AES key
// 5. Start heartbeat + message receive loop
// 6. On disconnect → cleanup → exponential backoff → retry
Ok(())
}
/// Send an encrypted message to the server.
///
/// The message is encrypted with AES-256-GCM using the session key,
/// then sent as `{ event: "encrypted", data: { iv, ct } }`.
pub async fn send(&self, event: &str, data: Value) -> Result<(), Error> {
let aes_key = self.aes_key.read().await;
let aes_key = aes_key
.as_ref()
.ok_or_else(|| Error::NotConnected("Encryption not established".into()))?;
let inner = json!({ "event": event, "data": data });
let payload = aes_key.encrypt(&inner.to_string())?;
let msg = json!({
"event": "encrypted",
"data": { "iv": payload.iv, "ct": payload.ct }
});
if let Some(tx) = self.outbound_tx.lock().await.as_ref() {
tx.send(msg.to_string())
.await
.map_err(|e| Error::Send(format!("Failed to send: {e}")))?;
}
Ok(())
}
/// Join one or more rooms. Sent as plaintext control event.
pub async fn join_rooms(&self, rooms: Vec<String>) -> Result<(), Error> {
let msg = json!({ "event": "join", "data": { "rooms": rooms } });
if let Some(tx) = self.outbound_tx.lock().await.as_ref() {
tx.send(msg.to_string()).await.ok();
}
Ok(())
}
/// Leave one or more rooms. Sent as plaintext control event.
pub async fn leave_rooms(&self, rooms: Vec<String>) -> Result<(), Error> {
let msg = json!({ "event": "leave", "data": { "rooms": rooms } });
if let Some(tx) = self.outbound_tx.lock().await.as_ref() {
tx.send(msg.to_string()).await.ok();
}
Ok(())
}
/// Disconnect manually. Prevents auto-reconnect.
pub async fn disconnect(&self) {
if let Some(tx) = self.shutdown_tx.lock().await.take() {
let _ = tx.send(true);
}
self.cleanup().await;
info!("[Signal] Disconnected (manual)");
}
/// Update the JWT token. Used on the next reconnect attempt.
pub async fn update_token(&self, token: String) {
*self.token.write().await = token;
}
/// Get the current connection state.
pub async fn get_state(&self) -> ConnectionState {
self.state.read().await.clone()
}
/// Get the client ID assigned by the server (after authentication).
pub async fn get_client_id(&self) -> Option<String> {
self.client_id.read().await.clone()
}
// ======================== PRIVATE ========================================
/// Clean up session state. Called on every disconnect (intentional or not).
/// Drops the AES key and resets connection state.
async fn cleanup(&self) {
*self.aes_key.write().await = None;
*self.client_id.write().await = None;
*self.state.write().await = ConnectionState::Disconnected;
}
}Tauri Plugin & Commands
Plugin đăng ký SignalClient vào Tauri state và expose các command cho frontend.
lib.rs — Khởi tạo plugin:
rust
// SignalExt trait cho phép truy cập plugin từ bất kỳ đâu trong app
pub trait SignalExt<R: Runtime> {
fn signal(&self) -> &SignalPlugin;
}
pub fn init<R: Runtime>(config: SignalPluginConfig) -> TauriPlugin<R> {
Builder::new("signal")
.invoke_handler(tauri::generate_handler![
commands::connect,
commands::disconnect,
commands::send_message,
commands::join_rooms,
commands::leave_rooms,
commands::get_state,
commands::get_client_id,
commands::update_token,
])
.setup(|app, _api| {
let client = Arc::new(SignalClient::new(&config));
app.manage(SignalPlugin { client });
Ok(())
})
.build()
}commands.rs — 8 Tauri commands:
| Command | Mô tả |
|---|---|
connect | Spawn connection loop trên tokio task background |
disconnect | Ngắt kết nối thủ công |
send_message | Mã hóa và gửi tin nhắn |
join_rooms | Tham gia phòng (plaintext control event) |
leave_rooms | Rời phòng |
get_state | Lấy trạng thái kết nối hiện tại |
get_client_id | Lấy client ID (sau khi xác thực) |
update_token | Cập nhật JWT token cho lần reconnect tiếp |
models.rs — SignalPluginConfig:
rust
pub struct SignalPluginConfig {
pub server_url: String, // URL WebSocket server
pub ecdh_info: String, // HKDF info string (phải khớp server)
pub auto_join_rooms: Vec<String>, // Rooms tự động join
pub max_retries: Option<u32>, // Mặc định: 20 (mobile)
pub base_interval_ms: Option<u64>, // Mặc định: 2000 (mobile)
pub backoff_multiplier: Option<f64>, // Mặc định: 1.5
pub max_interval_ms: Option<u64>, // Mặc định: 30_000
pub heartbeat_interval_ms: Option<u64>, // Mặc định: 30_000
}Đăng ký Plugin
rust
// apps/sale-main/src-tauri/src/application/application.rs
fn bind_plugins<R: tauri::Runtime>(
&self,
builder: tauri::Builder<R>,
config: &tauri::Config,
) -> tauri::Builder<R> {
// ... existing plugins ...
let builder = builder
.plugin(tauri_plugin_signal::init(tauri_plugin_signal::SignalPluginConfig {
server_url: "wss://develop.nx-signal.bana.nexpando.vn/stream".into(),
ecdh_info: "nx-signal-e2e".into(),
auto_join_rooms: vec!["ws-default".into(), "ws-notifications".into()],
max_retries: Some(20), // Cao hơn cho mạng di động
base_interval_ms: Some(2000), // Retry nhanh hơn cho mobile
backoff_multiplier: Some(1.5),
max_interval_ms: Some(30_000),
heartbeat_interval_ms: Some(30_000),
}));
builder
}Lưu ý dành riêng cho Android
| Vấn đề | Khuyến nghị |
|---|---|
| Vòng đời Activity | Rust process tồn tại qua activity recreation. Không cần xử lý onPause/onResume — tokio tasks tiếp tục chạy. Chỉ gọi disconnect khi logout rõ ràng. |
| Thay đổi mạng | Sử dụng @tauri-apps/plugin-os để phát hiện thay đổi kết nối. Khi mạng phục hồi, reconnect loop của Rust tự xử lý. |
| Reconnect mạnh hơn | Kết nối di động rớt thường xuyên. Cấu hình mặc định dùng maxRetries: 20 và baseIntervalMs: 2000 (nhanh hơn desktop). |
| Pin | Heartbeat interval 30s chấp nhận được. Không giảm dưới 15s để tránh hao pin. |
| Doze mode | Android Doze giới hạn mạng background. WebSocket tokio task sẽ ngắt, nhưng reconnect loop tự phục hồi khi app quay lại foreground. |
| Server URL | Luôn dùng wss:// (TLS). Android 9+ chặn cleartext HTTP/WS theo mặc định. |
| Chứng chỉ TLS | tokio-tungstenite với feature native-tls sử dụng certificate store hệ thống. Cert tự ký cần cấu hình riêng. |
Cấu trúc file khuyến nghị
apps/sale-main/src-tauri/
├── Cargo.toml # Thêm tauri-plugin-signal dependency
└── tauri-plugin-signal/
├── Cargo.toml
├── build.rs
└── src/
├── lib.rs # Khởi tạo plugin + SignalExt trait
├── error.rs # Kiểu lỗi
├── models.rs # SignalPluginConfig, etc.
├── commands.rs # Tauri #[command] handlers
├── crypto.rs # ECDH P-256 + AES-256-GCM
└── client.rs # SignalWebSocketClient (tokio)