Skip to content

Tauri Android Client

Overview

The Signal WebSocket connection is managed entirely in the Rust main process via a Tauri plugin (tauri-plugin-signal). This approach provides:

  • Survives WebView recreation — the Rust process persists across Android activity lifecycle
  • Native performance — ECDH + AES-256-GCM via Rust crypto crates (no Web Crypto overhead)
  • Network fault tolerance — Rust-level reconnection logic handles unstable mobile networks
  • Process-level security — Private keys never cross the Rust <-> WebView IPC boundary

The frontend (React in WebView) communicates with the Rust plugin via Tauri commands and 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"

Plugin File Structure

apps/sale-main/src-tauri/tauri-plugin-signal/
├── Cargo.toml                    # Dependencies (see above)
├── build.rs                      # Register command names
├── permissions/
│   └── default.toml              # Permission configuration
└── src/
    ├── lib.rs                    # Plugin initialization + SignalExt trait
    ├── error.rs                  # Error types (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                # Desktop impl (optional)
    └── mobile.rs                 # Mobile impl (Android/iOS)

Crypto Module (crypto.rs)

Rust equivalent of signal-crypto.ts. Uses p256 + aes-gcm + hkdf instead of Web Crypto API.

Key structs:

StructDescription
EcdhKeyPairEphemeral ECDH P-256 key pair. generate() creates a new pair, derive_aes_key() derives an AES key from the shared secret + HKDF.
AesSessionKeyAES-256-GCM session key. encrypt() encrypts plaintext with a random 12-byte IV, decrypt() decrypts a payload { iv, ct }.
EncryptedPayload{ iv: String, ct: String } struct (base64) — compatible with the server format.

Handshake flow in Rust:

1. EcdhKeyPair::generate()                    → create ECDH P-256 ephemeral key pair
2. Send public_key_b64 in authenticate msg    → server receives client public key
3. Server sends { serverPublicKey, salt }     → client receives in "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)  → encrypt/decrypt messages
Click to expand full source code
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 — the main struct managing the WebSocket connection. Runs on a tokio task, communicates with Tauri commands via channels.

Key features:

FeatureDescription
Forward secrecyNew ECDH key pair for every connection (including reconnects)
Auto-reconnectExponential backoff with configurable maxRetries
HeartbeatSends { event: "heartbeat" } every 30s via tokio interval
Auto-join roomsAutomatically joins rooms after successful authentication
Tauri eventsEmits signal:* events to the frontend via 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>;
}
Click to expand full source code
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

The plugin registers SignalClient into Tauri state and exposes commands to the frontend.

lib.rs — Plugin initialization:

rust
// SignalExt trait allows accessing the plugin from anywhere in the 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:

CommandDescription
connectSpawn connection loop on a background tokio task
disconnectDisconnect manually
send_messageEncrypt and send a message
join_roomsJoin rooms (plaintext control event)
leave_roomsLeave rooms
get_stateGet the current connection state
get_client_idGet the client ID (after authentication)
update_tokenUpdate the JWT token for the next reconnect

models.rs — SignalPluginConfig:

rust
pub struct SignalPluginConfig {
    pub server_url: String,            // WebSocket server URL
    pub ecdh_info: String,             // HKDF info string (must match server)
    pub auto_join_rooms: Vec<String>,  // Rooms to auto-join
    pub max_retries: Option<u32>,      // Default: 20 (mobile)
    pub base_interval_ms: Option<u64>, // Default: 2000 (mobile)
    pub backoff_multiplier: Option<f64>, // Default: 1.5
    pub max_interval_ms: Option<u64>,    // Default: 30_000
    pub heartbeat_interval_ms: Option<u64>, // Default: 30_000
}

Plugin Registration

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),          // Higher for mobile networks
            base_interval_ms: Some(2000),   // Faster retry for mobile
            backoff_multiplier: Some(1.5),
            max_interval_ms: Some(30_000),
            heartbeat_interval_ms: Some(30_000),
        }));

    builder
}

Android-Specific Notes

IssueRecommendation
Activity lifecycleThe Rust process survives activity recreation. No need to handle onPause/onResume — tokio tasks continue running. Only call disconnect on explicit logout.
Network changesUse @tauri-apps/plugin-os to detect connectivity changes. When the network recovers, the Rust reconnect loop handles it automatically.
Stronger reconnectMobile connections drop frequently. Default configuration uses maxRetries: 20 and baseIntervalMs: 2000 (faster than desktop).
BatteryHeartbeat interval of 30s is acceptable. Do not reduce below 15s to avoid battery drain.
Doze modeAndroid Doze restricts background networking. The WebSocket tokio task will disconnect, but the reconnect loop automatically recovers when the app returns to the foreground.
Server URLAlways use wss:// (TLS). Android 9+ blocks cleartext HTTP/WS by default.
TLS certificatestokio-tungstenite with the native-tls feature uses the system certificate store. Self-signed certs require separate configuration.
apps/sale-main/src-tauri/
├── Cargo.toml                         # Add tauri-plugin-signal dependency
└── tauri-plugin-signal/
    ├── Cargo.toml
    ├── build.rs
    └── src/
        ├── lib.rs                     # Plugin initialization + SignalExt trait
        ├── error.rs                   # Error types
        ├── models.rs                  # SignalPluginConfig, etc.
        ├── commands.rs                # Tauri #[command] handlers
        ├── crypto.rs                  # ECDH P-256 + AES-256-GCM
        └── client.rs                  # SignalWebSocketClient (tokio)

Connection Flow Sequence

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