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:Noise protocol handshake
The handshake establishes an encrypted channel using the Noise Protocol. whatsapp-rust supports two interactive patterns to match WhatsApp Web’s behavior:- Noise XX — three-message mutual authentication used on cold start, after pairing, and as a fallback when IK fails. The server’s static public key is unknown ahead of time and is delivered (and verified against a cert chain) inside the
ServerHello. - Noise IK — a single round-trip resumed handshake used when a previously verified server static key is cached. This saves a server round trip on reconnect by pre-encrypting the client static and the 0-RTT
ClientPayloadagainst the cached server static. - Noise XXfallback — a recovery pattern the server transparently triggers when the cached server static used by an IK attempt no longer matches its current key. The transcript pivots from IK to XX without restarting the connection.
NOISE_HANDSHAKE_RESPONSE_TIMEOUT) is applied when waiting for the server’s handshake response, ensuring the client does not hang indefinitely if the server is unresponsive.
Pattern selection
do_handshake picks the pattern based on cached state:
- The device is registered (paired).
- A cached
server_cert_chainis present inDevice. - Both the leaf and intermediate certificates are inside their
not_before/not_aftervalidity window for the current wall-clock time. - The process has not already observed an IK failure this session — the client allows at most
IK_FAILURE_THRESHOLD = 1IK failure per process before forcing XX on subsequent connects.
server_cert_chain so that the next reconnect can attempt IK again.
Handshake state types
Thewacore_noise crate exposes one state machine per pattern:
pattern strings are "Noise_XX_25519_AESGCM_SHA256\0\0\0\0" for XX and XXfallback (the longer "Noise_XXfallback_25519_AESGCM_SHA256" name is hashed to derive h0 because it exceeds HASHLEN).
XX handshake (cold start / fallback)
-
Prepare client payload:
The payload contains client version, platform, and device details.
-
Initialize XX state:
-
Send ClientHello:
-
Receive ServerHello (with 20s timeout):
-
Send ClientFinish:
-
Complete handshake and persist the cert chain:
IK handshake (resumed)
Whenselect_pattern returns HandshakePattern::Ik(server_static_pub), the client uses the cached server static and skips the second round trip:
Continue, no SetServerCertChain command is issued — the on-disk cache remains authoritative.
XXfallback (server-driven recovery)
Ifread_server_hello returns IkServerHelloOutcome::Fallback(inputs), the server has signalled that the cached static is stale. The client constructs an XxFallbackHandshakeState from the IK transcript and finishes the handshake as if it had been XX from the start, without reconnecting:
fallback_taken flag is flipped before any operation that could fail. Failures after this pivot are not treated as crypto-fatal for IK cache invalidation purposes — by that point the server has already accepted the IK ClientHello and the cache is no longer the implicated party.
IK failure handling
Errors are partitioned into two buckets inHandshakeError:
do_handshake selected IK, the pivot to XXfallback was not taken, and the error returns is_crypto_fatal() == true, the client:
- Increments a process-local
ik_handshake_failures: AtomicU32. - Issues
DeviceCommand::ClearServerCertChainto drop the cached chain. - Forces XX on the next connect via
select_pattern.
Proto, generic Crypto(String), HkdfExpandFailed, CounterExhausted) are explicitly not crypto-fatal — they indicate a code defect, and clearing the cache would mask it.
Location: src/handshake.rs
Edge routing pre-intro
For optimized reconnection, the client can include edge routing info in the initial frame:wacore/noise/src/edge_routing.rs
Both XX and IK send their first handshake message through send_first_handshake_message, so the prologue (and any edge-routing pre-intro) is identical for the two patterns. This is required for the wire-side server to re-derive h0 for transcript MAC checks regardless of which pattern is in use.
Handshake Errors
src/handshake.rs
NoiseSocket
TheNoiseSocket provides encrypted send/receive operations after handshake.
Architecture
src/socket/noise_socket.rs:21-32
Design Patterns
Dedicated sender task
The socket uses a dedicated task for sending to ensure frame ordering:- 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
src/socket/noise_socket.rs:34-62
Sender task implementation
src/socket/noise_socket.rs:64-89
Encryption
Small Messages (≤16KB)
Encrypted inline to avoid thread pool overhead:src/socket/noise_socket.rs:103-130
Large Messages (>16KB)
Offloaded to blocking thread pool: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
src/socket/noise_socket.rs:173-200
Decryption
- Frames arrive sequentially in the transport receiver
- Decryption is fast (AES-GCM hardware acceleration)
- No ordering concerns (unlike send)
src/socket/noise_socket.rs:202-207
Cleanup
src/socket/noise_socket.rs:210-217
Frame Protocol
Messages are framed before encryption:wacore/src/framing/mod.rs:10-30
Frame Format
- Maximum frame size: 16MB (enforced by framing layer)
- Typical message frame: < 1KB
- Media messages: 100KB - 2MB (encrypted metadata)
Frame Decoder
FrameDecoder (in wacore/noise/src/framing.rs) accumulates inbound bytes and splits out complete frames. It has two feed methods:
feed(&[u8])— borrow path; always copies into the internalBytesMutstaging buffer. Used during the handshake and in tests.feed_bytes(Bytes)— owned path; adopts the payload’s allocation without copying in steady state (buffer empty + sole reference). Falls back to copying when the buffer has a partial frame or the payload is shared (refcount > 1).
wacore/noise/src/framing.rs
Steady-state zero-copy guarantee: because the WebSocket transport delivers each message as its own Bytes, in steady state the staging buffer is empty when feed_bytes is called and the payload is the sole reference. try_into_mut succeeds, the bytes are adopted wholesale, and the decoded frame’s slice points directly into the original WebSocket allocation at offset FRAME_LENGTH_SIZE — verified by pointer identity in test_feed_bytes_adopts_unique_payload_without_copy.
Transport Abstraction
The transport layer abstracts WebSocket implementation:src/transport/mod.rs
WebSocket implementation
The transport is generic over anyAsyncRead + AsyncWrite stream. The from_websocket function wraps an already-upgraded WebSocketStream into a Transport + event channel:
Arc<Mutex>) and a read half (moved to a spawned read_pump task). A watch channel coordinates graceful shutdown between the transport and the read pump.
TokioWebSocketTransportFactory handles the default DNS/TCP/TLS connection and delegates to from_websocket. For custom connection strategies (IPv4 preference, TCP keepalive, proxies), call from_websocket directly.
Location: transports/tokio-transport/src/lib.rs
Connection Lifecycle
Connect timeout
Both the transport connection and the version fetch are wrapped in a 20-second timeout (TRANSPORT_CONNECT_TIMEOUT), matching WhatsApp Web’s MQTT CONNECT_TIMEOUT and DGW connectTimeoutMs defaults. Without this, a dead network would block on the OS TCP SYN timeout (~60-75s).
tokio::join!, both under this timeout:
"Transport connect timed out after 20s").
Location: src/client.rs:108-877
1. Connect
2. Message loop (read loop)
Theread_messages_loop runs on the run() caller’s task — not spawned — so the keepalive loop (which runs in a separate spawned task) is never blocked by frame processing. This eliminates a class of bugs where a long-running batch of frames (e.g., offline sync) could starve the keepalive timer.
select_biased!— the shutdown listener has priority over transport events, ensuring the loop exits promptly whenshutdown_notifierfires (e.g., on stream error or disconnect)- Batch timestamp refresh — after processing multiple frames,
last_data_received_msis updated again so the keepalive loop sees the batch completion time rather than the arrival time. This prevents false-positive dead-socket triggers during large offline sync batches that take seconds to drain - Cooperative yielding — the loop yields to the runtime every
yield_frequency()frames, preventing a large burst of frames from monopolizing the executor
Inline vs concurrent node processing
Frame decryption is always sequential (noise protocol counter ordering), but node processing uses a hybrid strategy:marshal_auto function automatically selects an appropriate buffer capacity based on the node’s characteristics. For nodes exceeding certain thresholds (24+ attributes, 64+ children, or 8KB+ scalar content), it pre-estimates the capacity to avoid reallocations. For typical small nodes, it uses the default 1024-byte capacity. This replaces the previous manual Vec::with_capacity(1024) + marshal_to pattern.
Connection state tracking
The client tracks whether the noise socket is established using a dedicatedAtomicBool (is_connected) rather than probing the noise socket mutex. This design prevents a TOCTOU race where try_lock() on the mutex fails due to contention (e.g., during frame encryption), not because the socket is absent — which previously caused is_connected() to return false on live connections, silently dropping receipt acks.
State transitions:
The
Release/Acquire ordering ensures that any task reading is_connected() == true is guaranteed to see the noise socket as Some, and any task reading false after cleanup sees the socket as None.
is_connected() to decide whether to send data. Under the old try_lock() approach, concurrent send_node() calls holding the mutex would cause false negatives, leading to skipped keepalive pings or dropped ack stanzas.
Error Handling
Socket Errors
source(). Cipher wraps a NoiseError (from wacore::handshake), which itself carries a typed CryptoProviderError source. Walking the chain with std::error::Error::source() lets callers downcast to the original AES-GCM, libsignal, or binary-protocol error without parsing strings.
All variants return buffers for reuse:
src/socket/error.rs
Stream error handling
When the server sends a<stream:error> stanza, it is processed inline (not spawned concurrently) because stream errors are critical for connection state. The StanzaRouter dispatches the node to a StreamErrorHandler, which calls Client::handle_stream_error().
Each stream error sets is_logged_in = false and fires the shutdown_notifier to exit the keepalive loop and other background tasks.
Error code behavior:
Before v0.6 the client treated every unknown stream-error code as fatal: it set
is_logged_in = false, fired shutdown_notifier, and ended up in a “zombie” state where the connection survived but background tasks (keepalive, prekey upload) refused to run. v0.6 keeps the fatal set listed above and downgrades everything else (including code 500 and code-less <stream:error><ack/> routing wrappers) to a warning. is_logged_in stays true, the transport stays open, and any reconnect is driven by the server’s <xmlstreamend/> — not by the stream-error handler itself.<xml-not-well-formed/> is checked before the ack-wrapper case above and is an exception to that downgrade: WA Web (Handle/StreamError.js) treats it as “bad xml, closing socket” (CLOSE_SOCKET). A malformed frame desyncs the stream, so the client proactively recycles the socket (is_logged_in = false, should_disconnect = true) instead of waiting for the server to end it. It counts toward the reconnect backoff like a normal disconnect — it is not an expected disconnect (515), so no immediate reconnect. Like every other case in this code-less/unknown-code branch, an Event::StreamError is still dispatched before the socket is closed — consumers see the event even though the connection is being force-recycled rather than gracefully downgraded.Stanza acknowledgment
The client automatically sends<ack/> nodes in response to incoming stanzas (messages, receipts, notifications, calls). The ack construction follows WhatsApp Web and whatsmeow behavior:
Class gating for newsletter and status: Newsletter and status@broadcast inbound messages produce a <ack class="message"/> instead of a <receipt>, matching WA Web’s WAWebSendMsgAckOrReceiptJob. Regular DMs and groups continue to skip the message-class ack because they ride on the regular receipt path.
Ack attributes:
type attribute rules:
The type attribute is handled differently depending on the stanza:
Location:
src/client.rs (build_ack_node, is_encrypt_identity_notification)
Fibonacci backoff
The reconnection backoff follows the Fibonacci sequence, matching WhatsApp Web’s behavior:<success> authentication. Instead, connected_at_ms records the auth time, and the counter only resets when the next disconnect finds the connection was stable for at least STABLE_CONNECTION_RESET_MS (30s) — matching WA Web’s resetDelay. A connection that authenticates and then immediately drops keeps escalating the backoff instead of resetting to 1s and retrying in a tight loop.
An explicit penalty applied during the connection — a 429 rate-limit or a manual Client::reconnect() — sets backoff_reset_suppressed, which survives even a stable (≥30s) connection and prevents the next disconnect from erasing that deliberate backoff step (matching WA Web’s cancelReset()). The suppression flag is cleared on the next successful <success>, so it does not carry over indefinitely. An expected disconnect (e.g., 515) resets connected_at_ms to 0 so a later failed connect cannot read the prior cycle’s stale timestamp as “stable.”
Retry strategy
Therun() method handles reconnection automatically:
Keepalive and dead socket detection
The keepalive loop monitors connection health, matching WhatsApp Web’s behavior precisely.Constants
Timestamp safety
All timestamp conversions fromnow_millis() (which returns i64) to u64 are guarded with .max(0) before casting. This prevents silent wrap-around on negative clock values (e.g., from NTP corrections or virtualized environments) that would otherwise produce incorrect timestamps.
Keepalive loop behavior
The loop runs every 15-30 seconds (randomized, matching WA Web’s15 * (1 + random()) formula) and performs these checks in order:
- Skip if recently active — if data was received within
KEEP_ALIVE_INTERVAL_MIN(15s), the connection is proven alive; skip the ping and reset the error counter - Send keepalive ping — sends the ping before the dead-socket check so that a successful pong updates
last_data_received_msand prevents false-positive dead-socket detection on idle-but-healthy connections - RTT-adjusted clock skew — on pong, calculates server time offset using the midpoint formula:
(startTime + rtt/2) / 1000 - serverTime, matching WA Web’sonClockSkewUpdate - Skip ping when IQ pending — if there are already pending IQ responses, the connection is implicitly being tested; skip the explicit ping
Dead socket detection
Dead socket detection mirrors WA Web’sdeadSocketTimer.onOrBefore pattern, which keeps the earliest armed deadline rather than the most recent one:
- Not armed if nothing has been sent since the last receive (the anchor is zero)
- Cancelled if data was received after the anchor was armed
- Fires if
DEAD_SOCKET_TIME(20s) has elapsed since the anchor with no receive since
SessionStats::first_send_since_recv_ms — the first send since the last receive, not the most recent send. record_frame_sent only stores a new anchor when the current one is unset or stale (<= the last-received timestamp); once armed, further sends leave it in place. Every receive resets the anchor to zero, and the next send re-arms it. Anchoring on the most recent send instead (the pre-fix behavior) let continued outgoing traffic — messages, receipts, presence — keep pushing the deadline forward, hiding a half-open socket (a peer that silently disappeared while writes still buffer and reads hang) for as long as the app kept emitting frames.
The dead-socket check runs on every keepalive tick — not just after a failed ping. This catches scenarios where pending IQs caused the ping to be skipped, or where the ping “succeeded” but the connection died immediately after. When a dead socket is detected, the client calls reconnect_immediately() and exits the keepalive loop.
WA Web’s deadSocketTimer.onOrBefore (WA/Shift/Timer.js) arms on the first callStanza after a receive and is cancelled by parseAndHandleStanza; subsequent sends never push the deadline back out. The keepalive loop approximates this by checking is_dead_socket_at(first_send_since_recv, last_recv, now) unconditionally each iteration, where first_send_since_recv is the armed-anchor value described above. The tick reads the clock once into now and evaluates both the dead-socket check and the elapsed-time log message against that single instant, rather than re-reading the clock for each. There is no last_data_sent_ms field — nothing reads a “most recent send” timestamp, only the armed anchor.
Error classification
Keepalive errors are classified exhaustively (compile-time enforced for new error variants):Periodic maintenance
Approximately every 12 keepalive ticks (~5 minutes), the keepalive loop runs background cleanup of expired sent messages from the database, based onCacheConfig::sent_message_ttl_secs.
Performance Considerations
Buffer Sizing
Optimal buffer capacity based on payload characteristics:src/socket/noise_socket.rs:373-407 (tests verify this formula)
SIMD Encryption
The Noise cipher uses hardware AES acceleration when available:Zero-Copy Patterns
Send path — reuse the marshal and output buffers across sends:feed_bytes adopts the WebSocket payload’s allocation in steady state so inbound bytes cross the framing layer without a copy:
memcpy of every inbound byte in the common case. Shared payloads and partial-frame continuations fall back to copying automatically.
Testing
Mock Transport
src/transport/mock.rs
Test Cases
Key test scenarios:src/socket/noise_socket.rs:219-459
Related Components
- Signal Protocol - Message-level encryption
- Binary Protocol - Payload serialization
- State Management - Connection state persistence
References
- Handshake:
src/handshake.rs - NoiseSocket:
src/socket/noise_socket.rs - Framing:
wacore/noise/src/framing.rs - Transport:
src/transport/ - Noise Protocol Framework
- Noise XX Pattern