Skip to content

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:

StructMô tả
EcdhKeyPairCặ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.
AesSessionKeyKhó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 }.
EncryptedPayloadCấ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ắn
Nhấ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ăngMô tả
Forward secrecyCặp khóa ECDH mới cho mỗi kết nối (bao gồm reconnect)
Auto-reconnectExponential backoff với maxRetries có thể cấu hình
HeartbeatGửi { event: "heartbeat" } mỗi 30s qua tokio interval
Auto-join roomsTự động join rooms sau khi xác thực thành công
Tauri eventsEmit 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:

CommandMô tả
connectSpawn connection loop trên tokio task background
disconnectNgắt kết nối thủ công
send_messageMã hóa và gửi tin nhắn
join_roomsTham gia phòng (plaintext control event)
leave_roomsRời phòng
get_stateLấy trạng thái kết nối hiện tại
get_client_idLấy client ID (sau khi xác thực)
update_tokenCậ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 ActivityRust 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ạngSử 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ơnKết nối di động rớt thường xuyên. Cấu hình mặc định dùng maxRetries: 20baseIntervalMs: 2000 (nhanh hơn desktop).
PinHeartbeat interval 30s chấp nhận được. Không giảm dưới 15s để tránh hao pin.
Doze modeAndroid 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 URLLuôn dùng wss:// (TLS). Android 9+ chặn cleartext HTTP/WS theo mặc định.
Chứng chỉ TLStokio-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)

Biểu đồ chuỗi luồng kết nối

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.