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:
| Struct | Description |
|---|---|
EcdhKeyPair | Ephemeral ECDH P-256 key pair. generate() creates a new pair, derive_aes_key() derives an AES key from the shared secret + HKDF. |
AesSessionKey | AES-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 messagesClick 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:
| Feature | Description |
|---|---|
| Forward secrecy | New ECDH key pair for every connection (including reconnects) |
| Auto-reconnect | Exponential backoff with configurable maxRetries |
| Heartbeat | Sends { event: "heartbeat" } every 30s via tokio interval |
| Auto-join rooms | Automatically joins rooms after successful authentication |
| Tauri events | Emits 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:
| Command | Description |
|---|---|
connect | Spawn connection loop on a background tokio task |
disconnect | Disconnect manually |
send_message | Encrypt and send a message |
join_rooms | Join rooms (plaintext control event) |
leave_rooms | Leave rooms |
get_state | Get the current connection state |
get_client_id | Get the client ID (after authentication) |
update_token | Update 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
| Issue | Recommendation |
|---|---|
| Activity lifecycle | The Rust process survives activity recreation. No need to handle onPause/onResume — tokio tasks continue running. Only call disconnect on explicit logout. |
| Network changes | Use @tauri-apps/plugin-os to detect connectivity changes. When the network recovers, the Rust reconnect loop handles it automatically. |
| Stronger reconnect | Mobile connections drop frequently. Default configuration uses maxRetries: 20 and baseIntervalMs: 2000 (faster than desktop). |
| Battery | Heartbeat interval of 30s is acceptable. Do not reduce below 15s to avoid battery drain. |
| Doze mode | Android Doze restricts background networking. The WebSocket tokio task will disconnect, but the reconnect loop automatically recovers when the app returns to the foreground. |
| Server URL | Always use wss:// (TLS). Android 9+ blocks cleartext HTTP/WS by default. |
| TLS certificates | tokio-tungstenite with the native-tls feature uses the system certificate store. Self-signed certs require separate configuration. |
Recommended File Structure
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)