Skip to main content

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 ClientPayload against 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.
A 20-second timeout (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:
A handshake uses IK only when all of the following are true:
  • The device is registered (paired).
  • A cached server_cert_chain is present in Device.
  • Both the leaf and intermediate certificates are inside their not_before/not_after validity 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 = 1 IK failure per process before forcing XX on subsequent connects.
Otherwise, the client falls back to XX. A successful XX (or XXfallback) handshake refreshes the cached server_cert_chain so that the next reconnect can attempt IK again.

Handshake state types

The wacore_noise crate exposes one state machine per pattern:
The 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)

Step-by-step process for XX:
  1. Prepare client payload:
    The payload contains client version, platform, and device details.
  2. Initialize XX state:
  3. Send ClientHello:
  4. Receive ServerHello (with 20s timeout):
  5. Send ClientFinish:
  6. Complete handshake and persist the cert chain:

IK handshake (resumed)

When select_pattern returns HandshakePattern::Ik(server_static_pub), the client uses the cached server static and skips the second round trip:
On Continue, no SetServerCertChain command is issued — the on-disk cache remains authoritative.

XXfallback (server-driven recovery)

If read_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:
A 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 in HandshakeError:
If do_handshake selected IK, the pivot to XXfallback was not taken, and the error returns is_crypto_fatal() == true, the client:
  1. Increments a process-local ik_handshake_failures: AtomicU32.
  2. Issues DeviceCommand::ClearServerCertChain to drop the cached chain.
  3. Forces XX on the next connect via select_pattern.
Programmer-side variants (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:
Location: 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

Location: src/handshake.rs

NoiseSocket

The NoiseSocket provides encrypted send/receive operations after handshake.

Architecture

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:
Why a dedicated task?
  1. Ordering guarantee: Frames must be sent with sequential counters
  2. Non-blocking: Callers don’t block on encryption or network I/O
  3. Backpressure: Channel capacity (32) prevents unbounded queuing
Location: src/socket/noise_socket.rs:34-62

Sender task implementation

Location: src/socket/noise_socket.rs:64-89

Encryption

Small Messages (≤16KB)

Encrypted inline to avoid thread pool overhead:
Location: src/socket/noise_socket.rs:103-130

Large Messages (>16KB)

Offloaded to blocking thread pool:
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

Buffer Management: The API returns both buffers for reuse:
This enables the client to reuse buffers across multiple sends:
Location: src/socket/noise_socket.rs:173-200

Decryption

Decryption is synchronous because:
  1. Frames arrive sequentially in the transport receiver
  2. Decryption is fast (AES-GCM hardware acceleration)
  3. No ordering concerns (unlike send)
Location: src/socket/noise_socket.rs:202-207

Cleanup

Location: src/socket/noise_socket.rs:210-217

Frame Protocol

Messages are framed before encryption:
Location: wacore/src/framing/mod.rs:10-30

Frame Format

Frame length limits:
  • 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 internal BytesMut staging 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).
Location: 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:
Location: src/transport/mod.rs

WebSocket implementation

The transport is generic over any AsyncRead + AsyncWrite stream. The from_websocket function wraps an already-upgraded WebSocketStream into a Transport + event channel:
Internally, the WebSocket is split into a write half (guarded by 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).
The client runs the version fetch and transport connection in parallel using tokio::join!, both under this timeout:
If either times out, the connection attempt fails with a descriptive error (e.g., "Transport connect timed out after 20s"). Location: src/client.rs:108-877

1. Connect

2. Message loop (read loop)

The read_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.
Key design decisions:
  • select_biased! — the shutdown listener has priority over transport events, ensuring the loop exits promptly when shutdown_notifier fires (e.g., on stream error or disconnect)
  • Batch timestamp refresh — after processing multiple frames, last_data_received_ms is 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:
The 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.
Key cleanup actions include invalidating chat lanes (so stale message processing workers don’t survive with outdated crypto state), clearing the signal cache, draining IQ response waiters, and resetting offline sync state. See disconnect cleanup for the full list.

Connection state tracking

The client tracks whether the noise socket is established using a dedicated AtomicBool (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.
This is critical for the keepalive loop and stanza acknowledgment, both of which call 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

Each variant preserves the underlying typed error as a 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:
Location: 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.
Processing pipeline:

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:
Sending incorrect type attributes in ack stanzas can cause the server to issue <stream:error> disconnections. The library handles this automatically — you don’t need to build ack nodes manually.
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:
For rate-limited errors (429), the backoff counter is incremented by 5 before the normal increment, causing the delay to jump significantly on the next reconnection attempt. Stability-gated reset. The backoff counter does not reset to its base immediately on a successful <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

The run() 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 from now_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’s 15 * (1 + random()) formula) and performs these checks in order:
  1. 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
  2. Send keepalive ping — sends the ping before the dead-socket check so that a successful pong updates last_data_received_ms and prevents false-positive dead-socket detection on idle-but-healthy connections
  3. RTT-adjusted clock skew — on pong, calculates server time offset using the midpoint formula: (startTime + rtt/2) / 1000 - serverTime, matching WA Web’s onClockSkewUpdate
  4. 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’s deadSocketTimer.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
The watchdog is anchored to 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 on CacheConfig::sent_message_ttl_secs.

Performance Considerations

Buffer Sizing

Optimal buffer capacity based on payload characteristics:
Location: 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:
Receive pathfeed_bytes adopts the WebSocket payload’s allocation in steady state so inbound bytes cross the framing layer without a copy:
This eliminates one full memcpy of every inbound byte in the common case. Shared payloads and partial-frame continuations fall back to copying automatically.

Testing

Mock Transport

Location: src/transport/mock.rs

Test Cases

Key test scenarios:
Location: src/socket/noise_socket.rs:219-459

References