Overview
whatsapp-rust uses WebSocket for transport and the Noise Protocol for encryption. All messages are encrypted at the transport layer before being sent over the network.
Architecture
The WebSocket handling system has several layers:
src/
├── handshake.rs # Noise protocol handshake
├── socket/
│ ├── noise_socket.rs # Encrypted communication layer
│ ├── mod.rs # Re-exports
│ └── error.rs # Socket errors
├── transport/ # WebSocket transport abstraction
└── client.rs # High-level client orchestration
Noise Protocol Handshake
The handshake establishes an encrypted channel using the Noise XX pattern.
Handshake State
pub struct HandshakeState {
noise_key: KeyPair, // Client's static Curve25519 keypair
client_payload: Vec<u8>, // Client identification data
pattern: &'static str, // "Noise_XX_25519_AESGCM_SHA256"
prologue: &'static [u8], // "WA" header
}
Location: wacore/src/handshake/state.rs
Handshake Process
// From src/handshake.rs:29-104
pub async fn do_handshake(
device: &Device,
transport: Arc<dyn Transport>,
transport_events: &mut async_channel::Receiver<TransportEvent>,
) -> Result<Arc<NoiseSocket>>
Step-by-step process:
-
Prepare client payload:
let client_payload = device.core.get_client_payload().encode_to_vec();
The payload contains:
- Client version
- Platform information
- Device details
-
Initialize Noise state:
let mut handshake_state = HandshakeState::new(
device.core.noise_key.clone(),
client_payload,
NOISE_START_PATTERN, // "Noise_XX_25519_AESGCM_SHA256"
&WA_CONN_HEADER, // [0x57, 0x41] ("WA")
)?;
-
Send ClientHello:
let client_hello_bytes = handshake_state.build_client_hello()?;
// Optionally include edge routing for faster reconnection
let (header, used_edge_routing) =
build_handshake_header(device.core.edge_routing_info.as_deref());
let framed = wacore::framing::encode_frame(
&client_hello_bytes,
Some(&header)
)?;
transport.send(framed).await?;
-
Receive ServerHello:
let resp_frame = loop {
match timeout(HANDSHAKE_TIMEOUT, transport_events.recv()).await {
Ok(Ok(TransportEvent::DataReceived(data))) => {
frame_decoder.feed(&data);
if let Some(frame) = frame_decoder.decode_frame() {
break frame;
}
}
// Handle errors...
}
};
-
Send ClientFinish:
let client_finish_bytes = handshake_state
.read_server_hello_and_build_client_finish(&resp_frame)?;
let framed = wacore::framing::encode_frame(&client_finish_bytes, None)?;
transport.send(framed).await?;
-
Complete handshake:
let (write_key, read_key) = handshake_state.finish()?;
info!("Handshake complete, switching to encrypted communication");
Ok(Arc::new(NoiseSocket::new(transport, write_key, read_key)))
Location: src/handshake.rs:29-104
Edge Routing Pre-Intro
For optimized reconnection, the client can include edge routing info in the initial frame:
pub fn build_handshake_header(
edge_routing_info: Option<&[u8]>
) -> (Vec<u8>, bool) {
let mut header = WA_CONN_HEADER.to_vec(); // [0x57, 0x41]
if let Some(info) = edge_routing_info {
if info.len() <= 8192 { // Max size
header.extend_from_slice(info);
return (header, true);
}
}
(header, false)
}
Location: wacore/src/handshake/edge_routing.rs
Handshake Errors
pub enum HandshakeError {
Transport(#[from] anyhow::Error),
Core(#[from] CoreHandshakeError),
Timeout,
UnexpectedEvent(String),
}
Location: src/handshake.rs:15-25
NoiseSocket
The NoiseSocket provides encrypted send/receive operations after handshake.
Architecture
pub struct NoiseSocket {
read_key: Arc<NoiseCipher>,
read_counter: Arc<AtomicU32>,
send_job_tx: mpsc::Sender<SendJob>,
sender_task_handle: JoinHandle<()>,
}
struct SendJob {
plaintext_buf: Vec<u8>,
out_buf: Vec<u8>,
response_tx: oneshot::Sender<SendResult>,
}
Location: src/socket/noise_socket.rs:21-32
Design Patterns
Dedicated Sender Task
The socket uses a dedicated task for sending to ensure frame ordering:
impl NoiseSocket {
pub fn new(
transport: Arc<dyn Transport>,
write_key: NoiseCipher,
read_key: NoiseCipher,
) -> Self {
let (send_job_tx, send_job_rx) = mpsc::channel::<SendJob>(32);
// Spawn dedicated sender task
let sender_task_handle = tokio::spawn(
Self::sender_task(transport, write_key, send_job_rx)
);
Self {
read_key: Arc::new(read_key),
read_counter: Arc::new(AtomicU32::new(0)),
send_job_tx,
sender_task_handle,
}
}
}
Why a dedicated task?
- Ordering guarantee: Frames must be sent with sequential counters
- Non-blocking: Callers don’t block on encryption or network I/O
- Backpressure: Channel capacity (32) prevents unbounded queuing
Location: src/socket/noise_socket.rs:34-62
Sender Task Implementation
async fn sender_task(
transport: Arc<dyn Transport>,
write_key: Arc<NoiseCipher>,
mut send_job_rx: mpsc::Receiver<SendJob>,
) {
let mut write_counter: u32 = 0;
while let Some(job) = send_job_rx.recv().await {
let result = Self::process_send_job(
&transport,
&write_key,
&mut write_counter,
job.plaintext_buf,
job.out_buf,
).await;
// Return buffers to caller for reuse
let _ = job.response_tx.send(result);
}
}
Location: src/socket/noise_socket.rs:64-89
Encryption
Small Messages (≤16KB)
Encrypted inline to avoid thread pool overhead:
if plaintext_buf.len() <= INLINE_ENCRYPT_THRESHOLD {
// Copy to output buffer and encrypt in-place
out_buf.clear();
out_buf.extend_from_slice(&plaintext_buf);
plaintext_buf.clear();
write_key.encrypt_in_place_with_counter(counter, &mut out_buf)?;
// Frame the ciphertext
wacore::framing::encode_frame_into(&ciphertext, None, &mut out_buf)?;
}
Location: src/socket/noise_socket.rs:103-130
Large Messages (>16KB)
Offloaded to blocking thread pool:
else {
let plaintext_arc = Arc::new(plaintext_buf);
let plaintext_arc_for_task = plaintext_arc.clone();
// Offload to blocking thread
let spawn_result = tokio::task::spawn_blocking(move || {
write_key.encrypt_with_counter(
counter,
&plaintext_arc_for_task[..]
)
}).await;
// Recover original buffer
plaintext_buf = Arc::try_unwrap(plaintext_arc)
.unwrap_or_else(|arc| (*arc).clone());
let ciphertext = spawn_result??;
// Frame and send
wacore::framing::encode_frame_into(&ciphertext, None, &mut out_buf)?;
transport.send(out_buf).await?;
}
Location: src/socket/noise_socket.rs:131-164
The 16KB threshold is chosen based on benchmarking. Smaller messages benefit from inline encryption (no thread spawning overhead), while larger messages benefit from parallel execution.
Send API
pub async fn encrypt_and_send(
&self,
plaintext_buf: Vec<u8>,
out_buf: Vec<u8>
) -> SendResult {
let (response_tx, response_rx) = oneshot::channel();
let job = SendJob {
plaintext_buf,
out_buf,
response_tx,
};
// Send to dedicated task
if let Err(send_err) = self.send_job_tx.send(job).await {
let job = send_err.0;
return Err(EncryptSendError::channel_closed(
job.plaintext_buf,
job.out_buf,
));
}
// Wait for result
match response_rx.await {
Ok(result) => result,
Err(_) => Err(EncryptSendError::channel_closed(
Vec::new(), Vec::new()
)),
}
}
Buffer Management:
The API returns both buffers for reuse:
type SendResult = Result<(Vec<u8>, Vec<u8>), EncryptSendError>;
// ^^^^^^^^^^^^^^^^^^^^^
// (plaintext_buf, out_buf) for reuse
This enables the client to reuse buffers across multiple sends:
let mut plaintext_buf = Vec::with_capacity(1024);
let mut out_buf = Vec::with_capacity(1056); // plaintext + 32 overhead
loop {
plaintext_buf.clear();
marshal_to_vec(&message, &mut plaintext_buf)?;
(plaintext_buf, out_buf) =
noise_socket.encrypt_and_send(plaintext_buf, out_buf).await?;
}
Location: src/socket/noise_socket.rs:173-200
Decryption
pub fn decrypt_frame(&self, ciphertext: &[u8]) -> Result<Vec<u8>> {
let counter = self.read_counter.fetch_add(1, Ordering::SeqCst);
self.read_key
.decrypt_with_counter(counter, ciphertext)
.map_err(|e| SocketError::Crypto(e.to_string()))
}
Decryption is synchronous because:
- Frames arrive sequentially in the transport receiver
- Decryption is fast (AES-GCM hardware acceleration)
- No ordering concerns (unlike send)
Location: src/socket/noise_socket.rs:202-207
Cleanup
impl Drop for NoiseSocket {
fn drop(&mut self) {
// Abort sender task to prevent resource leaks
self.sender_task_handle.abort();
}
}
Location: src/socket/noise_socket.rs:210-217
Frame Protocol
Messages are framed before encryption:
pub fn encode_frame(
payload: &[u8],
header: Option<&[u8]>
) -> Result<Vec<u8>> {
let mut output = Vec::new();
if let Some(header) = header {
output.extend_from_slice(header);
}
// 3-byte big-endian length
let len = payload.len() as u32;
output.push(((len >> 16) & 0xFF) as u8);
output.push(((len >> 8) & 0xFF) as u8);
output.push((len & 0xFF) as u8);
output.extend_from_slice(payload);
Ok(output)
}
Location: wacore/src/framing/mod.rs:10-30
┌────────────────┬─────────────────┬────────────────────┐
│ Optional Header│ Length (3 bytes)│ Payload │
│ (handshake) │ (big-endian) │ (encrypted) │
└────────────────┴─────────────────┴────────────────────┘
Frame length limits:
- Maximum frame size: 16MB (enforced by framing layer)
- Typical message frame: < 1KB
- Media messages: 100KB - 2MB (encrypted metadata)
Frame Decoder
pub struct FrameDecoder {
buffer: Vec<u8>,
}
impl FrameDecoder {
pub fn feed(&mut self, data: &[u8]) {
self.buffer.extend_from_slice(data);
}
pub fn decode_frame(&mut self) -> Option<Vec<u8>> {
if self.buffer.len() < 3 {
return None; // Need length header
}
let len = u32::from_be_bytes([
0,
self.buffer[0],
self.buffer[1],
self.buffer[2],
]) as usize;
if self.buffer.len() < 3 + len {
return None; // Incomplete frame
}
let frame = self.buffer[3..3 + len].to_vec();
self.buffer.drain(..3 + len);
Some(frame)
}
}
Location: wacore/src/framing/decoder.rs
Transport Abstraction
The transport layer abstracts WebSocket implementation:
#[async_trait]
pub trait Transport: Send + Sync {
async fn send(&self, data: Vec<u8>) -> Result<(), anyhow::Error>;
async fn disconnect(&self);
}
pub enum TransportEvent {
Connected,
Disconnected,
DataReceived(Vec<u8>),
}
Location: src/transport/mod.rs
WebSocket Implementation
pub struct TungsteniteTransport {
write_tx: mpsc::Sender<Vec<u8>>,
disconnect_tx: Arc<Notify>,
}
#[async_trait]
impl Transport for TungsteniteTransport {
async fn send(&self, data: Vec<u8>) -> Result<()> {
self.write_tx.send(data).await
.map_err(|_| anyhow!("transport closed"))
}
async fn disconnect(&self) {
self.disconnect_tx.notify_one();
}
}
Location: src/transport/tungstenite.rs
Connection Lifecycle
1. Connect
impl Client {
pub async fn connect(&self) -> Result<()> {
// Create WebSocket transport
let (transport, mut events) = TungsteniteTransport::connect(
"wss://web.whatsapp.com/ws/chat"
).await?;
// Perform Noise handshake
let device = self.persistence_manager.get_device_snapshot().await;
let noise_socket = do_handshake(
&device,
transport,
&mut events
).await?;
// Store socket and start receivers
self.noise_socket.store(Some(Arc::clone(&noise_socket)));
self.start_frame_receiver(events, noise_socket).await;
Ok(())
}
}
2. Frame Receiver
async fn start_frame_receiver(
&self,
mut events: Receiver<TransportEvent>,
noise_socket: Arc<NoiseSocket>,
) {
tokio::spawn(async move {
let mut frame_decoder = FrameDecoder::new();
while let Ok(event) = events.recv().await {
match event {
TransportEvent::DataReceived(data) => {
frame_decoder.feed(&data);
while let Some(frame) = frame_decoder.decode_frame() {
// Decrypt frame
let plaintext = noise_socket
.decrypt_frame(&frame)?;
// Process message
self.handle_frame(plaintext).await?;
}
}
TransportEvent::Disconnected => {
self.handle_disconnect().await;
break;
}
_ => {}
}
}
});
}
3. Send Message
impl Client {
pub async fn send_node(&self, node: &Node) -> Result<()> {
let noise_socket = self.noise_socket.load()
.ok_or_else(|| anyhow!("not connected"))?;
// Reuse buffers for efficiency
let mut plaintext_buf = Vec::with_capacity(1024);
let mut out_buf = Vec::with_capacity(1056);
// Marshal node to binary
marshal_to_vec(node, &mut plaintext_buf)?;
// Encrypt and send
(plaintext_buf, out_buf) = noise_socket
.encrypt_and_send(plaintext_buf, out_buf)
.await?;
// Buffers returned for potential reuse
Ok(())
}
}
4. Disconnect
impl Client {
pub async fn disconnect(&self) -> Result<()> {
if let Some(socket) = self.noise_socket.swap(None) {
// Transport will close connection
socket.transport.disconnect().await;
}
Ok(())
}
}
Error Handling
Socket Errors
pub enum SocketError {
Crypto(String),
Transport(#[from] anyhow::Error),
Framing(#[from] FramingError),
}
pub enum EncryptSendError {
Crypto { error: anyhow::Error, ... },
Transport { error: anyhow::Error, ... },
Framing { error: FramingError, ... },
Join { error: JoinError, ... },
ChannelClosed { ... },
}
All variants return buffers for reuse:
impl EncryptSendError {
pub fn into_buffers(self) -> (Vec<u8>, Vec<u8>) {
match self {
Self::Crypto { plaintext_buf, out_buf, .. } =>
(plaintext_buf, out_buf),
Self::Transport { plaintext_buf, out_buf, .. } =>
(plaintext_buf, out_buf),
// ...
}
}
}
Location: src/socket/error.rs
Retry Strategy
impl Client {
pub async fn connect_with_retry(&self, max_attempts: u32) -> Result<()> {
let mut attempt = 0;
loop {
match self.connect().await {
Ok(()) => return Ok(()),
Err(e) if attempt < max_attempts => {
attempt += 1;
let backoff = Duration::from_secs(2u64.pow(attempt));
warn!("Connection failed (attempt {attempt}): {e}");
sleep(backoff).await;
}
Err(e) => return Err(e),
}
}
}
}
Buffer Sizing
Optimal buffer capacity based on payload characteristics:
// Encrypted size = plaintext + 16 (AES-GCM tag) + 3 (frame header)
let buffer_capacity = plaintext.len() + 32; // Extra headroom
let out_buf = Vec::with_capacity(buffer_capacity);
Location: src/socket/noise_socket.rs:373-407 (tests verify this formula)
SIMD Encryption
The Noise cipher uses hardware AES acceleration when available:
pub struct NoiseCipher {
cipher: Aes256Gcm, // Uses AES-NI on x86_64
}
Zero-Copy Patterns
// Bad: Allocates new buffer
let data = node.to_bytes();
socket.send(data).await?;
// Good: Reuses buffer
let mut buf = Vec::with_capacity(1024);
marshal_to_vec(&node, &mut buf)?;
socket.send(buf).await?;
Testing
Mock Transport
pub struct MockTransport;
#[async_trait]
impl Transport for MockTransport {
async fn send(&self, data: Vec<u8>) -> Result<()> {
// Record for assertions
Ok(())
}
async fn disconnect(&self) {}
}
Location: src/transport/mock.rs
Test Cases
Key test scenarios:
#[tokio::test]
async fn test_encrypt_and_send_returns_both_buffers()
#[tokio::test]
async fn test_concurrent_sends_maintain_order()
#[tokio::test]
async fn test_encrypted_buffer_sizing_is_sufficient()
#[tokio::test]
async fn test_handshake_with_edge_routing()
Location: src/socket/noise_socket.rs:219-459
References