diff --git a/src/client.rs b/src/client.rs index b81735e6e..a7fa16a2f 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1160,6 +1160,10 @@ pub struct Client { pub(crate) connected_notifier: Arc, pub(crate) major_sync_task_sender: async_channel::Sender, pub(crate) pairing_cancellation_tx: Arc>>>, + /// Asks the QR rotation task to re-render the ref it is already showing. + /// The payload embeds the adv secret, so a rotation has to reach the code + /// on screen and not just the next one. + pub(crate) pairing_qr_refresh_tx: Arc>>>, /// State machine for pair code authentication flow. /// Tracks the pending pair code request and ephemeral keys. diff --git a/src/client/lifecycle.rs b/src/client/lifecycle.rs index 8607eae87..052d12dfc 100644 --- a/src/client/lifecycle.rs +++ b/src/client/lifecycle.rs @@ -353,6 +353,7 @@ impl Client { connected_notifier: Arc::new(event_listener::Event::new()), major_sync_task_sender: tx, pairing_cancellation_tx: Arc::new(Mutex::new(None)), + pairing_qr_refresh_tx: Arc::new(Mutex::new(None)), pair_code_state: Arc::new(Mutex::new(wacore::pair_code::PairCodeState::default())), passkey_state: Arc::new(Mutex::new(crate::passkey::flow::PasskeyFlowState::default())), passkey_opening: AtomicBool::new(false), @@ -922,6 +923,20 @@ impl Client { #[cfg(not(feature = "client-lifecycle"))] pub(crate) async fn cleanup_connection_state(self: &Arc) { self.cleanup_connection_state_inner().await; + self.clear_connection_scoped_pair_code().await; + } + + /// A pair-code flow belongs to the connection that carried it: the pairing + /// ref and any in-flight `companion_hello` die with the socket, and the + /// server routes no `primary_hello` to a session it has dropped. Left + /// standing, the outstanding-code guard would reject the very request that + /// reconnecting exists to make. + /// + /// Runs after the inner teardown, so the generation is already retired and + /// the transport already closed: a request that claims the slot from here + /// on is one the next connection will carry. + async fn clear_connection_scoped_pair_code(self: &Arc) { + *self.pair_code_state.lock().await = wacore::pair_code::PairCodeState::Idle; } #[cfg_attr( @@ -932,6 +947,7 @@ impl Client { pub(crate) async fn cleanup_connection_state(self: &Arc) { if self.lifecycle.is_none() { self.cleanup_connection_state_inner().await; + self.clear_connection_scoped_pair_code().await; return; } @@ -951,6 +967,7 @@ impl Client { Ok(Err(panic)) => std::panic::resume_unwind(panic), Err(_) => error!("Detached connection cleanup stopped before completion"), } + self.clear_connection_scoped_pair_code().await; } async fn cleanup_connection_state_inner(&self) { diff --git a/src/handlers/notification/companion_reg.rs b/src/handlers/notification/companion_reg.rs new file mode 100644 index 000000000..7667415dd --- /dev/null +++ b/src/handlers/notification/companion_reg.rs @@ -0,0 +1,78 @@ +//! `` — the server retiring an +//! unpaired companion's registration material. +//! +//! WA Web (`Handle/CompanionReqRefreshNotification.js`) accepts the stanza with +//! either a `companion_reg_refresh` or a `pair-device-rotate-qr` child, rejects +//! it outright when neither is present, and answers by regenerating the ADV +//! secret key. That key is what the QR payload advertises, so ignoring the +//! request leaves us handing out a QR built on a secret the server has retired. + +use crate::client::Client; +use log::{debug, warn}; +use std::sync::Arc; +use wacore_binary::NodeRef; + +/// The two children WA Web's parser accepts on this notification. +const REFRESH_CHILDREN: [&str; 2] = ["companion_reg_refresh", "pair-device-rotate-qr"]; + +/// Rotate the ADV secret and re-render the QR that advertises it. +async fn rotate_companion_registration(client: &Arc) { + use rand::Rng as _; + let mut secret = [0u8; 32]; + rand::make_rng::().fill_bytes(&mut secret); + client + .persistence_manager + .process_command(wacore::store::commands::DeviceCommand::SetAdvSecretKey( + secret, + )) + .await; + debug!( + target: "Client/PairRefresh", + "Rotated the adv secret the server asked to retire" + ); + // The QR on screen embeds the old secret; re-render it rather than let it + // stay scannable until its ref expires. + client.refresh_pairing_qr().await; +} + +pub(super) async fn handle_companion_reg_refresh(client: &Arc, node: &NodeRef<'_>) { + if !REFRESH_CHILDREN + .iter() + .any(|tag| node.get_optional_child_by_tag(&[tag]).is_some()) + { + warn!( + target: "Client/PairRefresh", + "companion_reg_refresh carries neither companion_reg_refresh nor pair-device-rotate-qr; ignoring" + ); + return; + } + + // The one place we knowingly diverge from WA Web, which rotates + // unconditionally. Once stage 2 has run, this is the secret the pending + // pair-success HMAC is computed over, and rotating it turns a link that was + // about to succeed into one that cannot. A code that is only displayed does + // not qualify: stage 2 derives its own secret when the phone answers, so + // deferring there would protect nothing while leaving the QR that shares + // this connection advertising material the server just retired. + // Held across the write, not just the check: stage 2 derives its secret and + // builds `companion_finish` under this same lock, so releasing it in + // between would let a `primary_hello` land in the gap and have its secret + // overwritten — the pair-success would then be verified against the wrong + // one. + let state = client.pair_code_state.lock().await; + if state.awaiting_pair_success() { + debug!( + target: "Client/PairRefresh", + "Server asked to refresh companion registration; keeping the adv secret a pending pair-success depends on" + ); + // Dropped, not queued. Replaying it later would have to be atomic with + // pair-success completing, with the flow being retired on any of four + // paths, and with the connection going away — four synchronisation + // points to recover a QR in a window where the phone-number flow is the + // one being used anyway. If that flow fails, the QR advertises a retired + // secret until the next reconnect, which is where the server asks again. + return; + } + + rotate_companion_registration(client).await; +} diff --git a/src/handlers/notification/mod.rs b/src/handlers/notification/mod.rs index 9b4f287aa..3683f4e2d 100644 --- a/src/handlers/notification/mod.rs +++ b/src/handlers/notification/mod.rs @@ -52,6 +52,7 @@ async fn handle_notification_impl(client: &Arc, node: Arc) "link_code_companion_reg" => { crate::pair_code::handle_pair_code_notification(client, nr).await; } + "companion_reg_refresh" => handle_companion_reg_refresh(client, nr).await, "business" => handle_business_notification(client, nr).await, "picture" => handle_picture_notification(client, nr), "privacy_token" => handle_privacy_token_notification(client, nr).await, @@ -83,11 +84,13 @@ async fn handle_notification_impl(client: &Arc, node: Arc) } } +mod companion_reg; mod device; mod groups; mod privacy_business; mod profile; +use companion_reg::*; // `pub(crate)` re-export keeps `crate::handlers::notification::handle_local_identity_change` // resolving for device_registry.rs. pub(crate) use device::*; @@ -113,6 +116,139 @@ mod tests { crate::test_utils::node_to_owned_ref(&node) } + // ── `companion_reg_refresh` (WA Web `Handle/CompanionReqRefreshNotification.js`) + // + // The server tells an unpaired companion to re-mint its registration. WA + // Web regenerates the ADV secret key and acks; we routed the stanza to the + // catch-all instead, so the QR we kept advertising carried a secret the + // server had already retired. + + fn companion_reg_refresh_notif(child: &'static str) -> Node { + NodeBuilder::new("notification") + .attr("type", "companion_reg_refresh") + .attr("from", "s.whatsapp.net") + .attr("id", "reg-refresh-1") + .children([NodeBuilder::new(child).build()]) + .build() + } + + async fn adv_secret(client: &Arc) -> [u8; 32] { + client + .persistence_manager + .get_device_snapshot() + .adv_secret_key + } + + #[tokio::test] + async fn companion_reg_refresh_rotates_the_adv_secret() { + let client = create_test_client().await; + let before = adv_secret(&client).await; + + let notif = companion_reg_refresh_notif("companion_reg_refresh"); + handle_notification_impl(&client, node_to_arc(notif)).await; + + assert_ne!( + adv_secret(&client).await, + before, + "companion_reg_refresh must re-mint the ADV secret the QR advertises" + ); + } + + /// WA Web accepts either child tag on this notification. + #[tokio::test] + async fn pair_device_rotate_qr_is_the_same_request() { + let client = create_test_client().await; + let before = adv_secret(&client).await; + + let notif = companion_reg_refresh_notif("pair-device-rotate-qr"); + handle_notification_impl(&client, node_to_arc(notif)).await; + + assert_ne!(adv_secret(&client).await, before); + } + + /// WA Web's parser rejects the stanza when neither child is present; a + /// malformed notification must not silently rotate live key material. + #[tokio::test] + async fn companion_reg_refresh_without_a_known_child_is_ignored() { + let client = create_test_client().await; + let before = adv_secret(&client).await; + + let notif = companion_reg_refresh_notif("something-else"); + handle_notification_impl(&client, node_to_arc(notif)).await; + + assert_eq!(adv_secret(&client).await, before); + } + + /// Regression: the displayed-code window and the pending-link window are not + /// the same. A `primary_hello` accepted near the end of the 180s validity + /// leaves `companion_finish` waiting up to another minute for pair-success, + /// and the ADV secret that HMAC is computed over is already derived — but + /// the code itself has expired, so a validity-only guard would rotate right + /// through it. + #[tokio::test] + async fn companion_reg_refresh_waits_for_a_pending_pair_success() { + use wacore::libsignal::protocol::KeyPair; + use wacore::pair_code::{PairCodeState, PairCodeUtils}; + + let client = create_test_client().await; + let expired = + wacore::time::now_secs() - (PairCodeUtils::code_validity().as_secs() as i64 + 1); + *client.pair_code_state.lock().await = PairCodeState::WaitingForPhoneConfirmation { + pairing_ref: b"3@2:ref".to_vec(), + phone_jid: "15551234567".to_string(), + pair_code: "ABCD1234".to_string(), + ephemeral_keypair: Box::new(KeyPair::generate( + &mut rand::make_rng::(), + )), + code_generation_ts: expired, + // Stage 2 ran: companion_finish is out and pair-success is pending. + primary_hello_attempt_count: 1, + }; + let before = adv_secret(&client).await; + + let notif = companion_reg_refresh_notif("companion_reg_refresh"); + handle_notification_impl(&client, node_to_arc(notif)).await; + + assert_eq!( + adv_secret(&client).await, + before, + "the pending pair-success HMAC is computed over this secret" + ); + } + + /// A code that is merely displayed does not depend on the current ADV + /// secret — stage 2 derives and persists a fresh one when the phone + /// answers. Deferring there would protect nothing while leaving the QR, + /// which shares the connection, advertising registration material the + /// server asked to retire for the code's whole validity window. + #[tokio::test] + async fn a_merely_displayed_code_does_not_defer_the_rotation() { + use wacore::libsignal::protocol::KeyPair; + use wacore::pair_code::PairCodeState; + + let client = create_test_client().await; + *client.pair_code_state.lock().await = PairCodeState::WaitingForPhoneConfirmation { + pairing_ref: b"3@2:ref".to_vec(), + phone_jid: "15551234567".to_string(), + pair_code: "ABCD1234".to_string(), + ephemeral_keypair: Box::new(KeyPair::generate( + &mut rand::make_rng::(), + )), + code_generation_ts: wacore::time::now_secs(), + primary_hello_attempt_count: 0, + }; + let before = adv_secret(&client).await; + + let notif = companion_reg_refresh_notif("companion_reg_refresh"); + handle_notification_impl(&client, node_to_arc(notif)).await; + + assert_ne!( + adv_secret(&client).await, + before, + "nothing depends on this secret yet, and the QR still needs it rotated" + ); + } + #[test] fn test_parse_device_add_notification() { // Per WhatsApp Web: add operation has single device + key-index-list diff --git a/src/pair.rs b/src/pair.rs index 0598b6022..e830af64f 100644 --- a/src/pair.rs +++ b/src/pair.rs @@ -33,6 +33,25 @@ pub fn make_qr_data_with_client_type( PairUtils::make_qr_data(&device_state, ref_str, client_type) } +impl Client { + /// Ask the QR rotation task to re-render the ref it is currently showing. + /// + /// The QR payload embeds the adv secret key, so anything that re-mints that + /// key has to reach the code already on screen — waiting out the current + /// ref would leave a scannable code whose pairing data `handle_pair_success` + /// then verifies against a secret that no longer matches. WA Web drives its + /// QR re-render from an adv-secret change through the same path as a ref + /// change (`Link/DeviceQrcode.react.js`). + /// + /// A no-op when no rotation is in progress. + pub(crate) async fn refresh_pairing_qr(self: &Arc) { + if let Some(tx) = self.pairing_qr_refresh_tx.lock().await.as_ref() { + // Full means a refresh is already queued, which is just as good. + let _ = tx.try_send(()); + } + } +} + #[cfg_attr( feature = "tracing", tracing::instrument(name = "wa.pair.handle_iq", level = "debug", skip_all) @@ -56,27 +75,29 @@ pub async fn handle_iq(client: &Arc, node: &NodeRef<'_>) -> bool { warn!("Failed to send acknowledgement: {e:?}"); } - let mut codes = Vec::new(); - - let device_snapshot = client.persistence_manager.get_device_snapshot(); - let device_state = DeviceState { - identity_key: device_snapshot.identity_key.clone(), - noise_key: device_snapshot.noise_key.clone(), - adv_secret_key: device_snapshot.adv_secret_key, - }; - let client_type = - companion_web_client_type_for_props(&device_snapshot.device_props); - - for grandchild in child.get_children_by_tag("ref") { - if let Some(bytes) = grandchild.content_bytes() - && let Ok(r) = std::str::from_utf8(bytes) - { - codes.push(PairUtils::make_qr_data(&device_state, r, client_type)); - } - } + // Refs, not payloads: the QR string embeds the ADV secret, + // and anything that re-mints it mid-rotation (see the + // `companion_reg_refresh` handler) would leave every queued + // payload advertising a retired one. WA Web builds each + // payload as it publishes the ref, from the state of that + // moment. + let refs: Vec = child + .get_children_by_tag("ref") + .filter_map(|grandchild| { + let bytes = grandchild.content_bytes()?; + std::str::from_utf8(bytes).ok().map(str::to_owned) + }) + .collect(); let (stop_tx, stop_rx) = async_channel::bounded::<()>(1); - let codes_clone = codes.clone(); + let (refresh_tx, refresh_rx) = async_channel::bounded::<()>(1); + // Published before the task can run: `spawn` may poll it + // immediately or on another thread, and a refresh arriving + // in that window would find no sender and be dropped — + // leaving the code already on screen keyed to a secret the + // server has retired. + *client.pairing_cancellation_tx.lock().await = Some(stop_tx); + *client.pairing_qr_refresh_tx.lock().await = Some(refresh_tx); let client_clone = client.clone(); client @@ -84,7 +105,7 @@ pub async fn handle_iq(client: &Arc, node: &NodeRef<'_>) -> bool { .spawn(Box::pin(async move { let mut is_first = true; - for code in codes_clone { + 'refs: for pairing_ref in refs { // Guard: pairing may complete before this task gets polled // (single-threaded runtimes, fast auto-pair, mock servers) if client_clone.is_logged_in() { @@ -92,48 +113,126 @@ pub async fn handle_iq(client: &Arc, node: &NodeRef<'_>) -> bool { return; } - let timeout = if is_first { + let ttl = if is_first { is_first = false; std::time::Duration::from_secs(60) } else { std::time::Duration::from_secs(20) }; - - client_clone.core.event_bus.dispatch(Event::PairingQrCode( - crate::types::events::PairingQrCode::builder() - .code(code) - .timeout(timeout) - .build(), - )); - - let sleep = client_clone.runtime.sleep(timeout); - let stop = stop_rx.recv(); + let started = wacore::time::Instant::now(); + + // Re-rendering the ref already on screen is its + // own loop, because the payload embeds the adv + // secret: a rotation mid-ref would otherwise + // leave a scannable code keyed to a secret the + // server has retired, for as long as this ref + // has left. WA Web re-renders through one path + // for a ref change and for an adv-secret change + // (`Link/DeviceQrcode.react.js`). + // One sleep for the whole ref, polled across + // re-renders rather than recreated by them: it + // is the runtime's own clock that decides when + // this ref is spent, and restarting it would + // let a refresh extend a ref past the deadline + // the server set. `started` only feeds the + // advisory countdown on the event. + let sleep = client_clone.runtime.sleep(ttl); futures::pin_mut!(sleep); - futures::pin_mut!(stop); - match futures::future::select(sleep, stop).await { - futures::future::Either::Left(_) => { - if client_clone.is_logged_in() { - info!( - "Logged in during QR timeout, stopping rotation." - ); + + loop { + let snapshot = + client_clone.persistence_manager.get_device_snapshot(); + let code = PairUtils::make_qr_data( + &DeviceState { + identity_key: snapshot.identity_key.clone(), + noise_key: snapshot.noise_key.clone(), + adv_secret_key: snapshot.adv_secret_key, + }, + &pairing_ref, + companion_web_client_type_for_props(&snapshot.device_props), + ); + client_clone.core.event_bus.dispatch(Event::PairingQrCode( + crate::types::events::PairingQrCode::builder() + .code(code) + .timeout(ttl.saturating_sub(started.elapsed())) + .build(), + )); + + let stop = stop_rx.recv(); + let refresh = refresh_rx.recv(); + futures::pin_mut!(stop); + futures::pin_mut!(refresh); + let outcome = futures::future::select( + sleep.as_mut(), + futures::future::select(stop, refresh), + ) + .await; + match outcome { + futures::future::Either::Left(_) => { + if client_clone.is_logged_in() { + info!( + "Logged in during QR timeout, stopping rotation." + ); + return; + } + continue 'refs; + } + futures::future::Either::Right(( + futures::future::Either::Left(_), + _, + )) => { + info!("Pairing complete. Stopping QR code rotation."); return; } - } - futures::future::Either::Right(_) => { - info!("Pairing complete. Stopping QR code rotation."); - return; - } + // Same ref and deadline, rebuilt payload. + futures::future::Either::Right(( + futures::future::Either::Right(_), + _, + )) => continue, + } } } - if !client_clone.is_logged_in() { - info!("All QR codes for this session have expired."); + if client_clone.is_logged_in() { + return; + } + + // WA Web stops here without closing the socket: its + // rotation timer (`Handle/PairDevice.js`) cancels + // itself and reports `UNPAIRED_IDLE`, leaving the + // "click to reload" decision to the surface above. + // That matters because the same connection can be + // carrying a phone-number flow, whose code stays + // valid past the rotation budget the six refs buy + // (60s + 5×20s) — disconnecting would revoke a code + // we advertised as still good, and the server would + // route the eventual `primary_hello` to a session + // that no longer exists. QR-only callers keep the + // self-disconnect they rely on to get fresh refs. + let pair_code_outstanding = client_clone + .pair_code_state + .lock() + .await + .is_outstanding(wacore::time::now_secs()); + + info!( + "All QR codes for this session have expired\ + (pair-code flow outstanding: {pair_code_outstanding})." + ); + client_clone + .core + .event_bus + .dispatch(Event::PairingQrCodesExhausted( + crate::types::events::PairingQrCodesExhausted::builder() + .disconnected(!pair_code_outstanding) + .build(), + )); + if !pair_code_outstanding { client_clone.disconnect().await; } })) .detach(); - *client.pairing_cancellation_tx.lock().await = Some(stop_tx); true } "pair-success" => { @@ -160,16 +259,6 @@ async fn handle_pair_success<'a>( request_node: &NodeRef<'a>, success_node: &NodeRef<'a>, ) { - if let Some(tx) = client.pairing_cancellation_tx.lock().await.take() { - let _ = tx.try_send(()); - debug!("Sent QR rotation stop signal"); - } else { - 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 = wacore::pair_code::PairCodeState::Completed; - client.update_server_time_offset(request_node); let req_id = match request_node.get_attr("id").map(|v| v.as_str()) { @@ -232,6 +321,24 @@ async fn handle_pair_success<'a>( match result { Ok((self_signed_identity_bytes, key_index)) => { + // Both retired only once the identity and its HMAC check out. A + // pair-success that arrives after its own flow was written off can + // otherwise cancel the replacement that succeeded it, and then fail + // verification against the secret that replacement derived — + // leaving neither able to complete. Stopping the QR rotation early + // is the same mistake in the other direction: a rejected response + // would take the displayed code down with it, and nothing puts one + // back. + *client.pair_code_state.lock().await = wacore::pair_code::PairCodeState::Completed; + if let Some(tx) = client.pairing_cancellation_tx.lock().await.take() { + let _ = tx.try_send(()); + debug!("Sent QR rotation stop signal"); + } else { + debug!( + "QR rotation channel not yet stored — is_logged_in guard will stop the task" + ); + } + let signed_identity_for_event = match waproto::codec::adv_signed_device_identity_decode( self_signed_identity_bytes.as_slice(), ) { @@ -443,3 +550,271 @@ pub async fn pair_with_qr_code(client: &Arc, qr_code: &str) -> Result<() info!(target: "Client/PairTest", "Master client sent pairing confirmation."); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::{TestEventCollector, create_iq_test_client, poll_until}; + use wacore::pair_code::PairCodeState; + use wacore_binary::Node; + use wacore_binary::builder::NodeBuilder; + + /// The six refs the server hands out in one ``, and the + /// rotation budget they buy: WA Web `Handle/PairDevice.js` waits 60s on the + /// first (`u = 6e4`) and 20s on each of the rest (`c = 20 * 1e3`). + const QR_REFS: usize = 6; + + fn qr_codes_seen(collector: &Arc) -> usize { + collector + .events() + .iter() + .filter(|e| matches!(&***e, Event::PairingQrCode(_))) + .count() + } + + /// Burn the whole rotation budget without a real-time wait. + /// + /// Each step waits for the code to actually be published before moving the + /// clock: the rotation task must have registered its sleep first, or the + /// jump lands before the timer exists and the deadline is simply pushed out + /// of reach. + async fn exhaust_qr_rotation(collector: &Arc) { + for nth in 1..=QR_REFS { + poll_until("the next QR code to be published", || { + qr_codes_seen(collector) >= nth + }) + .await; + // Longer than the first ref's 60s, which covers the 20s ones too. + tokio::time::advance(std::time::Duration::from_secs(61)).await; + } + } + + fn pair_device_iq() -> Node { + NodeBuilder::new("iq") + .attrs([ + ("from", SERVER_JID.to_string()), + ("type", "set".to_string()), + ("id", "pair-1".to_string()), + ("xmlns", "md".to_string()), + ]) + .children([NodeBuilder::new("pair-device") + .children((0..QR_REFS).map(|i| { + NodeBuilder::new("ref") + .bytes(format!("2@ref{i}").into_bytes()) + .build() + })) + .build()]) + .build() + } + + async fn set_pair_code_waiting(client: &Arc) { + *client.pair_code_state.lock().await = PairCodeState::WaitingForPhoneConfirmation { + pairing_ref: b"3@2:ref".to_vec(), + phone_jid: "15551234567".to_string(), + pair_code: "ABCD1234".to_string(), + ephemeral_keypair: Box::new(KeyPair::generate( + &mut rand::make_rng::(), + )), + code_generation_ts: wacore::time::now_secs(), + primary_hello_attempt_count: 0, + }; + } + + /// Regression: the six payloads used to be built once, from a single + /// snapshot, and handed to the rotation task. Anything that re-mints the ADV + /// secret mid-rotation (see the `companion_reg_refresh` handler) then left + /// every queued payload advertising a secret the server had retired, so a + /// scan produced pairing data `handle_pair_success` verifies against the new + /// one. WA Web builds each payload when it publishes the ref, not up front. + #[tokio::test(start_paused = true)] + async fn each_qr_payload_carries_the_adv_secret_of_its_own_moment() { + use base64::Engine as _; + + let (client, _transport) = create_iq_test_client().await; + let collector = Arc::new(TestEventCollector::default()); + client.subscribe_handler(collector.clone()).detach(); + + let iq = pair_device_iq(); + assert!(handle_iq(&client, &iq.as_node_ref()).await); + poll_until("the first QR code", || qr_codes_seen(&collector) >= 1).await; + + let rotated = [7u8; 32]; + client + .persistence_manager + .process_command(crate::store::commands::DeviceCommand::SetAdvSecretKey( + rotated, + )) + .await; + tokio::time::advance(std::time::Duration::from_secs(61)).await; + poll_until("the second QR code", || qr_codes_seen(&collector) >= 2).await; + + let expected = base64::engine::general_purpose::STANDARD.encode(rotated); + let codes: Vec = collector + .events() + .iter() + .filter_map(|e| match &**e { + Event::PairingQrCode(qr) => Some(qr.code.clone()), + _ => None, + }) + .collect(); + assert!( + !codes[0].contains(&expected), + "the first code predates the rotation" + ); + assert!( + codes[1].contains(&expected), + "a code published after the rotation must advertise the new secret" + ); + } + + /// Regression: rotating the ADV secret has to reach the code already on + /// screen, not just the next one. WA Web listens on `advSecretEventEmitter` + /// and re-renders through the same path as a ref change + /// (`Link/DeviceQrcode.react.js`); waiting out the current 20s or 60s ref + /// leaves a QR whose scan produces pairing data verified against a secret + /// that no longer matches. + #[tokio::test(start_paused = true)] + async fn rotating_the_adv_secret_re_emits_the_qr_on_screen() { + use base64::Engine as _; + + let (client, _transport) = create_iq_test_client().await; + let collector = Arc::new(TestEventCollector::default()); + client.subscribe_handler(collector.clone()).detach(); + + let iq = pair_device_iq(); + assert!(handle_iq(&client, &iq.as_node_ref()).await); + poll_until("the first QR code", || qr_codes_seen(&collector) >= 1).await; + + let rotated = [7u8; 32]; + client + .persistence_manager + .process_command(crate::store::commands::DeviceCommand::SetAdvSecretKey( + rotated, + )) + .await; + client.refresh_pairing_qr().await; + + // No clock movement: the current ref is nowhere near expiry. + poll_until("the QR to be re-emitted", || qr_codes_seen(&collector) >= 2).await; + let expected = base64::engine::general_purpose::STANDARD.encode(rotated); + let codes: Vec = collector + .events() + .iter() + .filter_map(|e| match &**e { + Event::PairingQrCode(qr) => Some(qr.code.clone()), + _ => None, + }) + .collect(); + assert!( + codes[1].contains(&expected), + "the re-emitted code must carry the new secret" + ); + assert_eq!( + codes[0].split(',').next(), + codes[1].split(',').next(), + "it is the same ref, re-rendered — not the next one" + ); + } + + /// Regression: the pair-code lifetime the exhaustion guard has to respect is + /// the link's, not the code's. A `primary_hello` accepted near the end of + /// the validity window leaves `companion_finish` pending for up to a minute + /// more, and tearing the socket down there kills a confirmation still on its + /// way. + #[tokio::test(start_paused = true)] + async fn qr_exhaustion_keeps_the_socket_up_for_a_pending_pair_success() { + let (client, _transport) = create_iq_test_client().await; + let collector = Arc::new(TestEventCollector::default()); + client.subscribe_handler(collector.clone()).detach(); + + let expired = wacore::time::now_secs() + - (wacore::pair_code::PairCodeUtils::code_validity().as_secs() as i64 + 1); + *client.pair_code_state.lock().await = PairCodeState::WaitingForPhoneConfirmation { + pairing_ref: b"3@2:ref".to_vec(), + phone_jid: "15551234567".to_string(), + pair_code: "ABCD1234".to_string(), + ephemeral_keypair: Box::new(KeyPair::generate( + &mut rand::make_rng::(), + )), + code_generation_ts: expired, + // Stage 2 ran: pair-success is still due. + primary_hello_attempt_count: 1, + }; + + let iq = pair_device_iq(); + assert!(handle_iq(&client, &iq.as_node_ref()).await); + exhaust_qr_rotation(&collector).await; + poll_until("the QR rotation task to run out of refs", || { + collector + .events() + .iter() + .any(|e| matches!(&**e, Event::PairingQrCodesExhausted(_))) + }) + .await; + + assert!( + client.is_running.load(Ordering::Relaxed), + "a pending pair-success still needs this socket" + ); + } + + /// Regression: a pair-code flow outlives the QR refs. WA Web's rotation + /// timer (`Handle/PairDevice.js`) cancels itself and reports + /// `UNPAIRED_IDLE` when the refs run out — it never closes the socket — so + /// tearing the connection down here would kill a phone-number link that the + /// server still considers open (and whose code we advertised for longer + /// than the rotation budget). + #[tokio::test(start_paused = true)] + async fn qr_exhaustion_keeps_the_socket_up_while_a_pair_code_is_outstanding() { + let (client, _transport) = create_iq_test_client().await; + let collector = Arc::new(TestEventCollector::default()); + client.subscribe_handler(collector.clone()).detach(); + set_pair_code_waiting(&client).await; + + let iq = pair_device_iq(); + assert!(handle_iq(&client, &iq.as_node_ref()).await); + + exhaust_qr_rotation(&collector).await; + poll_until("the QR rotation task to run out of refs", || { + collector + .events() + .iter() + .any(|e| matches!(&**e, Event::PairingQrCodesExhausted(x) if !x.disconnected)) + }) + .await; + + assert!( + client.is_running.load(Ordering::Relaxed), + "exhausted QR refs must not tear down a connection a pair-code flow is still using" + ); + } + + /// The companion still needs to hear that the QR codes are gone: WA Web + /// switches the screen to `UNPAIRED_IDLE` ("click to reload"), which is the + /// consumer's cue to reconnect. Without a pair-code flow in progress the + /// legacy self-disconnect stays, so QR-only consumers keep the reconnect + /// they already rely on. + #[tokio::test(start_paused = true)] + async fn qr_exhaustion_reports_itself_and_still_disconnects_a_qr_only_flow() { + let (client, _transport) = create_iq_test_client().await; + let collector = Arc::new(TestEventCollector::default()); + client.subscribe_handler(collector.clone()).detach(); + + let iq = pair_device_iq(); + assert!(handle_iq(&client, &iq.as_node_ref()).await); + + exhaust_qr_rotation(&collector).await; + poll_until("the QR rotation task to give up", || { + collector + .events() + .iter() + .any(|e| matches!(&**e, Event::PairingQrCodesExhausted(x) if x.disconnected)) + }) + .await; + + poll_until("the QR-only client to disconnect", || { + !client.is_running.load(Ordering::Relaxed) + }) + .await; + } +} diff --git a/src/pair_code.rs b/src/pair_code.rs index 5f37d36c4..d0159af1c 100644 --- a/src/pair_code.rs +++ b/src/pair_code.rs @@ -43,7 +43,18 @@ //! //! ## Concurrent with QR Codes //! -//! Pair code and QR code can run simultaneously. Whichever completes first wins. +//! Pair code and QR code run on the same connection, and whichever completes +//! first wins — matching WA Web, which leaves its QR rotation running when the +//! user switches to phone-number linking. +//! +//! They are not, however, the same clock. A QR code is superseded every 20s and +//! the surface re-renders it; a pair code is read off a screen and typed into a +//! phone minutes later, so **a QR rotation is not a reason to request a new +//! pair code**. WA Web mints one per user action and regenerates it only on the +//! server's `refresh_code`, on `force_manual_refresh`, or on its own expiry +//! timers. [`Client::pair_with_code`] enforces that: it refuses to supersede a +//! code that is still live, and [`Client::cancel_pair_code`] is the explicit +//! way to replace one. use crate::client::Client; use crate::request::{InfoQuery, InfoQueryType, IqError}; @@ -92,6 +103,20 @@ impl Client { /// /// This can run concurrently with QR code pairing - whichever completes first wins. /// + /// # One code at a time + /// + /// Fails with [`PairCodeError::CodeAlreadyOutstanding`] while a previously + /// issued code is still within its validity window. A second code does not + /// replace the first for the phone: the server routes `primary_hello` by + /// number, so whoever enters the older code still reaches stage 2 and is + /// answered with a key bundle their code cannot open — the phone reports a + /// failed link and nothing surfaces here. Call + /// [`Client::cancel_pair_code`] first when the replacement is intentional. + /// + /// In particular, do not drive this from QR-code rotation: the two have + /// unrelated lifetimes, and a code being typed into a phone outlives + /// several QR refs. + /// /// # Arguments /// /// * `options` - Configuration for pair code authentication @@ -99,9 +124,10 @@ impl Client { /// # Returns /// /// * `Ok(String)` - The 8-character pairing code to display - /// * `Err` - If validation fails, not connected, or server error. A - /// [`PairError::RequestFailed`] carrying `bad-request` may be **rate-limiting** - /// (throttled per phone number), not invalid input — back off and retry. + /// * `Err` - If validation fails, a code is already outstanding, not + /// connected, or server error. A [`PairError::RequestFailed`] carrying + /// `bad-request` may be **rate-limiting** (throttled per phone number), + /// not invalid input — back off and retry. /// /// # Example /// @@ -158,17 +184,54 @@ impl Client { None => PairCodeUtils::generate_code(), }; + // A second code does not replace the first for the *phone*: the server + // routes `primary_hello` by number, never seeing the code, so whoever + // is still reading the older one reaches stage 2 and is handed a key + // bundle their code cannot open — the phone reports a failed link and + // nothing surfaces here. WA Web makes the overlap impossible by + // guarding `startAltLinkingFlow` with `invariant(stage === Initialized)` + // (`Alt/DeviceLinkingApi.js`); `cancel_pair_code` is our + // `initializeAltDeviceLinking()`. + // + // Claimed under the same lock that reads it, because releasing it + // across the stage-1 round trip would let two concurrent callers both + // find the state idle. The stamp doubles as the validity clock, which + // WA Web also starts before the request (`startAltLinkingFlow` sets + // `codeGenerationTs` before sending), so the ~180s window covers the + // round trip rather than starting after it. + let code_generation_ts = wacore::time::now_secs(); + let claim = wacore::pair_code::PairCodeClaim::next(); + { + let mut state = self.pair_code_state.lock().await; + if state.is_outstanding(code_generation_ts) { + return Err(PairCodeError::CodeAlreadyOutstanding { + remaining: state + .live_flow_remaining(code_generation_ts) + .unwrap_or_default(), + } + .into()); + } + *state = PairCodeState::RequestingCode { + code_generation_ts, + claim, + }; + } + // Every path out has to hand the claim back, including a caller who + // drops this future (a `timeout` shorter than the IQ's, say) — an + // orphaned claim rejects every later request for the rest of the + // validity window. Disarmed only once the flow is installed. + let mut claim_guard = ClaimGuard { + client: Arc::clone(self), + claim, + armed: true, + }; + info!( target: "Client/PairCode", "Starting pair code authentication for phone: {}", phone_number ); - // Stamp the validity clock before companion_hello, matching WA Web - // (`startAltLinkingFlow` sets codeGenerationTs before sending), so the - // ~180s window covers the stage-1 round-trip rather than starting after it. - let code_generation_ts = wacore::time::now_secs(); - // Generate ephemeral keypair for this pairing session let ephemeral_keypair = KeyPair::generate(&mut rand::make_rng::()); @@ -249,10 +312,29 @@ impl Client { timeout: Some(std::time::Duration::from_secs(30)), }; - let response = self.send_iq(query).await?; + // The PBKDF2 above takes long enough for a `cancel_pair_code` to land. + // Sending anyway would put a second `companion_hello` on the server for + // this number, which then routes `primary_hello` to whichever it likes + // — the overlap the claim exists to prevent. + if !self.owns_code_claim(claim).await { + // Someone else owns the slot; releasing would take theirs. + claim_guard.armed = false; + return Err(PairCodeError::Cancelled.into()); + } + + let response = match self.send_iq(query).await { + Ok(response) => response, + Err(e) => { + claim_guard.release_now().await; + return Err(e.into()); + } + }; - let pairing_ref = PairCodeUtils::parse_companion_hello_response(response.get()) - .ok_or(PairCodeError::MissingPairingRef)?; + let Some(pairing_ref) = PairCodeUtils::parse_companion_hello_response(response.get()) + else { + claim_guard.release_now().await; + return Err(PairCodeError::MissingPairingRef.into()); + }; info!( target: "Client/PairCode", @@ -260,15 +342,26 @@ impl Client { code ); - // Store state for when phone confirms - *self.pair_code_state.lock().await = PairCodeState::WaitingForPhoneConfirmation { - pairing_ref, - phone_jid: phone_number, - pair_code: code.clone(), - ephemeral_keypair: Box::new(ephemeral_keypair), - code_generation_ts, - primary_hello_attempt_count: 0, - }; + // Store state for when phone confirms, unless the claim was withdrawn + // while stage 1 was in flight: installing over a cancellation would + // revive a flow the caller asked to drop, and over a replacement would + // strand the code that replacement returned. + { + let mut state = self.pair_code_state.lock().await; + if !matches!(&*state, PairCodeState::RequestingCode { claim: c, .. } if *c == claim) { + claim_guard.armed = false; + return Err(PairCodeError::Cancelled.into()); + } + *state = PairCodeState::WaitingForPhoneConfirmation { + pairing_ref, + phone_jid: phone_number, + pair_code: code.clone(), + ephemeral_keypair: Box::new(ephemeral_keypair), + code_generation_ts, + primary_hello_attempt_count: 0, + }; + claim_guard.armed = false; + } // Dispatch event for the user to display the code. The validity clock // started at `code_generation_ts` (before stage 1), so advertise the @@ -289,6 +382,73 @@ impl Client { Ok(code) } + + /// Hand back a claim taken by [`Self::pair_with_code`] when stage 1 failed. + /// + /// Identified by its token, so a claim already superseded — by a + /// cancellation, or by the replacement that followed one — is left alone. + async fn owns_code_claim(self: &Arc, claim: wacore::pair_code::PairCodeClaim) -> bool { + matches!(&*self.pair_code_state.lock().await, PairCodeState::RequestingCode { claim: c, .. } if *c == claim) + } + + async fn release_code_claim(self: &Arc, claim: wacore::pair_code::PairCodeClaim) { + let mut state = self.pair_code_state.lock().await; + if matches!(&*state, PairCodeState::RequestingCode { claim: c, .. } if *c == claim) { + *state = PairCodeState::Idle; + } + } + + /// Abandons the outstanding pair-code flow, if any. + /// + /// The explicit reset [`Client::pair_with_code`] requires before it will + /// mint a replacement — WA Web's `initializeAltDeviceLinking()`. After this + /// the previous code can no longer complete: a `primary_hello` for it is + /// dropped rather than answered with a bundle its holder cannot open. + pub async fn cancel_pair_code(self: &Arc) { + { + let mut state = self.pair_code_state.lock().await; + if matches!(&*state, PairCodeState::Idle) { + return; + } + *state = PairCodeState::Idle; + } + } +} + +/// Releases a stage-1 claim unless the flow it belongs to was installed. +/// +/// Error paths call [`Self::release_now`]; `Drop` is the backstop for a caller +/// that drops the future instead. It cannot await, so that release is spawned — +/// the claim is identified by its token, which makes a late release harmless +/// once something else has taken the slot. +struct ClaimGuard { + client: Arc, + claim: wacore::pair_code::PairCodeClaim, + armed: bool, +} + +impl ClaimGuard { + /// Hand the claim back before returning, so a caller that retries the + /// moment it sees the error does not race the detached release and get + /// `CodeAlreadyOutstanding` for a request that already failed. `Drop` is + /// left to cover only the caller who never sees the error at all. + async fn release_now(&mut self) { + self.armed = false; + self.client.release_code_claim(self.claim).await; + } +} + +impl Drop for ClaimGuard { + fn drop(&mut self) { + if !self.armed { + return; + } + let client = Arc::clone(&self.client); + let claim = self.claim; + client.clone().runtime.spawn_detached(Box::pin(async move { + client.release_code_claim(claim).await; + })); + } } /// Handles a `link_code_companion_reg` notification. Dispatches on the child's @@ -372,17 +532,16 @@ async fn handle_primary_hello(client: &Arc, reg_node: &NodeRef<'_>) -> b } }; - // Serialize the whole of stage 2 under the pair_code_state lock. The - // transport dispatches stanzas on concurrent detached tasks - // (see client/node_io.rs), so holding the guard across derive → persist → - // send makes two primary_hello for the same code sequential, matching WA - // Web's single-threaded model. Without it, both could derive a *different* - // random adv_secret and race SetAdvSecretKey (last-write-wins), leaving the - // persisted secret out of sync with the companion_finish the server acts on - // → pair-success HMAC failure. The state is kept (not taken) so a genuine - // retry can reuse it. + // Only the cheap guards run here. Everything they admit is handed to a + // task, because this function's return is what releases the stanza's ack: + // WA Web starts `handlePrimaryHello` without awaiting it and returns the + // ack in the same expression (`Alt/DeviceLinkingHandleNotification.js`), + // whereas running stage 2 inline puts a 131k-round PBKDF2 between the + // server's notification and our acknowledgement of it. + // + // The lock still serializes stage 2 end to end — see `run_stage_two`. let mut state_guard = client.pair_code_state.lock().await; - let (pairing_ref, phone_jid, pair_code, ephemeral_keypair) = match &mut *state_guard { + let (pairing_ref, phone_jid, pair_code, ephemeral_keypair, attempt) = match &mut *state_guard { PairCodeState::WaitingForPhoneConfirmation { pairing_ref, phone_jid, @@ -425,6 +584,7 @@ async fn handle_primary_hello(client: &Arc, reg_node: &NodeRef<'_>) -> b phone_jid.clone(), pair_code.clone(), (**ephemeral_keypair).clone(), + *primary_hello_attempt_count, ) } _ => { @@ -441,6 +601,68 @@ async fn handle_primary_hello(client: &Arc, reg_node: &NodeRef<'_>) -> b "Phone confirmed code entry, processing stage 2" ); + // Released before the task runs: `run_stage_two` re-takes it. + drop(state_guard); + + let client = Arc::clone(client); + // Armed on acceptance, matching WA Web: `primaryHelloReceivedAltLinking` + // fires before `handlePrimaryHelloInternal` runs, so the screen's clock + // starts on the notification. Waiting for a successful `companion_finish` + // would leave a failed stage 2 with no timeout at all — the case that most + // needs the consumer to hear about it. + start_pair_success_timeout(Arc::clone(&client), pairing_ref.clone(), attempt); + client.clone().runtime.spawn_detached(Box::pin(async move { + run_stage_two( + client, + pairing_ref, + phone_jid, + pair_code, + ephemeral_keypair, + primary_wrapped_ephemeral, + primary_identity_pub, + ) + .await; + })); + true +} + +/// Derive the key bundle, persist the rotated adv secret, send +/// `companion_finish`, and start the clock on the `pair-success` that should +/// answer it. +/// +/// Runs under the `pair_code_state` lock from derive through send. The +/// transport dispatches `notification` stanzas on concurrent detached tasks +/// (see `client/node_io.rs`), so without it two `primary_hello` for the same +/// code could each derive a *different* random adv_secret and race +/// `SetAdvSecretKey` (last-write-wins), leaving the persisted secret out of +/// sync with the `companion_finish` the server acts on → pair-success HMAC +/// failure. The state is kept (not taken) so a genuine retry can reuse it. +#[allow(clippy::too_many_arguments)] +async fn run_stage_two( + client: Arc, + pairing_ref: Vec, + phone_jid: String, + pair_code: String, + ephemeral_keypair: KeyPair, + primary_wrapped_ephemeral: Vec, + primary_identity_pub: [u8; 32], +) { + let state_guard = client.pair_code_state.lock().await; + // The flow can be retired while this task waits for the lock — by + // pair-success, a cancellation, or a replacement code. Matching the ref + // rather than the variant is what tells a replacement apart from our own + // flow: answering for one would persist a retired adv secret over the + // replacement's and put a `companion_finish` on the wire for a ref nobody + // holds. + let still_ours = matches!( + &*state_guard, + PairCodeState::WaitingForPhoneConfirmation { pairing_ref: current, .. } + if current.as_slice() == pairing_ref.as_slice() + ); + if !still_ours { + return; + } + // Decrypt primary's ephemeral public key (expensive PBKDF2 operation) // Run in spawn_blocking to avoid stalling the async runtime let pair_code_clone = pair_code.clone(); @@ -455,7 +677,7 @@ async fn handle_primary_hello(client: &Arc, reg_node: &NodeRef<'_>) -> b target: "Client/PairCode", "Failed to decrypt primary ephemeral pub: {e}" ); - return false; + return; } }; @@ -472,7 +694,7 @@ async fn handle_primary_hello(client: &Arc, reg_node: &NodeRef<'_>) -> b Ok(result) => result, Err(e) => { error!(target: "Client/PairCode", "Failed to prepare key bundle: {e}"); - return false; + return; } }; @@ -503,7 +725,7 @@ async fn handle_primary_hello(client: &Arc, reg_node: &NodeRef<'_>) -> b if let Err(e) = client.send_node(iq).await { error!(target: "Client/PairCode", "Failed to send companion_finish: {e}"); - return false; + return; } info!( @@ -512,10 +734,58 @@ async fn handle_primary_hello(client: &Arc, reg_node: &NodeRef<'_>) -> b ); // State stays WaitingForPhoneConfirmation so a retry can reuse it; only - // pair-success (see `crate::pair`) transitions to Completed. `state_guard` - // (held since the top for serialization) is released here. - drop(state_guard); - true + // pair-success (see `crate::pair`) transitions to Completed. The timeout + // that answers for this send was armed by the caller, on acceptance. +} + +/// Write the code off if `pair-success` never answers `companion_finish`. +/// +/// The primary reading the code proves nothing about the outcome: if it cannot +/// open the key bundle — which is what a superseded or mistyped code looks like +/// from here — it reports a failed link to its own user and says nothing to us. +/// WA Web treats that silence as the failure signal, arming a one-minute timer +/// on `primary_hello_received` and regenerating the code when it fires +/// (`Link/DevicePhoneNumberCodeScreen.react.js`). +fn start_pair_success_timeout(client: Arc, pairing_ref: Vec, attempt: u32) { + let timeout = PairCodeUtils::primary_hello_pair_success_timeout(); + client.clone().runtime.spawn_detached(Box::pin(async move { + client.runtime.sleep(timeout).await; + + { + let mut state = client.pair_code_state.lock().await; + // Only this flow's own timer may retire it: pair-success, a + // cancellation, or a replacement code all leave a state this + // does not match. + // Keyed on the attempt, not just the ref: a retry accepted partway + // through this window opens its own, and the earlier timer must not + // cut it short. + let still_ours = matches!( + &*state, + PairCodeState::WaitingForPhoneConfirmation { + pairing_ref: r, + primary_hello_attempt_count, + .. + } if r.as_slice() == pairing_ref.as_slice() + && *primary_hello_attempt_count == attempt + ); + if !still_ours { + return; + } + // Cleared before the event so the consumer acting on it is not + // rejected by the very flow it was told to replace. + *state = PairCodeState::Idle; + } + + warn!( + target: "Client/PairCode", + "No pair-success within {timeout:?} of companion_finish; the code will not complete" + ); + client.core.event_bus.dispatch(Event::PairingCodeRefresh( + crate::types::events::PairingCodeRefresh::builder() + .force_manual(false) + .build(), + )); + })); } /// The server asked us to refresh the code we are displaying (WA Web @@ -542,14 +812,22 @@ async fn handle_refresh_code(client: &Arc, reg_node: &NodeRef<'_>) -> bo .unwrap_or(false); // Ignore a refresh whose ref doesn't match the outstanding code — matches - // WA Web's `getCurrentRef()` guard. + // WA Web's `getCurrentRef()` guard. A matching one retires the flow on the + // spot: the consumer is being told to request a replacement, and leaving + // the old flow standing would make `pair_with_code` reject it. WA Web does + // the same, re-running `initializeAltDeviceLinking()` on the + // `force_manual_refresh` path. let matches_current = { - let state_guard = client.pair_code_state.lock().await; - matches!( + let mut state_guard = client.pair_code_state.lock().await; + let matches = matches!( &*state_guard, PairCodeState::WaitingForPhoneConfirmation { pairing_ref, .. } if pairing_ref.as_slice() == notif_ref.as_slice() - ) + ); + if matches { + *state_guard = PairCodeState::Idle; + } + matches }; if !matches_current { warn!( @@ -617,7 +895,7 @@ mod tests { // proxy for "we did not process the notification"; conversely a valid // primary_hello rotates it (via `SetAdvSecretKey`) before the socket send. - use crate::test_utils::create_test_client; + use crate::test_utils::{create_iq_test_client, create_test_client, poll_until}; use wacore::libsignal::protocol::KeyPair; use wacore_binary::Node; use wacore_binary::builder::NodeBuilder; @@ -750,11 +1028,11 @@ mod tests { let good = primary_hello_notif(&pairing_ref); let _ = handle_pair_code_notification(&client, &good.as_node_ref()).await; - assert_ne!( - adv(&client), - adv_before, - "the genuine primary_hello must still reach stage 2 after stale mismatches" - ); + poll_until( + "the genuine primary_hello to still reach stage 2 after stale mismatches", + || adv(&client) != adv_before, + ) + .await; } /// Regression: a `primary_hello` for a code older than the ~180s validity @@ -833,11 +1111,11 @@ mod tests { let notif = primary_hello_notif(&pairing_ref); let _ = handle_pair_code_notification(&client, ¬if.as_node_ref()).await; - assert_ne!( - adv(&client), - adv_before, - "a valid in-window retry must reach stage 2 and rotate the adv secret" - ); + poll_until( + "a valid in-window retry to reach stage 2 and rotate the adv secret", + || adv(&client) != adv_before, + ) + .await; } /// A `refresh_code` whose ref matches the outstanding flow surfaces a @@ -909,6 +1187,639 @@ mod tests { ); } + // ── Requesting a code over a live one ──────────────────────────────────── + // + // WA Web guards `startAltLinkingFlow` with `invariant(stage === Initialized)` + // (`Alt/DeviceLinkingApi.js`): a second `companion_hello` may only follow an + // explicit `initializeAltDeviceLinking()`. Silently minting a second code + // strands whoever is reading the first one — the server keeps routing + // `primary_hello` by phone number, so the stale code reaches stage 2 and the + // primary is handed a key bundle its code cannot open. + + /// Answers the `companion_hello` this flow puts on the wire, so + /// `pair_with_code` can complete against the harness. + async fn answer_companion_hello( + client: &Arc, + transport: &Arc, + frame: usize, + pairing_ref: &[u8], + ) { + let hello = crate::test_utils::decode_sent_iq(transport, frame).await; + let id = hello + .get() + .attrs() + .optional_string("id") + .expect("companion_hello carries an id") + .into_owned(); + let response = NodeBuilder::new("iq") + .attrs([ + ("from", "s.whatsapp.net".to_string()), + ("type", "result".to_string()), + ("id", id.clone()), + ]) + .children([NodeBuilder::new("link_code_companion_reg") + .attr("stage", "companion_hello") + .children([NodeBuilder::new("link_code_pairing_ref") + .bytes(pairing_ref.to_vec()) + .build()]) + .build()]) + .build(); + crate::test_utils::answer_iq(client, &id, &response).await; + } + + fn options() -> PairCodeOptions { + PairCodeOptions { + phone_number: "15551234567".to_string(), + ..Default::default() + } + } + + #[tokio::test] + async fn pair_with_code_refuses_to_supersede_a_live_code() { + let (client, _transport) = create_iq_test_client().await; + set_waiting(&client, vec![1, 2, 3, 4], wacore::time::now_secs(), 0).await; + + let err = client + .pair_with_code(options()) + .await + .expect_err("a second code would strand the one already displayed"); + + assert!( + matches!( + err, + PairError::PairCode(PairCodeError::CodeAlreadyOutstanding { .. }) + ), + "expected CodeAlreadyOutstanding, got {err:?}" + ); + } + + /// `cancel_pair_code` is our `initializeAltDeviceLinking()`: the explicit + /// reset that lets a caller mint a replacement on purpose. + #[tokio::test] + async fn cancel_pair_code_lets_a_replacement_be_requested() { + let (client, transport) = create_iq_test_client().await; + set_waiting(&client, vec![1, 2, 3, 4], wacore::time::now_secs(), 0).await; + + client.cancel_pair_code().await; + + let pending = { + let client = client.clone(); + tokio::spawn(async move { client.pair_with_code(options()).await }) + }; + answer_companion_hello(&client, &transport, 0, b"3@2:fresh").await; + let code = pending + .await + .expect("the pair-code task should not panic") + .expect("a cancelled flow leaves the way clear"); + assert!(PairCodeUtils::validate_code(&code)); + } + + /// An expired code strands nobody — its holder cannot complete it either — + /// so it must not block a fresh request. + #[tokio::test] + async fn an_expired_code_does_not_block_a_new_one() { + let (client, transport) = create_iq_test_client().await; + let stale = + wacore::time::now_secs() - (PairCodeUtils::code_validity().as_secs() as i64 + 1); + set_waiting(&client, vec![1, 2, 3, 4], stale, 0).await; + + let pending = { + let client = client.clone(); + tokio::spawn(async move { client.pair_with_code(options()).await }) + }; + answer_companion_hello(&client, &transport, 0, b"3@2:fresh").await; + pending + .await + .expect("the pair-code task should not panic") + .expect("an expired code must not block a new request"); + } + + /// The server's `refresh_code` asks for a replacement, so it must also + /// clear the way for one — WA Web's `force_manual_refresh` path calls + /// `initializeAltDeviceLinking()` before the screen re-requests. + #[tokio::test] + async fn refresh_code_clears_the_flow_it_asks_to_replace() { + let client = create_test_client().await; + let pairing_ref = vec![5, 6, 7, 8]; + set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await; + + let notif = refresh_code_notif(&pairing_ref, Some(true)); + assert!(handle_pair_code_notification(&client, ¬if.as_node_ref()).await); + + assert!( + !is_waiting(&client).await, + "a consumer acting on the refresh must not be rejected by the flow it replaces" + ); + } + + /// Regression: the guard has to survive two callers, not just two calls. + /// Checking the state and then releasing the lock across the + /// `companion_hello` round trip lets both pass, and the second response + /// overwrites the first flow's key material — so the code returned first + /// can no longer complete. + #[tokio::test] + async fn a_request_racing_another_is_refused_too() { + let (client, transport) = create_iq_test_client().await; + + let first = { + let client = client.clone(); + tokio::spawn(async move { client.pair_with_code(options()).await }) + }; + poll_until("the first companion_hello to be on the wire", || { + !transport.sent().is_empty() + }) + .await; + + let second = client.pair_with_code(options()).await; + assert!( + matches!( + second, + Err(PairError::PairCode( + PairCodeError::CodeAlreadyOutstanding { .. } + )) + ), + "a request in flight already owns the slot, got {second:?}" + ); + + answer_companion_hello(&client, &transport, 0, b"3@2:first").await; + first + .await + .expect("the pair-code task should not panic") + .expect("the winner still completes"); + } + + /// ...and a request that fails must hand the slot back, or the client is + /// stuck refusing to issue any code at all. + #[tokio::test] + async fn a_rejected_request_frees_the_slot() { + let (client, transport) = create_iq_test_client().await; + + let first = { + let client = client.clone(); + tokio::spawn(async move { client.pair_with_code(options()).await }) + }; + let hello = crate::test_utils::decode_sent_iq(&transport, 0).await; + let id = hello + .get() + .attrs() + .optional_string("id") + .expect("companion_hello carries an id") + .into_owned(); + let error = NodeBuilder::new("iq") + .attrs([ + ("from", "s.whatsapp.net".to_string()), + ("type", "error".to_string()), + ("id", id.clone()), + ]) + .children([NodeBuilder::new("error") + .attrs([ + ("code", "400".to_string()), + ("text", "bad-request".to_string()), + ]) + .build()]) + .build(); + crate::test_utils::answer_iq(&client, &id, &error).await; + first + .await + .expect("the pair-code task should not panic") + .expect_err("the server rejected this one"); + + let retry = { + let client = client.clone(); + tokio::spawn(async move { client.pair_with_code(options()).await }) + }; + answer_companion_hello(&client, &transport, 1, b"3@2:second").await; + retry + .await + .expect("the pair-code task should not panic") + .expect("a rejected request must not leave the slot taken"); + } + + /// Move the clock past `d`, after giving spawned tasks a turn to register + /// their timers. A jump taken before the sleep exists only pushes its + /// deadline out of reach. + async fn advance_past(d: std::time::Duration) { + for _ in 0..64 { + tokio::task::yield_now().await; + } + tokio::time::advance(d + std::time::Duration::from_secs(1)).await; + } + + /// Regression: a second-granularity stamp is not an identity. Cancel a + /// request and start its replacement inside the same wall-clock second and + /// both claims carry the same number, so the first one's late response + /// installs its own code over the replacement's claim — and its failure + /// path would release the replacement's. + #[tokio::test] + async fn a_claim_is_identified_by_more_than_the_second_it_started_in() { + let (client, transport) = create_iq_test_client().await; + + let first = { + let client = client.clone(); + tokio::spawn(async move { client.pair_with_code(options()).await }) + }; + poll_until("the first companion_hello", || !transport.sent().is_empty()).await; + + // Same second, by construction: no clock advances in between. + client.cancel_pair_code().await; + let second = { + let client = client.clone(); + tokio::spawn(async move { client.pair_with_code(options()).await }) + }; + poll_until("the replacement's companion_hello", || { + transport.sent().len() >= 2 + }) + .await; + + answer_companion_hello(&client, &transport, 0, b"3@2:first").await; + let stale = first + .await + .expect("the pair-code task should not panic") + .expect_err("the cancelled request must not install its flow"); + assert!( + matches!(stale, PairError::PairCode(PairCodeError::Cancelled)), + "expected Cancelled, got {stale:?}" + ); + + answer_companion_hello(&client, &transport, 1, b"3@2:second").await; + second + .await + .expect("the pair-code task should not panic") + .expect("the replacement owns the slot and must complete"); + assert!( + matches!( + &*client.pair_code_state.lock().await, + PairCodeState::WaitingForPhoneConfirmation { pairing_ref, .. } + if pairing_ref.as_slice() == b"3@2:second" + ), + "the replacement's flow must be the one left standing" + ); + } + + /// Regression: the code's validity window and the link's are different + /// clocks. A `primary_hello` accepted near the end of the window leaves + /// `companion_finish` pending for up to a minute more, and a new request + /// started in that gap races the pending pair-success for the adv secret. + #[tokio::test] + async fn a_pending_pair_success_still_owns_the_slot() { + let (client, _transport) = create_iq_test_client().await; + let expired = + wacore::time::now_secs() - (PairCodeUtils::code_validity().as_secs() as i64 + 1); + // Stage 2 ran: companion_finish is out, pair-success is pending. + set_waiting(&client, vec![1, 2, 3, 4], expired, 1).await; + + let err = client + .pair_with_code(options()) + .await + .expect_err("a pending link still owns the flow"); + assert!( + matches!( + err, + PairError::PairCode(PairCodeError::CodeAlreadyOutstanding { .. }) + ), + "expected CodeAlreadyOutstanding, got {err:?}" + ); + } + + /// Regression: an accepted retry deserves its own response window. Timers + /// keyed only on the shared `pairing_ref` let the first attempt's timer + /// retire a flow the second attempt had just renewed. + #[tokio::test(start_paused = true)] + async fn a_retry_gets_its_own_response_window() { + let (client, transport) = create_iq_test_client().await; + let collector = Arc::new(crate::test_utils::TestEventCollector::default()); + client.subscribe_handler(collector.clone()).detach(); + let pairing_ref = vec![1, 2, 3, 4]; + set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await; + + let notif = primary_hello_notif(&pairing_ref); + assert!(handle_pair_code_notification(&client, ¬if.as_node_ref()).await); + poll_until("the first companion_finish", || { + !transport.sent().is_empty() + }) + .await; + + // Most of the first attempt's window goes by, then the phone retries. + advance_past(std::time::Duration::from_secs(50)).await; + let retry = primary_hello_notif(&pairing_ref); + assert!(handle_pair_code_notification(&client, &retry.as_node_ref()).await); + poll_until("the second companion_finish", || { + transport.sent().len() >= 2 + }) + .await; + + // The first attempt's timer is due about now; the retry's is not. + advance_past(std::time::Duration::from_secs(15)).await; + assert!( + !collector + .events() + .iter() + .any(|e| matches!(&**e, Event::PairingCodeRefresh(_))), + "the first attempt's timer must not cut the retry's window short" + ); + + advance_past(PairCodeUtils::primary_hello_pair_success_timeout()).await; + poll_until("the retry's own timeout", || { + collector + .events() + .iter() + .any(|e| matches!(&**e, Event::PairingCodeRefresh(_))) + }) + .await; + } + + /// Regression: the pairing ref and any in-flight `companion_hello` belong to + /// the connection that carried them. Left standing across a teardown, they + /// make the one-code guard reject the request that reconnecting is supposed + /// to enable — for the rest of the validity window. + #[tokio::test] + async fn a_teardown_does_not_leave_the_slot_claimed() { + let (client, _transport) = create_iq_test_client().await; + set_waiting(&client, vec![1, 2, 3, 4], wacore::time::now_secs(), 0).await; + + client.cleanup_connection_state().await; + + assert!( + matches!(&*client.pair_code_state.lock().await, PairCodeState::Idle), + "a flow scoped to a dead connection must not outlive it" + ); + } + + /// Regression: a caller that gives up on the request — a `timeout` shorter + /// than the IQ's own, say — used to leave its claim behind, and an orphaned + /// claim rejects every later request for the rest of the validity window. + #[tokio::test] + async fn dropping_the_request_hands_the_claim_back() { + let (client, transport) = create_iq_test_client().await; + + { + let client = client.clone(); + let task = tokio::spawn(async move { client.pair_with_code(options()).await }); + poll_until("the companion_hello to be on the wire", || { + !transport.sent().is_empty() + }) + .await; + task.abort(); + } + + poll_until("the abandoned claim to be released", || { + matches!( + client.pair_code_state.try_lock().as_deref(), + Some(PairCodeState::Idle) + ) + }) + .await; + } + + /// Regression: the claim has to be back *before* the error reaches the + /// caller. Releasing it only from `Drop` schedules a detached task, and a + /// caller that retries the moment it sees the failure gets + /// `CodeAlreadyOutstanding` for a request that already gave up. + // Paused clock: the failure here is the IQ timing out, and waiting 30s of + // real time for it would be the slowest test in the suite. + #[tokio::test(start_paused = true)] + async fn a_failed_request_hands_the_claim_back_before_it_returns() { + let (client, _transport) = create_iq_test_client().await; + client.set_connected_for_test(false); + + client + .pair_with_code(options()) + .await + .expect_err("stage 1 cannot complete while disconnected"); + + // Checked without awaiting: a detached release would not have run yet. + assert!( + matches!( + client.pair_code_state.try_lock().as_deref(), + Some(PairCodeState::Idle) + ), + "the slot must be free the moment the error is returned" + ); + } + + /// The predicate stage 1 rechecks before putting `companion_hello` on the + /// wire. Sending after a withdrawal registers a second flow for this number + /// on the server, which then routes `primary_hello` to whichever it likes — + /// the overlap the claim exists to prevent. (The race itself has no + /// deterministic test: the derivation it runs against is real CPU work.) + #[tokio::test] + async fn a_withdrawn_claim_stops_being_owned() { + let (client, _transport) = create_iq_test_client().await; + let claim = wacore::pair_code::PairCodeClaim::next(); + *client.pair_code_state.lock().await = PairCodeState::RequestingCode { + code_generation_ts: wacore::time::now_secs(), + claim, + }; + + assert!(client.owns_code_claim(claim).await); + client.cancel_pair_code().await; + assert!( + !client.owns_code_claim(claim).await, + "a cancelled request must not reach the wire" + ); + + // Nor does a replacement's claim answer for the one it superseded. + *client.pair_code_state.lock().await = PairCodeState::RequestingCode { + code_generation_ts: wacore::time::now_secs(), + claim: wacore::pair_code::PairCodeClaim::next(), + }; + assert!(!client.owns_code_claim(claim).await); + } + + // ── Stage-2 liveness (WA Web parity) ───────────────────────────────────── + + /// Regression: WA Web acks the `primary_hello` notification before stage 2 + /// runs — `Alt/DeviceLinkingHandleNotification.js` starts + /// `handlePrimaryHello` without awaiting it and returns the ack in the same + /// expression. Holding the ack behind a 131k-round PBKDF2 is a divergence + /// the server sees. Runs on the default current-thread runtime, so the + /// spawned stage-2 task provably has not been polled when the handler + /// returns. + #[tokio::test] + async fn primary_hello_returns_before_stage_two_reaches_the_wire() { + let (client, transport) = create_iq_test_client().await; + let pairing_ref = vec![1, 2, 3, 4]; + set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await; + + let notif = primary_hello_notif(&pairing_ref); + let handled = handle_pair_code_notification(&client, ¬if.as_node_ref()).await; + + assert!(handled, "a valid primary_hello is handled"); + assert!( + transport.sent().is_empty(), + "the ack must not wait on stage-2 crypto; companion_finish belongs to a later poll" + ); + poll_until("companion_finish to reach the transport", || { + !transport.sent().is_empty() + }) + .await; + } + + /// Cancelling mid-request must not be undone when stage 1 lands: installing + /// the flow anyway would revive one the caller dropped, and would overwrite + /// whatever replaced it. + #[tokio::test] + async fn a_cancelled_request_does_not_install_its_flow() { + let (client, transport) = create_iq_test_client().await; + + let pending = { + let client = client.clone(); + tokio::spawn(async move { client.pair_with_code(options()).await }) + }; + poll_until("the companion_hello to be on the wire", || { + !transport.sent().is_empty() + }) + .await; + + client.cancel_pair_code().await; + answer_companion_hello(&client, &transport, 0, b"3@2:late").await; + + let err = pending + .await + .expect("the pair-code task should not panic") + .expect_err("a cancelled request must not report a usable code"); + assert!( + matches!(err, PairError::PairCode(PairCodeError::Cancelled)), + "expected Cancelled, got {err:?}" + ); + assert!( + !is_waiting(&client).await, + "the cancelled flow must stay cancelled" + ); + } + + /// Regression: a stage-2 task can be scheduled and then find, once it has + /// the lock, that its flow was replaced rather than merely retired. Matching + /// on the variant alone reads the replacement as its own flow, and it would + /// then persist a retired adv secret and answer with a `companion_finish` + /// keyed to the old ref — breaking the replacement that was about to work. + #[tokio::test] + async fn a_stage_two_task_does_not_answer_for_the_flow_that_replaced_it() { + let (client, transport) = create_iq_test_client().await; + // The replacement: a live flow, but not the one stage 2 was spawned for. + set_waiting(&client, vec![9, 9, 9, 9], wacore::time::now_secs(), 0).await; + let adv_before = adv(&client); + + run_stage_two( + client.clone(), + vec![1, 2, 3, 4], + "15551234567".to_string(), + "ABCD1234".to_string(), + KeyPair::generate(&mut rand::make_rng::()), + vec![7u8; 80], + [9u8; 32], + ) + .await; + + assert_eq!( + adv(&client), + adv_before, + "the replacement flow's adv secret must survive" + ); + assert!( + transport.sent().is_empty(), + "no companion_finish may go out for a ref nobody is holding" + ); + } + + /// Regression: `companion_finish` leaving the socket is not the end of the + /// flow — the server may still never send `pair-success` (a primary that + /// could not open the key bundle simply goes quiet). WA Web arms a + /// one-minute timer on `primary_hello_received` + /// (`Link/DevicePhoneNumberCodeScreen.react.js`) and regenerates the code + /// when it fires; we had no timeout at all, leaving the consumer with a + /// code that will never complete and no signal that anything went wrong. + #[tokio::test(start_paused = true)] + async fn a_primary_hello_that_never_pairs_asks_for_a_new_code() { + let (client, transport) = create_iq_test_client().await; + let collector = Arc::new(crate::test_utils::TestEventCollector::default()); + client.subscribe_handler(collector.clone()).detach(); + let pairing_ref = vec![1, 2, 3, 4]; + set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await; + + let notif = primary_hello_notif(&pairing_ref); + assert!(handle_pair_code_notification(&client, ¬if.as_node_ref()).await); + poll_until("companion_finish to reach the transport", || { + !transport.sent().is_empty() + }) + .await; + + advance_past(PairCodeUtils::primary_hello_pair_success_timeout()).await; + poll_until("the regeneration request", || { + collector + .events() + .iter() + .any(|e| matches!(&**e, Event::PairingCodeRefresh(r) if !r.force_manual)) + }) + .await; + assert!( + !is_waiting(&client).await, + "the abandoned flow must not reject the replacement it just asked for" + ); + } + + /// Regression: WA Web starts the one-minute clock when the notification + /// arrives (`primaryHelloReceivedAltLinking` fires before + /// `handlePrimaryHelloInternal` runs), not when `companion_finish` lands. + /// Arming it after the send means stage 2 failing leaves the consumer with + /// no signal at all — the one case where it most needs one. + #[tokio::test(start_paused = true)] + async fn the_timeout_is_armed_even_when_stage_two_cannot_run() { + // No transport, so the companion_finish send fails. + let client = create_test_client().await; + let collector = Arc::new(crate::test_utils::TestEventCollector::default()); + client.subscribe_handler(collector.clone()).detach(); + let pairing_ref = vec![1, 2, 3, 4]; + set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await; + + let notif = primary_hello_notif(&pairing_ref); + assert!(handle_pair_code_notification(&client, ¬if.as_node_ref()).await); + + advance_past(PairCodeUtils::primary_hello_pair_success_timeout()).await; + poll_until("the regeneration request", || { + collector + .events() + .iter() + .any(|e| matches!(&**e, Event::PairingCodeRefresh(r) if !r.force_manual)) + }) + .await; + } + + /// The timer must not fire once pairing actually completed, or a freshly + /// linked client would be told to hand out a new code. + #[tokio::test(start_paused = true)] + async fn pair_success_silences_the_regeneration_timer() { + let (client, transport) = create_iq_test_client().await; + let collector = Arc::new(crate::test_utils::TestEventCollector::default()); + client.subscribe_handler(collector.clone()).detach(); + let pairing_ref = vec![1, 2, 3, 4]; + set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await; + + let notif = primary_hello_notif(&pairing_ref); + assert!(handle_pair_code_notification(&client, ¬if.as_node_ref()).await); + poll_until("companion_finish to reach the transport", || { + !transport.sent().is_empty() + }) + .await; + + // What `handle_pair_success` does once the server confirms the link. + *client.pair_code_state.lock().await = PairCodeState::Completed; + + advance_past(PairCodeUtils::primary_hello_pair_success_timeout()).await; + // The timer task is spawned, so it needs turns of the executor, not just + // a clock jump — `poll_until` would return on its first check here. + for _ in 0..64 { + tokio::task::yield_now().await; + } + assert!( + !collector + .events() + .iter() + .any(|e| matches!(&**e, Event::PairingCodeRefresh(_))), + "a completed pairing must not ask the consumer for another code" + ); + } + /// An unknown `stage` on the notification is ignored without touching the /// in-progress flow. #[tokio::test] diff --git a/wacore/src/pair_code.rs b/wacore/src/pair_code.rs index 65b812cda..1482e77b6 100644 --- a/wacore/src/pair_code.rs +++ b/wacore/src/pair_code.rs @@ -87,6 +87,14 @@ const PAIR_CODE_VALIDITY_SECS: u64 = 180; /// abandoned. Matches WA Web `DeviceLinkingApi` (`T = 3`, `MaxPrimaryHelloError`). const PAIR_CODE_MAX_PRIMARY_HELLO_ATTEMPTS: u32 = 3; +/// How long a `companion_finish` may go unanswered before the code is written +/// off. WA Web arms exactly this on `primary_hello_received` +/// (`Link/DevicePhoneNumberCodeScreen.react.js`, `1 * MINUTE_MILLISECONDS`) and +/// regenerates the code when it fires: the primary having read the code is no +/// guarantee it could open the key bundle, and a primary that could not simply +/// goes quiet — no error ever reaches the companion. +const PAIR_CODE_PRIMARY_HELLO_PAIR_SUCCESS_TIMEOUT_SECS: u64 = 60; + fn build_id_and_display( id: CompanionWebClientType, props: &wa::DeviceProps, @@ -152,12 +160,45 @@ impl Default for PairCodeOptions { } } +/// Identity of one `pair_with_code` request, minted per call. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PairCodeClaim(u64); + +impl PairCodeClaim { + /// A value no live claim shares. Process-wide rather than per-client: a + /// counter is cheaper than the coordination that scoping it would need, and + /// only equality within one client is ever asked of it. + pub fn next() -> Self { + use core::sync::atomic::Ordering; + static NEXT: portable_atomic::AtomicU64 = portable_atomic::AtomicU64::new(0); + Self(NEXT.fetch_add(1, Ordering::Relaxed)) + } +} + /// State machine for pair code authentication flow. #[derive(Default)] pub enum PairCodeState { /// Initial state - no pair code request in progress. #[default] Idle, + /// `companion_hello` is in flight and the slot is already spoken for. + /// + /// Checking for a live flow and then releasing the lock across the stage-1 + /// round trip would let two concurrent callers both mint a code, and the + /// second response would overwrite the first flow's ephemeral keypair — + /// stranding the code that was returned first. WA Web tracks the same + /// window as a distinct stage (`AfterSendCompanionHello` follows + /// `Initialized` before the request resolves). + RequestingCode { + /// Stamped before `companion_hello`, and carried into + /// [`Self::WaitingForPhoneConfirmation`] unchanged. + code_generation_ts: i64, + /// Identifies *this* request. The stamp cannot: cancel a request and + /// start its replacement inside the same second and both carry the same + /// number, so the first one's late response would install its code over + /// the replacement's claim, and its failure path would release it. + claim: PairCodeClaim, + }, /// Stage 1 complete - waiting for phone to confirm code entry. WaitingForPhoneConfirmation { /// Reference returned by server in stage 1. @@ -180,10 +221,71 @@ pub enum PairCodeState { Completed, } +impl PairCodeState { + /// The window left on a code someone may still be reading, or `None` when + /// there is nothing left to strand. + /// + /// A second `companion_hello` mints a new code *and* a new ephemeral + /// keypair, but the server keeps routing `primary_hello` by phone number — + /// it never sees the code itself. So the holder of the superseded code + /// still reaches stage 2, and gets a key bundle derived from key material + /// their code cannot open: the primary fails to link with no error the + /// companion can see. WA Web forbids the overlap outright, guarding + /// `startAltLinkingFlow` with `invariant(stage === Initialized)` + /// (`Alt/DeviceLinkingApi.js`) so a replacement must follow an explicit + /// `initializeAltDeviceLinking()`. + /// + /// The boundary matches [`PairCodeUtils::code_validity`] as applied in + /// stage 2, which rejects only `age > validity`. + /// Whether a `companion_finish` is out and its `pair-success` still due. + /// + /// Distinct from [`Self::live_flow_remaining`], which tracks how long the + /// *code* stays enterable. A `primary_hello` accepted near the end of that + /// window leaves the link pending for up to + /// [`PairCodeUtils::primary_hello_pair_success_timeout`] longer, and the adv + /// secret its HMAC is computed over is already derived — so anything that + /// would re-mint that secret has to wait for this, not for the code. + pub fn awaiting_pair_success(&self) -> bool { + matches!( + self, + Self::WaitingForPhoneConfirmation { + primary_hello_attempt_count: 1.., + .. + } + ) + } + + /// Whether anything would be stranded by starting a new flow now. + /// + /// The union of the two clocks: the code's own validity window, and the + /// link that outlives it once the phone has answered. + pub fn is_outstanding(&self, now: i64) -> bool { + self.live_flow_remaining(now).is_some() || self.awaiting_pair_success() + } + + pub fn live_flow_remaining(&self, now: i64) -> Option { + let (Self::RequestingCode { + code_generation_ts, .. + } + | Self::WaitingForPhoneConfirmation { + code_generation_ts, .. + }) = self + else { + return None; + }; + let validity = PairCodeUtils::code_validity(); + // A backwards clock jump reads as "no time has passed", never as an + // expiry that would let the overlap through unreported. + let age = now.saturating_sub(*code_generation_ts).max(0) as u64; + (age <= validity.as_secs()).then(|| validity - std::time::Duration::from_secs(age)) + } +} + impl std::fmt::Debug for PairCodeState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Idle => write!(f, "Idle"), + Self::RequestingCode { .. } => write!(f, "RequestingCode"), Self::WaitingForPhoneConfirmation { phone_jid, .. } => f .debug_struct("WaitingForPhoneConfirmation") .field("phone_jid", phone_jid) @@ -510,6 +612,12 @@ impl PairCodeUtils { pub fn max_primary_hello_attempts() -> u32 { PAIR_CODE_MAX_PRIMARY_HELLO_ATTEMPTS } + + /// How long `companion_finish` may go unanswered before the code is written + /// off — WA Web's one-minute `primary_hello_expire` timer. + pub fn primary_hello_pair_success_timeout() -> std::time::Duration { + std::time::Duration::from_secs(PAIR_CODE_PRIMARY_HELLO_PAIR_SUCCESS_TIMEOUT_SECS) + } } /// Errors raised by wacore-side pair-code validation, key derivation, and @@ -531,6 +639,17 @@ pub enum PairCodeError { #[error("invalid custom code: must be 8 characters from Crockford Base32 alphabet")] InvalidCustomCode, + /// A code is already displayed and still within its validity window. + /// + /// Minting a second one does not replace the first for the *phone*: the + /// server routes `primary_hello` by number, so whoever enters the older + /// code still reaches stage 2 and receives a key bundle their code cannot + /// open — the phone reports a failed link and the companion sees nothing. + /// WA Web forbids the overlap with `invariant(stage === Initialized)`. + /// Cancel the outstanding flow first if the replacement is intentional. + #[error("a pair code is already outstanding ({remaining:?} left of its validity window)")] + CodeAlreadyOutstanding { remaining: std::time::Duration }, + #[error("invalid wrapped data: expected {expected} bytes, got {got}")] InvalidWrappedData { expected: usize, got: usize }, @@ -560,6 +679,11 @@ pub enum PairCodeError { #[error("server response missing pairing ref")] MissingPairingRef, + + /// The flow was cancelled (or replaced) while `companion_hello` was in + /// flight, so the code stage 1 produced was never installed. + #[error("the pair-code flow was cancelled while it was being requested")] + Cancelled, } #[cfg(test)] @@ -914,6 +1038,75 @@ mod tests { assert_eq!(resolve_companion_platform(&opts, &p).1, "Chrome (Linux)"); } + // ── `PairCodeState::live_flow_remaining` ───────────────────────────────── + // + // A second `companion_hello` mints a fresh code and ref, and the server + // keeps routing `primary_hello` by phone number — so whoever is still + // holding the previous code gets a `companion_finish` derived from key + // material their code cannot open. WA Web never reaches that state from a + // QR rotation: `Alt/DeviceLinkingApi.js` generates the code once from the + // user's action and only regenerates it through `refreshAltLinkingCode`, + // `forceManualRefresh`, or the screen's own timers. This predicate is what + // lets the overwrite be reported instead of silent. + + fn waiting_at(ts: i64) -> PairCodeState { + PairCodeState::WaitingForPhoneConfirmation { + pairing_ref: b"3@2:ref".to_vec(), + phone_jid: "15551234567".to_string(), + pair_code: "ABCD1234".to_string(), + ephemeral_keypair: Box::new(KeyPair::generate( + &mut rand::make_rng::(), + )), + code_generation_ts: ts, + primary_hello_attempt_count: 0, + } + } + + #[test] + fn live_flow_remaining_is_none_when_no_code_is_outstanding() { + assert_eq!(PairCodeState::Idle.live_flow_remaining(1_000), None); + assert_eq!(PairCodeState::Completed.live_flow_remaining(1_000), None); + } + + #[test] + fn live_flow_remaining_counts_down_the_validity_window() { + let validity = PairCodeUtils::code_validity().as_secs() as i64; + assert_eq!( + waiting_at(1_000).live_flow_remaining(1_000), + Some(PairCodeUtils::code_validity()) + ); + assert_eq!( + waiting_at(1_000).live_flow_remaining(1_000 + 30), + Some(std::time::Duration::from_secs(validity as u64 - 30)) + ); + } + + /// The boundary matches `handle_primary_hello`, which rejects only + /// `age > validity` (WA Web `OldCodeError`): at exactly the window the code + /// is still usable, so it is still worth reporting as lost. + #[test] + fn live_flow_remaining_treats_the_exact_window_as_still_live() { + let validity = PairCodeUtils::code_validity().as_secs() as i64; + assert_eq!( + waiting_at(1_000).live_flow_remaining(1_000 + validity), + Some(std::time::Duration::ZERO) + ); + assert_eq!( + waiting_at(1_000).live_flow_remaining(1_000 + validity + 1), + None, + "an expired code is not a flow anyone can still complete" + ); + } + + /// A clock that jumped backwards must not underflow into a bogus window. + #[test] + fn live_flow_remaining_survives_a_backwards_clock() { + assert_eq!( + waiting_at(1_000).live_flow_remaining(900), + Some(PairCodeUtils::code_validity()) + ); + } + #[test] fn test_code_validity_duration() { let duration = PairCodeUtils::code_validity(); diff --git a/wacore/src/types/events.rs b/wacore/src/types/events.rs index e08a3f907..738aa6c5d 100755 --- a/wacore/src/types/events.rs +++ b/wacore/src/types/events.rs @@ -203,9 +203,13 @@ impl Serialize for LazyHistorySync { } /// Discriminant for each [`Event`] variant, used to express handler interest -/// without materializing the event. One per `Event` variant, in declaration -/// order; the value doubles as a bit index in [`EventInterest`], so there can -/// be at most 128 kinds. +/// without materializing the event. One per `Event` variant; the value doubles +/// as a bit index in [`EventInterest`], so there can be at most 128 kinds. +/// +/// New kinds go at the **end**, whatever position their `Event` variant takes: +/// the discriminant is what a consumer persists or transmits, and inserting in +/// the middle renumbers every kind after it. `ServerAck` and +/// `PairingQrCodesExhausted` both sit here for that reason. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] #[non_exhaustive] @@ -268,6 +272,7 @@ pub enum EventKind { PairPasskeyConfirmation, PairPasskeyError, ServerAck, + PairingQrCodesExhausted, // When adding a variant, mind the 128-kind ceiling below (EventInterest packs // each discriminant as a bit in a u128) and keep the guard pointing at the // last variant. @@ -281,7 +286,7 @@ impl EventKind { // Build-time tripwire: a new variant that would overflow EventInterest's bitmask // fails compilation instead of silently corrupting the mask at runtime. -const _: () = assert!((EventKind::ServerAck as u8) < EventKind::CAPACITY); +const _: () = assert!((EventKind::PairingQrCodesExhausted as u8) < EventKind::CAPACITY); /// A set of [`EventKind`]s a handler wants delivered. Producers can query the /// aggregate interest before building expensive payloads, and dispatch avoids @@ -813,6 +818,7 @@ pub enum Event { PairingQrCode(PairingQrCode), PairingCode(PairingCode), PairingCodeRefresh(PairingCodeRefresh), + PairingQrCodesExhausted(PairingQrCodesExhausted), QrScannedWithoutMultidevice(QrScannedWithoutMultidevice), ClientOutdated(ClientOutdated), @@ -989,6 +995,7 @@ impl Event { Event::PairingQrCode(_) => EventKind::PairingQrCode, Event::PairingCode(_) => EventKind::PairingCode, Event::PairingCodeRefresh(_) => EventKind::PairingCodeRefresh, + Event::PairingQrCodesExhausted(_) => EventKind::PairingQrCodesExhausted, Event::QrScannedWithoutMultidevice(_) => EventKind::QrScannedWithoutMultidevice, Event::ClientOutdated(_) => EventKind::ClientOutdated, Event::Messages(_) => EventKind::Messages, @@ -1211,11 +1218,17 @@ pub struct PairingCode { pub timeout: std::time::Duration, } -/// The server asked the companion to refresh an in-progress phone-number -/// pairing code (WA Web `refreshAltLinkingCode` / `forceManualRefresh`). -/// Only emitted while a pair-code flow is outstanding and the server's ref -/// matches it. The consumer should request a fresh code via -/// `pair_with_code`; the previous code is no longer guaranteed valid. +/// The in-progress phone-number pairing code should be replaced. +/// +/// Emitted for the two cases WA Web regenerates on +/// (`Alt/DeviceLinkingApi.js` + `Link/DevicePhoneNumberCodeScreen.react.js`): +/// the server asking for it (`refreshAltLinkingCode` / `forceManualRefresh`, +/// ref-gated against the outstanding flow), and a `companion_finish` that went +/// unanswered for a minute — a primary that could not open the key bundle just +/// goes quiet, so silence is the only signal there is. +/// +/// The outstanding flow is cleared before this fires, so the consumer can call +/// `pair_with_code` straight away. The previous code is no longer valid. #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct PairingCodeRefresh { @@ -1224,6 +1237,21 @@ pub struct PairingCodeRefresh { pub force_manual: bool, } +/// The server's `` refs are used up: there is no QR left to +/// render until the connection is re-established. +/// +/// WA Web's rotation timer (`Handle/PairDevice.js`) reports `UNPAIRED_IDLE` +/// here and stops — it does not close the socket, because an alt-linking +/// (phone-number) flow may still be riding the same connection. +#[derive(Debug, Clone, Serialize, bon::Builder)] +#[non_exhaustive] +pub struct PairingQrCodesExhausted { + /// `true` when the client closed the connection itself, which it only does + /// with no pair-code flow outstanding. `false` means the socket was left + /// up and reconnecting is the consumer's call. + pub disconnected: bool, +} + #[derive(Debug, Clone, Serialize, bon::Builder)] #[non_exhaustive] pub struct QrScannedWithoutMultidevice {}