Skip to main content

whatsapp_rust/
pair_code.rs

1//! Pair code authentication for phone number linking.
2//!
3//! This module provides an alternative to QR code pairing. Users enter an
4//! 8-character code on their phone instead of scanning a QR code.
5//!
6//! # Usage
7//!
8//! ## Random Code (Default)
9//!
10//! ```rust,no_run
11//! use whatsapp_rust::pair_code::PairCodeOptions;
12//!
13//! # async fn example(client: std::sync::Arc<whatsapp_rust::Client>) -> Result<(), Box<dyn std::error::Error>> {
14//! let options = PairCodeOptions {
15//!     phone_number: "15551234567".to_string(),
16//!     ..Default::default()
17//! };
18//! let code = client.pair_with_code(options).await?;
19//! println!("Enter this code on your phone: {}", code);
20//! # Ok(())
21//! # }
22//! ```
23//!
24//! ## Custom Pairing Code
25//!
26//! You can specify your own 8-character code using Crockford Base32 alphabet
27//! (characters: `123456789ABCDEFGHJKLMNPQRSTVWXYZ` - excludes 0, I, O, U):
28//!
29//! ```rust,no_run
30//! use whatsapp_rust::pair_code::PairCodeOptions;
31//!
32//! # async fn example(client: std::sync::Arc<whatsapp_rust::Client>) -> Result<(), Box<dyn std::error::Error>> {
33//! let options = PairCodeOptions {
34//!     phone_number: "15551234567".to_string(),
35//!     custom_code: Some("MYCODE12".to_string()), // Must be exactly 8 valid chars
36//!     ..Default::default()
37//! };
38//! let code = client.pair_with_code(options).await?;
39//! assert_eq!(code, "MYCODE12");
40//! # Ok(())
41//! # }
42//! ```
43//!
44//! ## Concurrent with QR Codes
45//!
46//! Pair code and QR code can run simultaneously. Whichever completes first wins.
47
48use crate::client::Client;
49use crate::request::{InfoQuery, InfoQueryType, IqError};
50use crate::types::events::Event;
51use log::{error, info, warn};
52
53use std::sync::Arc;
54use wacore::libsignal::protocol::KeyPair;
55use wacore::pair_code::{PairCodeError, PairCodeState, PairCodeUtils};
56use wacore_binary::jid::{Jid, SERVER_JID};
57use wacore_binary::node::{Node, NodeContent};
58
59// Re-export types for user convenience
60pub use wacore::pair_code::{PairCodeOptions, PlatformId};
61
62impl Client {
63    /// Initiates pair code authentication as an alternative to QR code pairing.
64    ///
65    /// This method starts the phone number linking process. The returned code should
66    /// be displayed to the user, who then enters it on their phone in:
67    /// **WhatsApp > Linked Devices > Link a Device > Link with phone number instead**
68    ///
69    /// This can run concurrently with QR code pairing - whichever completes first wins.
70    ///
71    /// # Arguments
72    ///
73    /// * `options` - Configuration for pair code authentication
74    ///
75    /// # Returns
76    ///
77    /// * `Ok(String)` - The 8-character pairing code to display
78    /// * `Err` - If validation fails, not connected, or server error
79    ///
80    /// # Example
81    ///
82    /// ```rust,no_run
83    /// use whatsapp_rust::pair_code::PairCodeOptions;
84    ///
85    /// # async fn example(client: std::sync::Arc<whatsapp_rust::Client>) -> Result<(), Box<dyn std::error::Error>> {
86    /// let options = PairCodeOptions {
87    ///     phone_number: "15551234567".to_string(),
88    ///     show_push_notification: true,
89    ///     custom_code: None, // Generate random code
90    ///     ..Default::default()
91    /// };
92    ///
93    /// let code = client.pair_with_code(options).await?;
94    /// println!("Enter this code on your phone: {}", code);
95    /// # Ok(())
96    /// # }
97    /// ```
98    pub async fn pair_with_code(
99        self: &Arc<Self>,
100        options: PairCodeOptions,
101    ) -> Result<String, PairCodeError> {
102        // Strip non-digit characters from phone number (allows "+1-555-123-4567" format)
103        let phone_number: String = options
104            .phone_number
105            .chars()
106            .filter(|c| c.is_ascii_digit())
107            .collect();
108
109        // Validate phone number
110        if phone_number.is_empty() {
111            return Err(PairCodeError::PhoneNumberRequired);
112        }
113        if phone_number.len() < 7 {
114            return Err(PairCodeError::PhoneNumberTooShort);
115        }
116        if phone_number.starts_with('0') {
117            return Err(PairCodeError::PhoneNumberNotInternational);
118        }
119
120        // Generate or validate code
121        let code = match &options.custom_code {
122            Some(custom) => {
123                if !PairCodeUtils::validate_code(custom) {
124                    return Err(PairCodeError::InvalidCustomCode);
125                }
126                custom.to_uppercase()
127            }
128            None => PairCodeUtils::generate_code(),
129        };
130
131        info!(
132            target: "Client/PairCode",
133            "Starting pair code authentication for phone: {}",
134            phone_number
135        );
136
137        // Generate ephemeral keypair for this pairing session
138        let ephemeral_keypair = KeyPair::generate(&mut rand::make_rng::<rand::rngs::StdRng>());
139
140        // Get device state for noise key
141        let device_snapshot = self.persistence_manager.get_device_snapshot().await;
142        let noise_static_pub: [u8; 32] = device_snapshot
143            .noise_key
144            .public_key
145            .public_key_bytes()
146            .try_into()
147            .expect("noise key is 32 bytes");
148
149        // Derive key and encrypt ephemeral pub (expensive PBKDF2 operation)
150        // Run in spawn_blocking to avoid stalling the async runtime
151        let code_clone = code.clone();
152        let ephemeral_pub: [u8; 32] = ephemeral_keypair
153            .public_key
154            .public_key_bytes()
155            .try_into()
156            .expect("ephemeral key is 32 bytes");
157
158        let wrapped_ephemeral = wacore::runtime::blocking(&*self.runtime, move || {
159            PairCodeUtils::encrypt_ephemeral_pub(&ephemeral_pub, &code_clone)
160        })
161        .await;
162
163        // Build the stage 1 IQ node
164        let req_id = self.generate_request_id();
165        let iq_content = PairCodeUtils::build_companion_hello_iq(
166            &phone_number,
167            &noise_static_pub,
168            &wrapped_ephemeral,
169            options.platform_id,
170            &options.platform_display,
171            options.show_push_notification,
172            req_id.clone(),
173        );
174
175        // Send the IQ and wait for response using the standard send_iq method
176        let query = InfoQuery {
177            query_type: InfoQueryType::Set,
178            namespace: "md",
179            to: Jid::new("", SERVER_JID),
180            target: None,
181            content: Some(NodeContent::Nodes(
182                iq_content
183                    .children()
184                    .map(|c| c.to_vec())
185                    .unwrap_or_default(),
186            )),
187            id: Some(req_id),
188            timeout: Some(std::time::Duration::from_secs(30)),
189        };
190
191        let response = self
192            .send_iq(query)
193            .await
194            .map_err(|e: IqError| PairCodeError::RequestFailed(e.to_string()))?;
195
196        // Extract pairing ref from response
197        let pairing_ref = PairCodeUtils::parse_companion_hello_response(&response)
198            .ok_or(PairCodeError::MissingPairingRef)?;
199
200        info!(
201            target: "Client/PairCode",
202            "Stage 1 complete, waiting for phone confirmation. Code: {}",
203            code
204        );
205
206        // Store state for when phone confirms
207        *self.pair_code_state.lock().await = PairCodeState::WaitingForPhoneConfirmation {
208            pairing_ref,
209            phone_jid: phone_number,
210            pair_code: code.clone(),
211            ephemeral_keypair: Box::new(ephemeral_keypair),
212        };
213
214        // Dispatch event for user to display the code
215        self.core.event_bus.dispatch(&Event::PairingCode {
216            code: code.clone(),
217            timeout: PairCodeUtils::code_validity(),
218        });
219
220        Ok(code)
221    }
222}
223
224/// Handles the `link_code_companion_reg` notification (stage 2 trigger).
225///
226/// This is called when the user enters the code on their phone. The notification
227/// contains the primary device's encrypted ephemeral public key and identity public key.
228pub(crate) async fn handle_pair_code_notification(client: &Arc<Client>, node: &Node) -> bool {
229    // Check if this is a link_code_companion_reg notification
230    let Some(reg_node) = node.get_optional_child_by_tag(&["link_code_companion_reg"]) else {
231        return false;
232    };
233
234    // Extract primary's wrapped ephemeral public key (80 bytes: salt + iv + encrypted key)
235    let primary_wrapped_ephemeral = match reg_node
236        .get_optional_child_by_tag(&["link_code_pairing_wrapped_primary_ephemeral_pub"])
237        .and_then(|n| n.content.as_ref())
238    {
239        Some(NodeContent::Bytes(b)) if b.len() == 80 => b.clone(),
240        _ => {
241            warn!(
242                target: "Client/PairCode",
243                "Missing or invalid primary wrapped ephemeral pub in notification"
244            );
245            return false;
246        }
247    };
248
249    // Extract primary's identity public key (32 bytes, unencrypted)
250    let primary_identity_pub: [u8; 32] = match reg_node
251        .get_optional_child_by_tag(&["primary_identity_pub"])
252        .and_then(|n| n.content.as_ref())
253    {
254        Some(NodeContent::Bytes(b)) if b.len() == 32 => match b.as_slice().try_into() {
255            Ok(arr) => arr,
256            Err(_) => {
257                warn!(
258                    target: "Client/PairCode",
259                    "Failed to convert primary identity pub to array"
260                );
261                return false;
262            }
263        },
264        _ => {
265            warn!(
266                target: "Client/PairCode",
267                "Missing or invalid primary identity pub in notification"
268            );
269            return false;
270        }
271    };
272
273    // Get current pair code state
274    let mut state_guard = client.pair_code_state.lock().await;
275    let state = std::mem::take(&mut *state_guard);
276    drop(state_guard);
277
278    let (pairing_ref, phone_jid, pair_code, ephemeral_keypair) = match state {
279        PairCodeState::WaitingForPhoneConfirmation {
280            pairing_ref,
281            phone_jid,
282            pair_code,
283            ephemeral_keypair,
284        } => (pairing_ref, phone_jid, pair_code, ephemeral_keypair),
285        _ => {
286            warn!(
287                target: "Client/PairCode",
288                "Received pair code notification but not in waiting state"
289            );
290            return false;
291        }
292    };
293
294    info!(
295        target: "Client/PairCode",
296        "Phone confirmed code entry, processing stage 2"
297    );
298
299    // Decrypt primary's ephemeral public key (expensive PBKDF2 operation)
300    // Run in spawn_blocking to avoid stalling the async runtime
301    let pair_code_clone = pair_code.clone();
302    let primary_ephemeral_pub = match wacore::runtime::blocking(&*client.runtime, move || {
303        PairCodeUtils::decrypt_primary_ephemeral_pub(&primary_wrapped_ephemeral, &pair_code_clone)
304    })
305    .await
306    {
307        Ok(pub_key) => pub_key,
308        Err(e) => {
309            error!(
310                target: "Client/PairCode",
311                "Failed to decrypt primary ephemeral pub: {e}"
312            );
313            return false;
314        }
315    };
316
317    // Get device keys
318    let device_snapshot = client.persistence_manager.get_device_snapshot().await;
319
320    // Prepare encrypted key bundle (includes rotated adv_secret_key)
321    let (wrapped_bundle, new_adv_secret) = match PairCodeUtils::prepare_key_bundle(
322        &ephemeral_keypair,
323        &primary_ephemeral_pub,
324        &primary_identity_pub,
325        &device_snapshot.identity_key,
326    ) {
327        Ok(result) => result,
328        Err(e) => {
329            error!(target: "Client/PairCode", "Failed to prepare key bundle: {e}");
330            return false;
331        }
332    };
333
334    // Persist rotated adv_secret_key so HMAC verification works in pair-success.
335    client
336        .persistence_manager
337        .process_command(crate::store::commands::DeviceCommand::SetAdvSecretKey(
338            new_adv_secret,
339        ))
340        .await;
341
342    // Build and send stage 2 IQ
343    let req_id = client.generate_request_id();
344    let identity_pub: [u8; 32] = device_snapshot
345        .identity_key
346        .public_key
347        .public_key_bytes()
348        .try_into()
349        .expect("identity key is 32 bytes");
350
351    let iq = PairCodeUtils::build_companion_finish_iq(
352        &phone_jid,
353        wrapped_bundle,
354        &identity_pub,
355        &pairing_ref,
356        req_id,
357    );
358
359    if let Err(e) = client.send_node(iq).await {
360        error!(target: "Client/PairCode", "Failed to send companion_finish: {e}");
361        return false;
362    }
363
364    info!(
365        target: "Client/PairCode",
366        "Sent companion_finish, waiting for pair-success"
367    );
368
369    // Mark state as completed
370    *client.pair_code_state.lock().await = PairCodeState::Completed;
371
372    true
373}