Overview
WhatsApp-Rust supports two authentication methods for linking companion devices:
QR Code Pairing - Scan a QR code with your phone
Pair Code (Phone Number Linking) - Enter an 8-character code on your phone
Both methods use the Noise Protocol for secure key exchange and can run concurrently - whichever completes first wins.
Authentication Flow
QR Code Pairing
How It Works
Location: src/pair.rs, wacore/src/pair.rs
Server sends pairing refs: After connection, server sends pair-device with multiple refs
Generate QR codes: Each ref becomes a QR code containing device keys
QR rotation: First code valid for 60s, subsequent codes for 20s each
Phone scans: User scans QR with WhatsApp > Linked Devices
Crypto handshake: Noise-based key exchange establishes trust
Completion: Server sends pair-success, device signs identity
QR Code Contents
// src/pair.rs
pub fn make_qr_data ( store : & Device , ref_str : String ) -> String {
let device_state = DeviceState {
identity_key : store . identity_key . clone (),
noise_key : store . noise_key . clone (),
adv_secret_key : store . adv_secret_key,
};
PairUtils :: make_qr_data ( & device_state , ref_str )
}
QR Format: ref,noise_pub,identity_pub,adv_secret
ref: Pairing reference from server
noise_pub: Static Noise public key (32 bytes, base64)
identity_pub: Signal identity public key (32 bytes, base64)
adv_secret: Advertisement secret key (32 bytes, base64)
Implementation
use std :: sync :: Arc ;
use whatsapp_rust :: bot :: Bot ;
use whatsapp_rust :: TokioRuntime ;
use whatsapp_rust :: store :: SqliteStore ;
use whatsapp_rust_tokio_transport :: TokioWebSocketTransportFactory ;
use whatsapp_rust_ureq_http_client :: UreqHttpClient ;
use wacore :: types :: events :: Event ;
#[tokio :: main]
async fn main () -> Result <(), Box < dyn std :: error :: Error >> {
let backend = Arc :: new ( SqliteStore :: new ( "whatsapp.db" ) . await ? );
let mut bot = Bot :: builder ()
. with_backend ( backend )
. with_transport_factory ( TokioWebSocketTransportFactory :: new ())
. with_http_client ( UreqHttpClient :: new ())
. with_runtime ( TokioRuntime )
. on_event ( | event , _client | async move {
match &* event {
Event :: PairingQrCode { code , timeout } => {
println! ( "Scan this QR code (valid for {}s):" , timeout . as_secs ());
println! ( "{}" , code );
}
Event :: PairSuccess ( info ) => {
println! ( "Paired as {}" , info . id);
}
_ => {}
}
})
. build ()
. await ? ;
bot . run () . await ?. await ? ;
Ok (())
}
QR Code Events
Event: Event::PairingQrCode
// wacore/src/types/events.rs
Event :: PairingQrCode {
code : String , // ASCII art QR or data string
timeout : Duration , // Validity duration (60s first, 20s subsequent)
}
Generated in: src/pair.rs:63-116
The rotation loop includes a safety guard that checks is_logged_in() before emitting each QR code. This prevents stale QR events from firing after pairing completes — important for single-threaded runtimes, fast auto-pair scenarios, and mock servers where the spawned task may not be polled until after pairing succeeds.
for code in codes_clone {
// Safety guard: pairing may complete before this task gets polled
if client_clone . is_logged_in () {
info! ( "Already logged in, stopping QR rotation." );
return ;
}
let timeout = if is_first {
is_first = false ;
Duration :: from_secs ( 60 )
} else {
Duration :: from_secs ( 20 )
};
client . core . event_bus . dispatch ( Event :: PairingQrCode { code , timeout });
let sleep = client_clone . runtime . sleep ( timeout );
let stop = stop_rx . recv ();
futures :: pin_mut! ( sleep );
futures :: pin_mut! ( stop );
match futures :: future :: select ( sleep , stop ) . await {
futures :: future :: Either :: Left ( _ ) => {
// Timeout elapsed — check again in case login happened during sleep
if client_clone . is_logged_in () {
info! ( "Logged in during QR timeout, stopping rotation." );
return ;
}
}
futures :: future :: Either :: Right ( _ ) => {
info! ( "Pairing complete. Stopping QR code rotation." );
return ;
}
}
}
The rotation uses futures::future::select with an async_channel stop signal rather than Tokio-specific primitives. This keeps the QR rotation compatible with any async runtime, since the Client uses the pluggable Runtime trait for sleep and spawn operations.
Pair Code (Phone Number Linking)
How It Works
Location: src/pair_code.rs, wacore/src/pair_code.rs
Generate code: 8-character Crockford Base32 code
Stage 1 - Hello: Send phone number + encrypted ephemeral key
Server response: Returns pairing reference
User enters code: On phone: WhatsApp > Linked Devices > Link with phone number
Stage 2 - Finish: Phone confirms, companion sends key bundle
Completion: Server sends pair-success
Alphabet: Crockford Base32 (excludes 0, I, O, U)
123456789ABCDEFGHJKLMNPQRSTVWXYZ
Length: Exactly 8 characters
Example: ABCD1234, MYCODE12
Implementation
Random Code
use whatsapp_rust :: pair_code :: PairCodeOptions ;
let options = PairCodeOptions {
phone_number : "15551234567" . to_string (),
show_push_notification : true ,
.. Default :: default ()
};
let code = client . pair_with_code ( options ) . await ? ;
println! ( "Enter this code on your phone: {}" , code );
Custom Code
let options = PairCodeOptions {
phone_number : "15551234567" . to_string (),
custom_code : Some ( "MYCODE12" . to_string ()),
.. Default :: default ()
};
let code = client . pair_with_code ( options ) . await ? ;
assert_eq! ( code , "MYCODE12" );
Pair Code Options
// wacore/src/pair_code.rs
pub struct PairCodeOptions {
/// Phone number in international format (e.g., "15551234567")
/// Non-digit characters are automatically stripped
pub phone_number : String ,
/// Whether to show a push notification on the phone
pub show_push_notification : bool ,
/// Custom 8-character code (must be valid Crockford Base32)
/// If None, a random code is generated
pub custom_code : Option < String >,
/// Platform identifier (default: Chrome)
pub platform_id : PlatformId ,
/// Platform display name (default: "Chrome (Linux)")
pub platform_display : String ,
}
Platform identifiers for companion devices, matching the DeviceProps.PlatformType protobuf enum:
pub enum PlatformId {
Unknown = 0 ,
Chrome = 1 , // default
Firefox = 2 ,
InternetExplorer = 3 ,
Opera = 4 ,
Safari = 5 ,
Edge = 6 ,
Electron = 7 ,
Uwp = 8 ,
OtherWebClient = 9 ,
}
Pair Code Events
Event: Event::PairingCode
// wacore/src/types/events.rs
Event :: PairingCode {
code : String , // The 8-character pairing code
timeout : Duration , // Validity (~180 seconds)
}
Generated in: src/pair_code.rs:215-219
self . core . event_bus . dispatch ( Event :: PairingCode {
code : code . clone (),
timeout : PairCodeUtils :: code_validity (),
});
Two-Stage Flow
Stage 1: Hello
Purpose: Register phone number and encrypted ephemeral key
// src/pair_code.rs:165-174
let iq_content = PairCodeUtils :: build_companion_hello_iq (
& phone_number ,
& noise_static_pub ,
& wrapped_ephemeral ,
options . platform_id,
& options . platform_display,
options . show_push_notification,
req_id . clone (),
);
Response: Pairing reference
let pairing_ref = PairCodeUtils :: parse_companion_hello_response ( & response )
. ok_or ( PairCodeError :: MissingPairingRef ) ? ;
Stage 2: Finish
Trigger: link_code_companion_reg notification from server
Handling: src/pair_code.rs:229-376
pub ( crate ) async fn handle_pair_code_notification ( client : & Arc < Client >, node : & Node ) -> bool {
// 1. Extract primary's wrapped ephemeral pub (80 bytes)
// 2. Extract primary's identity pub (32 bytes)
// 3. Decrypt primary's ephemeral key (expensive PBKDF2)
// 4. Prepare encrypted key bundle
// 5. Send companion_finish IQ
}
Cryptography
Noise Protocol Handshake
Pattern: Noise XX (mutual authentication)
// wacore/noise/
pub struct NoiseHandshake {
initiator_static : KeyPair ,
ephemeral : KeyPair ,
// ... Noise state machine
}
Flow:
Initiator → Responder: ephemeral pub
Responder → Initiator: ephemeral pub, static pub, encrypted payload
Initiator → Responder: static pub, encrypted payload
Key Derivation
For QR Code:
// Direct key exchange - keys in QR code
For Pair Code:
// wacore/src/pair_code.rs
// Expensive PBKDF2 operation (wrapped in spawn_blocking)
let wrapped_ephemeral = tokio :: task :: spawn_blocking ( move || {
PairCodeUtils :: encrypt_ephemeral_pub ( & ephemeral_pub , & code_clone )
}) . await ? ;
Parameters:
Algorithm: AES-256-CBC
KDF: PBKDF2-HMAC-SHA256
Iterations: 2^16 (65,536)
Salt: 16 random bytes
IV: 16 random bytes
Signal Protocol Setup
After pairing:
Server sends signed device identity
Companion verifies signature
Identity keys exchanged
Pre-keys registered
// src/pair.rs:188-209
let result = PairUtils :: do_pair_crypto ( & device_state , & device_identity_bytes );
match result {
Ok (( self_signed_identity_bytes , key_index )) => {
// Store device JID, LID, account info
client . persistence_manager
. process_command ( DeviceCommand :: SetId ( Some ( jid . clone ())))
. await ;
client . persistence_manager
. process_command ( DeviceCommand :: SetAccount ( Some ( signed_identity )))
. await ;
}
Err ( e ) => {
// Send error to server
}
}
Concurrent Pairing
Both methods can run simultaneously:
// Start QR code (automatic on connection)
bot . run () . await ? ;
// Also start pair code in parallel
let code = client . pair_with_code ( options ) . await ? ;
State Management:
// src/client.rs
pub ( crate ) pairing_cancellation_tx : Mutex < Option < async_channel :: Sender <()>>>,
pub ( crate ) pair_code_state : Mutex < PairCodeState >,
Cancellation:
// src/pair.rs:140-149
async fn handle_pair_success ( ... ) {
// Cancel QR code rotation if active
if let Some ( tx ) = client . pairing_cancellation_tx . lock () . await . take () {
let _ = tx . try_send (());
debug! ( "Sent QR rotation stop signal" );
} else {
// is_logged_in guard will stop the task even without the channel
debug! ( "QR rotation channel not yet stored — is_logged_in guard will stop the task" );
}
// Clear pair code state if active
* client . pair_code_state . lock () . await = PairCodeState :: Completed ;
}
The is_logged_in() safety guard in the rotation loop acts as a fallback — even if the cancellation channel hasn’t been stored yet (race condition on fast pairing), the rotation task will exit cleanly on its next iteration.
Success Events
PairSuccess
// wacore/src/types/events.rs
#[derive( Debug , Clone , Serialize )]
pub struct PairSuccess {
pub id : Jid , // Device JID (e.g., "15551234567.0:1@s.whatsapp.net")
pub lid : Jid , // LID JID (e.g., "100000012345678.0:1@lid")
pub business_name : String , // Push name / business name
pub platform : String , // Platform identifier
}
Event :: PairSuccess ( PairSuccess { id , lid , business_name , platform })
PairError
#[derive( Debug , Clone , Serialize )]
pub struct PairError {
pub id : Jid ,
pub lid : Jid ,
pub business_name : String ,
pub platform : String ,
pub error : String , // Error description
}
Event :: PairError ( PairError { /* ... */ })
Error Handling
QR Code Errors
// Handled internally, retries with new QR codes
// If all QR codes expire, disconnects:
info! ( "All QR codes for this session have expired." );
client . disconnect () . await ;
Pair Code Errors
use wacore :: pair_code :: PairCodeError ;
match client . pair_with_code ( options ) . await {
Ok ( code ) => println! ( "Code: {}" , code ),
Err ( PairCodeError :: PhoneNumberRequired ) => {
eprintln! ( "Phone number is required" );
}
Err ( PairCodeError :: PhoneNumberTooShort ) => {
eprintln! ( "Phone number must be at least 7 digits" );
}
Err ( PairCodeError :: PhoneNumberNotInternational ) => {
eprintln! ( "Phone number must not start with 0 (use international format)" );
}
Err ( PairCodeError :: InvalidCustomCode ) => {
eprintln! ( "Custom code must be 8 valid Crockford Base32 characters" );
}
Err ( PairCodeError :: MissingPairingRef ) => {
eprintln! ( "Server did not return a pairing reference" );
}
Err ( PairCodeError :: NotWaiting ) => {
eprintln! ( "No pending pair code request" );
}
Err ( PairCodeError :: InvalidWrappedData { expected , got }) => {
eprintln! ( "Invalid wrapped data: expected {} bytes, got {}" , expected , got );
}
Err ( PairCodeError :: CryptoError ( msg )) => {
eprintln! ( "Crypto error during pairing: {}" , msg );
}
Err ( PairCodeError :: RequestFailed ( msg )) => {
eprintln! ( "Pairing request failed: {}" , msg );
}
}
Session Persistence
After Successful Pairing
State saved to storage:
Device JID (Phone Number)
LID (Long-term Identifier)
Identity keys
Noise keys
Registration ID
Push name
Next connection:
// No pairing needed - automatic reconnection
let bot = Bot :: builder ()
. with_backend ( backend )
. with_transport_factory ( TokioWebSocketTransportFactory :: new ())
. with_http_client ( UreqHttpClient :: new ())
. with_runtime ( TokioRuntime )
. build ()
. await ? ;
bot . run () . await ? ; // Uses saved session
Logout
// Clear session data
client . logout () . await ? ;
// Event emitted:
Event :: LoggedOut ( LoggedOut {
on_connect : false ,
reason : ConnectFailureReason :: LoggedOut ,
})
Best Practices
let options = PairCodeOptions {
phone_number : "15551234567" . to_string (), // International format
// Non-digits automatically stripped:
// phone_number: "+1-555-123-4567".to_string(),
.. Default :: default ()
};
Event Handling
. on_event ( | event , client | async move {
match &* event {
Event :: PairingQrCode { code , timeout } => {
// Display QR to user
println! ( "Valid for: {}s" , timeout . as_secs ());
}
Event :: PairingCode { code , timeout } => {
// Display code to user
println! ( "Enter {} on your phone" , code );
}
Event :: PairSuccess ( info ) => {
// Save success notification
println! ( "Paired: {}" , info . id);
}
Event :: PairError ( err ) => {
// Handle error
eprintln! ( "Pairing failed: {}" , err . error);
}
_ => {}
}
})
Concurrent Usage
// Both methods active - whichever completes first wins
tokio :: spawn ( async move {
if let Ok ( code ) = client . pair_with_code ( options ) . await {
println! ( "Pair code: {}" , code );
}
});
// QR codes automatically generated and rotated
bot . run () . await ? ;
Architecture Understand the project structure
Events Learn about all event types
Storage Explore session persistence
Quick Start Build your first bot