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};
52use rand::TryRngCore;
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::rngs::OsRng.unwrap_err());
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 = tokio::task::spawn_blocking(move || {
159            PairCodeUtils::encrypt_ephemeral_pub(&ephemeral_pub, &code_clone)
160        })
161        .await
162        .map_err(|e| PairCodeError::CryptoError(format!("spawn_blocking failed: {e}")))?;
163
164        // Build the stage 1 IQ node
165        let req_id = self.generate_request_id();
166        let iq_content = PairCodeUtils::build_companion_hello_iq(
167            &phone_number,
168            &noise_static_pub,
169            &wrapped_ephemeral,
170            options.platform_id,
171            &options.platform_display,
172            options.show_push_notification,
173            req_id.clone(),
174        );
175
176        // Send the IQ and wait for response using the standard send_iq method
177        let query = InfoQuery {
178            query_type: InfoQueryType::Set,
179            namespace: "md",
180            to: Jid::new("", SERVER_JID),
181            target: None,
182            content: Some(NodeContent::Nodes(
183                iq_content
184                    .children()
185                    .map(|c| c.to_vec())
186                    .unwrap_or_default(),
187            )),
188            id: Some(req_id),
189            timeout: Some(std::time::Duration::from_secs(30)),
190        };
191
192        let response = self
193            .send_iq(query)
194            .await
195            .map_err(|e: IqError| PairCodeError::RequestFailed(e.to_string()))?;
196
197        // Extract pairing ref from response
198        let pairing_ref = PairCodeUtils::parse_companion_hello_response(&response)
199            .ok_or(PairCodeError::MissingPairingRef)?;
200
201        info!(
202            target: "Client/PairCode",
203            "Stage 1 complete, waiting for phone confirmation. Code: {}",
204            code
205        );
206
207        // Store state for when phone confirms
208        *self.pair_code_state.lock().await = PairCodeState::WaitingForPhoneConfirmation {
209            pairing_ref,
210            phone_jid: phone_number,
211            pair_code: code.clone(),
212            ephemeral_keypair,
213        };
214
215        // Dispatch event for user to display the code
216        self.core.event_bus.dispatch(&Event::PairingCode {
217            code: code.clone(),
218            timeout: PairCodeUtils::code_validity(),
219        });
220
221        Ok(code)
222    }
223}
224
225/// Handles the `link_code_companion_reg` notification (stage 2 trigger).
226///
227/// This is called when the user enters the code on their phone. The notification
228/// contains the primary device's encrypted ephemeral public key and identity public key.
229pub(crate) async fn handle_pair_code_notification(client: &Arc<Client>, node: &Node) -> bool {
230    // Check if this is a link_code_companion_reg notification
231    let Some(reg_node) = node.get_optional_child_by_tag(&["link_code_companion_reg"]) else {
232        return false;
233    };
234
235    // Extract primary's wrapped ephemeral public key (80 bytes: salt + iv + encrypted key)
236    let primary_wrapped_ephemeral = match reg_node
237        .get_optional_child_by_tag(&["link_code_pairing_wrapped_primary_ephemeral_pub"])
238        .and_then(|n| n.content.as_ref())
239    {
240        Some(NodeContent::Bytes(b)) if b.len() == 80 => b.clone(),
241        _ => {
242            warn!(
243                target: "Client/PairCode",
244                "Missing or invalid primary wrapped ephemeral pub in notification"
245            );
246            return false;
247        }
248    };
249
250    // Extract primary's identity public key (32 bytes, unencrypted)
251    let primary_identity_pub: [u8; 32] = match reg_node
252        .get_optional_child_by_tag(&["primary_identity_pub"])
253        .and_then(|n| n.content.as_ref())
254    {
255        Some(NodeContent::Bytes(b)) if b.len() == 32 => match b.as_slice().try_into() {
256            Ok(arr) => arr,
257            Err(_) => {
258                warn!(
259                    target: "Client/PairCode",
260                    "Failed to convert primary identity pub to array"
261                );
262                return false;
263            }
264        },
265        _ => {
266            warn!(
267                target: "Client/PairCode",
268                "Missing or invalid primary identity pub in notification"
269            );
270            return false;
271        }
272    };
273
274    // Get current pair code state
275    let mut state_guard = client.pair_code_state.lock().await;
276    let state = std::mem::take(&mut *state_guard);
277    drop(state_guard);
278
279    let (pairing_ref, phone_jid, pair_code, ephemeral_keypair) = match state {
280        PairCodeState::WaitingForPhoneConfirmation {
281            pairing_ref,
282            phone_jid,
283            pair_code,
284            ephemeral_keypair,
285        } => (pairing_ref, phone_jid, pair_code, ephemeral_keypair),
286        _ => {
287            warn!(
288                target: "Client/PairCode",
289                "Received pair code notification but not in waiting state"
290            );
291            return false;
292        }
293    };
294
295    info!(
296        target: "Client/PairCode",
297        "Phone confirmed code entry, processing stage 2"
298    );
299
300    // Decrypt primary's ephemeral public key (expensive PBKDF2 operation)
301    // Run in spawn_blocking to avoid stalling the async runtime
302    let pair_code_clone = pair_code.clone();
303    let primary_ephemeral_pub = match tokio::task::spawn_blocking(move || {
304        PairCodeUtils::decrypt_primary_ephemeral_pub(&primary_wrapped_ephemeral, &pair_code_clone)
305    })
306    .await
307    {
308        Ok(Ok(pub_key)) => pub_key,
309        Ok(Err(e)) => {
310            error!(
311                target: "Client/PairCode",
312                "Failed to decrypt primary ephemeral pub: {e}"
313            );
314            return false;
315        }
316        Err(e) => {
317            error!(
318                target: "Client/PairCode",
319                "spawn_blocking failed: {e}"
320            );
321            return false;
322        }
323    };
324
325    // Get device keys
326    let device_snapshot = client.persistence_manager.get_device_snapshot().await;
327
328    // Prepare encrypted key bundle
329    // TODO: Store `new_adv_secret` via DeviceCommand::SetAdvSecretKey to enable HMAC
330    // verification in pair-success. Currently the HMAC check in do_pair_crypto is
331    // commented out, so pairing works without it. See wacore/src/pair.rs:147-153.
332    let (wrapped_bundle, _new_adv_secret) = match PairCodeUtils::prepare_key_bundle(
333        &ephemeral_keypair,
334        &primary_ephemeral_pub,
335        &primary_identity_pub,
336        &device_snapshot.identity_key,
337    ) {
338        Ok(result) => result,
339        Err(e) => {
340            error!(target: "Client/PairCode", "Failed to prepare key bundle: {e}");
341            return false;
342        }
343    };
344
345    // Build and send stage 2 IQ
346    let req_id = client.generate_request_id();
347    let identity_pub: [u8; 32] = device_snapshot
348        .identity_key
349        .public_key
350        .public_key_bytes()
351        .try_into()
352        .expect("identity key is 32 bytes");
353
354    let iq = PairCodeUtils::build_companion_finish_iq(
355        &phone_jid,
356        wrapped_bundle,
357        &identity_pub,
358        &pairing_ref,
359        req_id,
360    );
361
362    if let Err(e) = client.send_node(iq).await {
363        error!(target: "Client/PairCode", "Failed to send companion_finish: {e}");
364        return false;
365    }
366
367    info!(
368        target: "Client/PairCode",
369        "Sent companion_finish, waiting for pair-success"
370    );
371
372    // Mark state as completed
373    *client.pair_code_state.lock().await = PairCodeState::Completed;
374
375    true
376}