diff --git a/src/client.rs b/src/client.rs index e3868c286..b409cd547 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1475,7 +1475,7 @@ impl Client { match (code, conflict_type.as_str()) { ("515", _) => { // 515 is expected during registration/pairing phase - server closes stream after pairing - info!(target: "Client", "Got 515 stream error, server is closing stream. Will auto-reconnect."); + info!(target: "Client", "Got 515 stream error, server is closing stream (expected after pairing). Will auto-reconnect."); self.expect_disconnect().await; // Proactively disconnect transport since server may not close the connection // Clone the transport Arc before spawning to avoid holding the lock @@ -2765,17 +2765,6 @@ mod tests { /// - BUG (before fix): Called process_prekey_bundle() unconditionally, /// replacing the existing session with a new one /// - RESULT: Remote device still uses old session state, causing MAC failures - /// - /// WhatsApp Web Reference (MpTzv7av1aW.js, lines 32828-32834): - /// ```javascript - /// S.forEach(function (e, t) { - /// if (k[t]) { // If session exists - /// h.delete(e); // Just remove from pending - NO fetch - /// } else { - /// I.push(e); // Only fetch prekeys for devices WITHOUT sessions - /// } - /// }); - /// ``` #[tokio::test] async fn test_establish_session_skips_when_exists() { use wacore::libsignal::protocol::SessionRecord; diff --git a/src/client/sessions.rs b/src/client/sessions.rs index 28d7ce169..40e3ea130 100644 --- a/src/client/sessions.rs +++ b/src/client/sessions.rs @@ -215,10 +215,6 @@ mod tests { use super::*; use wacore_binary::jid::{DEFAULT_USER_SERVER, HIDDEN_USER_SERVER, JidExt}; - // Tests verify session management matches WhatsApp Web's behavior: - // - hasSignalSessions() via containSessions() (GysEGRAXCvh.js:48394) - // - ensureE2ESessions() (MpTzv7av1aW.js:32760) - #[test] fn test_primary_phone_jid_creation_from_pn() { let own_pn = Jid::pn("559999999999"); diff --git a/src/handlers/notification.rs b/src/handlers/notification.rs index 51f097ff6..2e7ea9765 100644 --- a/src/handlers/notification.rs +++ b/src/handlers/notification.rs @@ -1,27 +1,16 @@ use super::traits::StanzaHandler; use crate::client::Client; +use crate::lid_pn_cache::LearningSource; use crate::types::events::Event; use async_trait::async_trait; use log::{debug, info, warn}; use std::sync::Arc; +use wacore::stanza::devices::DeviceNotification; use wacore::store::traits::{DeviceInfo, DeviceListRecord}; -use wacore::types::events::{DeviceListUpdate, DeviceListUpdateType}; +use wacore::types::events::{DeviceListUpdate, DeviceNotificationInfo}; use wacore_binary::jid::{Jid, JidExt}; use wacore_binary::{jid::SERVER_JID, node::Node}; -/// Extract device IDs from child `` elements of a node. -fn extract_device_ids(node: &Node) -> Vec { - node.children() - .map(|device_nodes| { - device_nodes - .iter() - .filter(|n| n.tag == "device") - .filter_map(|n| n.attrs().optional_u64("id").map(|id| id as u32)) - .collect() - }) - .unwrap_or_default() -} - /// Handler for `` stanzas. /// /// Processes various notification types including: @@ -118,62 +107,62 @@ async fn handle_notification_impl(client: &Arc, node: &Node) { /// Device notifications have the structure: /// ```xml /// -/// or or -/// -/// +/// or or +/// +/// /// /// /// ``` async fn handle_devices_notification(client: &Arc, node: &Node) { - // Extract user JID from the "from" attribute - let from_jid = match node.attrs().optional_jid("from") { - Some(jid) => jid, - None => { - warn!(target: "Client", "Device notification missing 'from' attribute"); + // Parse using type-safe struct + let notification = match DeviceNotification::try_parse(node) { + Ok(n) => n, + Err(e) => { + warn!(target: "Client", "Failed to parse device notification: {e}"); return; } }; - let user = from_jid.user.clone(); - - // Determine update type and extract device list - let Some(children) = node.children() else { - warn!(target: "Client", "Device notification has no children"); - return; - }; - - for child in children.iter() { - let (update_type, hash) = match child.tag.as_str() { - "add" => (DeviceListUpdateType::Add, None), - "remove" => (DeviceListUpdateType::Remove, None), - "update" => { - let hash = child.attrs().optional_string("hash").map(|s| s.to_string()); - (DeviceListUpdateType::Update, hash) - } - _ => continue, - }; - - let devices = extract_device_ids(child); + // Learn LID-PN mapping if present + if let Some((lid, pn)) = notification.lid_pn_mapping() + && let Err(e) = client + .add_lid_pn_mapping(lid, pn, LearningSource::DeviceNotification) + .await + { + warn!(target: "Client", "Failed to add LID-PN mapping from device notification: {e}"); + } - debug!( - target: "Client", - "Device notification: user={}, type={:?}, devices={:?}, hash={:?}", - user, update_type, devices, hash - ); + // Process the single operation (per WhatsApp Web: one operation per notification) + let op = ¬ification.operation; + debug!( + target: "Client", + "Device notification: user={}, type={:?}, devices={:?}", + notification.user(), + op.operation_type, + op.device_ids() + ); - // Invalidate the device cache for this user - // This ensures the next lookup fetches fresh data - client.invalidate_device_cache(&user).await; - - // Dispatch event to notify application layer - let event = Event::DeviceListUpdate(DeviceListUpdate { - user: from_jid.clone(), - update_type, - devices, - hash, - }); - client.core.event_bus.dispatch(&event); - } + // Invalidate the device cache for this user + // This ensures the next lookup fetches fresh data + client.invalidate_device_cache(notification.user()).await; + + // Dispatch event to notify application layer + let event = Event::DeviceListUpdate(DeviceListUpdate { + user: notification.from.clone(), + lid_user: notification.lid_user.clone(), + update_type: op.operation_type.into(), + devices: op + .devices + .iter() + .map(|d| DeviceNotificationInfo { + device_id: d.device_id(), + key_index: d.key_index, + }) + .collect(), + key_index: op.key_index.clone(), + contact_hash: op.contact_hash.clone(), + }); + client.core.event_bus.dispatch(&event); } /// Parsed device info from account_sync notification @@ -316,54 +305,35 @@ async fn handle_account_sync_devices(client: &Arc, node: &Node, devices_ #[cfg(test)] mod tests { use super::*; + use wacore::stanza::devices::DeviceNotificationType; use wacore::types::events::DeviceListUpdateType; use wacore_binary::builder::NodeBuilder; - /// Helper to parse device notification and extract update info - fn parse_device_notification_info( - node: &wacore_binary::node::Node, - ) -> Vec<(DeviceListUpdateType, Vec, Option)> { - let Some(children) = node.children() else { - return vec![]; - }; - - let mut results = vec![]; - for child in children.iter() { - let (update_type, hash) = match child.tag.as_str() { - "add" => (DeviceListUpdateType::Add, None), - "remove" => (DeviceListUpdateType::Remove, None), - "update" => { - let hash = child.attrs().optional_string("hash").map(|s| s.to_string()); - (DeviceListUpdateType::Update, hash) - } - _ => continue, - }; - - let devices = extract_device_ids(child); - - results.push((update_type, devices, hash)); - } - results - } - #[test] fn test_parse_device_add_notification() { + // Per WhatsApp Web: add operation has single device + key-index-list let node = NodeBuilder::new("notification") .attr("type", "devices") .attr("from", "1234567890@s.whatsapp.net") .children([NodeBuilder::new("add") .children([ - NodeBuilder::new("device").attr("id", "1").build(), - NodeBuilder::new("device").attr("id", "2").build(), + NodeBuilder::new("device") + .attr("jid", "1234567890:1@s.whatsapp.net") + .build(), + NodeBuilder::new("key-index-list") + .attr("ts", "1000") + .bytes(vec![0x01, 0x02, 0x03]) + .build(), ]) .build()]) .build(); - let results = parse_device_notification_info(&node); - assert_eq!(results.len(), 1); - assert_eq!(results[0].0, DeviceListUpdateType::Add); - assert_eq!(results[0].1, vec![1, 2]); - assert_eq!(results[0].2, None); + let parsed = DeviceNotification::try_parse(&node).unwrap(); + assert_eq!(parsed.operation.operation_type, DeviceNotificationType::Add); + assert_eq!(parsed.operation.device_ids(), vec![1]); + // Verify key index info + assert!(parsed.operation.key_index.is_some()); + assert_eq!(parsed.operation.key_index.as_ref().unwrap().timestamp, 1000); } #[test] @@ -372,14 +342,23 @@ mod tests { .attr("type", "devices") .attr("from", "1234567890@s.whatsapp.net") .children([NodeBuilder::new("remove") - .children([NodeBuilder::new("device").attr("id", "3").build()]) + .children([ + NodeBuilder::new("device") + .attr("jid", "1234567890:3@s.whatsapp.net") + .build(), + NodeBuilder::new("key-index-list") + .attr("ts", "2000") + .build(), + ]) .build()]) .build(); - let results = parse_device_notification_info(&node); - assert_eq!(results.len(), 1); - assert_eq!(results[0].0, DeviceListUpdateType::Remove); - assert_eq!(results[0].1, vec![3]); + let parsed = DeviceNotification::try_parse(&node).unwrap(); + assert_eq!( + parsed.operation.operation_type, + DeviceNotificationType::Remove + ); + assert_eq!(parsed.operation.device_ids(), vec![3]); } #[test] @@ -389,49 +368,94 @@ mod tests { .attr("from", "1234567890@s.whatsapp.net") .children([NodeBuilder::new("update") .attr("hash", "2:abcdef123456") - .children([NodeBuilder::new("device").attr("id", "0").build()]) .build()]) .build(); - let results = parse_device_notification_info(&node); - assert_eq!(results.len(), 1); - assert_eq!(results[0].0, DeviceListUpdateType::Update); - assert_eq!(results[0].1, vec![0]); - assert_eq!(results[0].2, Some("2:abcdef123456".to_string())); + let parsed = DeviceNotification::try_parse(&node).unwrap(); + assert_eq!( + parsed.operation.operation_type, + DeviceNotificationType::Update + ); + assert_eq!( + parsed.operation.contact_hash, + Some("2:abcdef123456".to_string()) + ); + // Update operations don't have devices (just hash for lookup) + assert!(parsed.operation.devices.is_empty()); } #[test] - fn test_parse_empty_device_notification() { + fn test_parse_empty_device_notification_fails() { + // Per WhatsApp Web: at least one operation (add/remove/update) is required let node = NodeBuilder::new("notification") .attr("type", "devices") .attr("from", "1234567890@s.whatsapp.net") .build(); - let results = parse_device_notification_info(&node); - assert!(results.is_empty()); + let result = DeviceNotification::try_parse(&node); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("missing required operation") + ); } #[test] - fn test_parse_multiple_device_operations() { + fn test_parse_multiple_operations_uses_priority() { + // Per WhatsApp Web: only ONE operation is processed with priority remove > add > update + // If both remove and add are present, remove should be processed let node = NodeBuilder::new("notification") .attr("type", "devices") .attr("from", "1234567890@s.whatsapp.net") .children([ NodeBuilder::new("add") - .children([NodeBuilder::new("device").attr("id", "5").build()]) + .children([ + NodeBuilder::new("device") + .attr("jid", "1234567890:5@s.whatsapp.net") + .build(), + NodeBuilder::new("key-index-list") + .attr("ts", "3000") + .build(), + ]) .build(), NodeBuilder::new("remove") - .children([NodeBuilder::new("device").attr("id", "2").build()]) + .children([ + NodeBuilder::new("device") + .attr("jid", "1234567890:2@s.whatsapp.net") + .build(), + NodeBuilder::new("key-index-list") + .attr("ts", "3001") + .build(), + ]) .build(), ]) .build(); - let results = parse_device_notification_info(&node); - assert_eq!(results.len(), 2); - assert_eq!(results[0].0, DeviceListUpdateType::Add); - assert_eq!(results[0].1, vec![5]); - assert_eq!(results[1].0, DeviceListUpdateType::Remove); - assert_eq!(results[1].1, vec![2]); + let parsed = DeviceNotification::try_parse(&node).unwrap(); + // Should process remove, not add (priority: remove > add > update) + assert_eq!( + parsed.operation.operation_type, + DeviceNotificationType::Remove + ); + assert_eq!(parsed.operation.device_ids(), vec![2]); + } + + #[test] + fn test_device_list_update_type_from_notification_type() { + assert_eq!( + DeviceListUpdateType::from(DeviceNotificationType::Add), + DeviceListUpdateType::Add + ); + assert_eq!( + DeviceListUpdateType::from(DeviceNotificationType::Remove), + DeviceListUpdateType::Remove + ); + assert_eq!( + DeviceListUpdateType::from(DeviceNotificationType::Update), + DeviceListUpdateType::Update + ); } // Tests for account_sync device parsing diff --git a/src/lid_pn_cache.rs b/src/lid_pn_cache.rs index af43d4157..b022083b0 100644 --- a/src/lid_pn_cache.rs +++ b/src/lid_pn_cache.rs @@ -328,6 +328,7 @@ mod tests { (LearningSource::BlocklistActive, "blocklist_active"), (LearningSource::BlocklistInactive, "blocklist_inactive"), (LearningSource::Pairing, "pairing"), + (LearningSource::DeviceNotification, "device_notification"), (LearningSource::Other, "other"), ]; diff --git a/src/message.rs b/src/message.rs index 2e4f50c63..5d893676f 100644 --- a/src/message.rs +++ b/src/message.rs @@ -3744,7 +3744,6 @@ mod tests { /// Test: Status broadcast messages should always try skmsg even if pkmsg fails /// - /// Based on WhatsApp Web behavior from `.cargo/captured-js/lx-whGBdTEw.js`: /// - WhatsApp Web tracks pkmsg and skmsg failures separately /// - If pkmsg fails but skmsg succeeds, result is SUCCESS /// - For status@broadcast, we might have sender key cached from previous status diff --git a/wacore/libsignal/benches/libsignal_benchmark.rs b/wacore/libsignal/benches/libsignal_benchmark.rs index e225c5ad7..e349c164e 100644 --- a/wacore/libsignal/benches/libsignal_benchmark.rs +++ b/wacore/libsignal/benches/libsignal_benchmark.rs @@ -741,14 +741,6 @@ fn bench_key_generation() { } } -// ============================================================================= -// Session Optimization Benchmarks -// These benchmarks specifically target the session state optimizations: -// - Decryption with previous (archived) sessions -// - promote_matching_session during PreKey processing -// - Out-of-order message handling -// ============================================================================= - /// Creates a session with multiple archived previous sessions. /// This simulates a scenario where Alice has re-keyed multiple times. fn setup_with_archived_sessions() -> (User, User, Vec>) { diff --git a/wacore/libsignal/src/core/curve.rs b/wacore/libsignal/src/core/curve.rs index 24a4ab7a0..075671cf4 100644 --- a/wacore/libsignal/src/core/curve.rs +++ b/wacore/libsignal/src/core/curve.rs @@ -436,10 +436,6 @@ mod tests { rand::rng() } - // ========================================================================== - // XEdDSA Edwards Caching Tests - // ========================================================================== - #[test] fn test_signature_with_lazy_cache() { let mut csprng = rng(); diff --git a/wacore/libsignal/src/protocol/state/session.rs b/wacore/libsignal/src/protocol/state/session.rs index 4c23d7f2e..3b013c55f 100644 --- a/wacore/libsignal/src/protocol/state/session.rs +++ b/wacore/libsignal/src/protocol/state/session.rs @@ -933,10 +933,6 @@ mod tests { record } - // ========================================================================== - // Risk Point 1: Take/Restore Pattern Tests - // ========================================================================== - #[test] fn test_take_restore_preserves_order() { let mut record = create_record_with_previous_sessions(5); @@ -1028,10 +1024,6 @@ mod tests { assert!(record.take_previous_session(3).is_none()); } - // ========================================================================== - // Risk Point 2: set_message_keys Order Tests - // ========================================================================== - #[test] fn test_message_keys_lookup_by_counter_not_order() { let base_key = KeyPair::generate(&mut rng()).public_key; @@ -1096,10 +1088,6 @@ mod tests { MessageKeyGenerator::new_from_seed(&seed, counter) } - // ========================================================================== - // Risk Point 3: Byte Comparison Tests - // ========================================================================== - #[test] fn test_receiver_chain_lookup_by_bytes() { let base_key = KeyPair::generate(&mut rng()).public_key; @@ -1139,10 +1127,6 @@ mod tests { assert!(chain.is_some()); } - // ========================================================================== - // Risk Point 4: promote_matching_session Tests - // ========================================================================== - #[test] fn test_promote_matching_session_finds_correct_session() { let mut record = SessionRecord::new_fresh(); @@ -1203,10 +1187,6 @@ mod tests { assert_eq!(record.previous_session_count(), 0); } - // ========================================================================== - // Risk Point 5: SessionRecord Serialization Roundtrip - // ========================================================================== - #[test] fn test_session_record_serialization_preserves_previous_sessions() { let record = create_record_with_previous_sessions(10); diff --git a/wacore/src/iq/dirty.rs b/wacore/src/iq/dirty.rs index 98c9e6e4d..2768bcbe1 100644 --- a/wacore/src/iq/dirty.rs +++ b/wacore/src/iq/dirty.rs @@ -1,21 +1,3 @@ -//! Dirty bits IQ specification. -//! -//! Used to acknowledge and clear "dirty bits" - flags indicating pending server-side data -//! that needs to be synced (contacts, account settings, etc.). -//! -//! ## Wire Format -//! ```xml -//! -//! -//! -//! -//! -//! -//! -//! ``` -//! -//! Verified against WhatsApp Web JS (clearDirtyBits in 5Yec01dI04o.js). - use crate::iq::spec::IqSpec; use crate::request::InfoQuery; use wacore_binary::builder::NodeBuilder; diff --git a/wacore/src/lib.rs b/wacore/src/lib.rs index 90ea17d26..e03b97280 100644 --- a/wacore/src/lib.rs +++ b/wacore/src/lib.rs @@ -26,6 +26,7 @@ pub mod reporting_token; pub mod request; pub mod runtime; pub mod send; +pub mod stanza; pub mod store; pub mod types; pub mod upload; diff --git a/wacore/src/stanza/devices.rs b/wacore/src/stanza/devices.rs new file mode 100644 index 000000000..3ef5c6023 --- /dev/null +++ b/wacore/src/stanza/devices.rs @@ -0,0 +1,686 @@ +//! Device notification stanza types. +//! +//! Parses `` stanzas for device add/remove/update. +//! +//! Reference: WhatsApp Web `WAWebHandleDeviceNotification` (5Yec01dI04o.js:23109-23305) +//! +//! Key behaviors: +//! - Only ONE operation per notification (priority: remove > add > update) +//! - `key-index-list` is REQUIRED for add/remove +//! - Timestamp is REQUIRED (non-zero) for remove +//! - `hash` attribute is REQUIRED for update + +use crate::StringEnum; +use crate::iq::node::{optional_attr, optional_child, required_attr, required_child}; +use crate::protocol::ProtocolNode; +use anyhow::{Result, anyhow}; +use serde::Serialize; +use wacore_binary::builder::NodeBuilder; +use wacore_binary::jid::Jid; +use wacore_binary::node::{Node, NodeContent}; + +/// Device notification operation type. +/// +/// Wire format: Child element tag of `` +/// - `` - Device was added +/// - `` - Device was removed +/// - `` - Device info updated (hash-based lookup) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, StringEnum)] +pub enum DeviceNotificationType { + #[str = "add"] + Add, + #[str = "remove"] + Remove, + #[str = "update"] + Update, +} + +/// Key index information from `` element. +/// +/// Wire format: +/// ```xml +/// +/// SIGNED_BYTES +/// +/// +/// ``` +/// +/// Required for add/remove operations per WhatsApp Web. +#[derive(Debug, Clone, Serialize)] +pub struct KeyIndexInfo { + /// Timestamp (required for remove per WhatsApp Web) + pub timestamp: i64, + /// Signed key index bytes (only present for add) + #[serde(skip_serializing_if = "Option::is_none")] + pub signed_bytes: Option>, +} + +impl ProtocolNode for KeyIndexInfo { + fn tag(&self) -> &'static str { + "key-index-list" + } + + fn into_node(self) -> Node { + let mut builder = NodeBuilder::new("key-index-list").attr("ts", self.timestamp.to_string()); + if let Some(bytes) = self.signed_bytes { + builder = builder.bytes(bytes); + } + builder.build() + } + + fn try_from_node(node: &Node) -> Result { + if node.tag != "key-index-list" { + return Err(anyhow!("expected , got <{}>", node.tag)); + } + let ts_u64 = node + .attrs() + .optional_u64("ts") + .ok_or_else(|| anyhow!("key-index-list missing required 'ts' attribute"))?; + let timestamp = i64::try_from(ts_u64) + .map_err(|_| anyhow!("key-index-list 'ts' value {} exceeds i64::MAX", ts_u64))?; + let signed_bytes = match &node.content { + Some(NodeContent::Bytes(b)) if !b.is_empty() => Some(b.clone()), + _ => None, + }; + Ok(Self { + timestamp, + signed_bytes, + }) + } +} + +/// Device element from notification. +/// +/// Wire format: +/// ```xml +/// +/// ``` +/// +/// Device ID is extracted from the JID's device part (e.g., 75 from "user:75@lid"). +/// +/// Per WhatsApp Web: if both `jid` and `lid` attributes are present, the device IDs +/// must match or the notification is rejected. +#[derive(Debug, Clone, Serialize)] +pub struct DeviceElement { + /// Device JID (contains user and device ID) + pub jid: Jid, + /// Optional key index + #[serde(skip_serializing_if = "Option::is_none")] + pub key_index: Option, + /// Optional LID (device ID must match jid's device ID if present) + #[serde(skip_serializing_if = "Option::is_none")] + pub lid: Option, +} + +impl DeviceElement { + /// Extract the device ID from the JID. + #[inline] + pub fn device_id(&self) -> u32 { + self.jid.device as u32 + } +} + +impl ProtocolNode for DeviceElement { + fn tag(&self) -> &'static str { + "device" + } + + fn into_node(self) -> Node { + let mut builder = NodeBuilder::new("device").attr("jid", self.jid.to_string()); + if let Some(ki) = self.key_index { + builder = builder.attr("key-index", ki.to_string()); + } + if let Some(lid) = self.lid { + builder = builder.attr("lid", lid.to_string()); + } + builder.build() + } + + fn try_from_node(node: &Node) -> Result { + if node.tag != "device" { + return Err(anyhow!("expected , got <{}>", node.tag)); + } + let jid = node + .attrs() + .optional_jid("jid") + .ok_or_else(|| anyhow!("device missing required 'jid' attribute"))?; + + // Parse key-index with checked conversion (u64 -> u32) + let key_index = match node.attrs().optional_u64("key-index") { + Some(v) => Some( + u32::try_from(v) + .map_err(|_| anyhow!("device 'key-index' value {} exceeds u32::MAX", v))?, + ), + None => None, + }; + + let lid = node.attrs().optional_jid("lid"); + + // Per WhatsApp Web: validate device ID matches between jid and lid attributes + // Reference: 5Yec01dI04o.js:23169-23175 + if let Some(ref lid_jid) = lid { + let jid_device_id = jid.device; + let lid_device_id = lid_jid.device; + if jid_device_id != lid_device_id { + return Err(anyhow!( + "device id mismatch between jid ({}) and lid ({}) attributes", + jid_device_id, + lid_device_id + )); + } + } + + Ok(Self { + jid, + key_index, + lid, + }) + } +} + +/// Operation content (add/remove/update child element). +/// +/// Wire format per WhatsApp Web (5Yec01dI04o.js:23141-23180): +/// ```xml +/// +/// +/// SIGNED_BYTES +/// +/// +/// +/// +/// +/// +/// +/// +/// ``` +/// +/// Note: WhatsApp Web does NOT read any attributes from add/remove nodes. +/// The `device_hash` attribute (if present) is not used by the official client. +#[derive(Debug, Clone, Serialize)] +pub struct DeviceOperation { + /// Operation type (add/remove/update) + pub operation_type: DeviceNotificationType, + /// Contact hash (for update only) - from `hash` attribute, used for contact lookup + #[serde(skip_serializing_if = "Option::is_none")] + pub contact_hash: Option, + /// Device elements (for add/remove, single device per WhatsApp Web) + pub devices: Vec, + /// Key index info (required for add/remove per WhatsApp Web) + #[serde(skip_serializing_if = "Option::is_none")] + pub key_index: Option, +} + +impl DeviceOperation { + /// Parse from an add/remove/update child node. + /// + /// Per WhatsApp Web (5Yec01dI04o.js:23141-23157): + /// - `key-index-list` is REQUIRED for add/remove operations + /// - `ts` attribute is REQUIRED for remove operations + pub fn try_from_child(node: &Node) -> Result { + let operation_type = DeviceNotificationType::try_from(node.tag.as_str()) + .map_err(|_| anyhow!("unknown device operation: {}", node.tag))?; + + match operation_type { + DeviceNotificationType::Add | DeviceNotificationType::Remove => { + // Per WhatsApp Web: key-index-list is required for add/remove + let key_index_node = required_child(node, "key-index-list")?; + let key_index = KeyIndexInfo::try_from_node(key_index_node)?; + + // Per WhatsApp Web: timestamp is required for remove + if operation_type == DeviceNotificationType::Remove && key_index.timestamp == 0 { + return Err(anyhow!( + "timestamp is required to handle device remove notification" + )); + } + + // Parse device element + let device_node = required_child(node, "device")?; + let device = DeviceElement::try_from_node(device_node)?; + + Ok(Self { + operation_type, + contact_hash: None, + devices: vec![device], + key_index: Some(key_index), + }) + } + DeviceNotificationType::Update => { + // Per WhatsApp Web: hash attribute is REQUIRED for update + // Uses attrString (not maybeAttrString) which throws if missing + let contact_hash = required_attr(node, "hash")?; + + Ok(Self { + operation_type, + contact_hash: Some(contact_hash), + devices: Vec::new(), + key_index: None, + }) + } + } + } + + /// Get device IDs as a Vec (convenience method for logging). + pub fn device_ids(&self) -> Vec { + self.devices.iter().map(|d| d.device_id()).collect() + } +} + +/// Parsed device notification stanza. +/// +/// Wire format: +/// ```xml +/// +/// +/// +/// +/// +/// +/// ``` +/// +/// Reference: WhatsApp Web `WAWebHandleDeviceNotification` parser (5Yec01dI04o.js:23125-23183) +/// +/// Per WhatsApp Web: Only ONE operation per notification is processed. +/// Priority order: remove > add > update +#[derive(Debug, Clone, Serialize)] +pub struct DeviceNotification { + /// User JID (from attribute) + pub from: Jid, + /// Optional LID user (for LID-PN mapping learning) + #[serde(skip_serializing_if = "Option::is_none")] + pub lid_user: Option, + /// Stanza ID (for ACK) + pub stanza_id: String, + /// Timestamp + pub timestamp: i64, + /// The operation (one per notification, priority: remove > add > update) + pub operation: DeviceOperation, +} + +impl DeviceNotification { + /// Parse from a `` node. + /// + /// Per WhatsApp Web: Only ONE operation per notification is processed. + /// Priority order: remove > add > update + /// Returns error if no operation is found. + pub fn try_parse(node: &Node) -> Result { + if node.tag != "notification" { + return Err(anyhow!("expected , got <{}>", node.tag)); + } + if optional_attr(node, "type") != Some("devices") { + return Err(anyhow!("expected type='devices'")); + } + + let from = node + .attrs() + .optional_jid("from") + .ok_or_else(|| anyhow!("notification missing required 'from' attribute"))?; + let lid_user = node.attrs().optional_jid("lid"); + let stanza_id = optional_attr(node, "id") + .map(String::from) + .unwrap_or_default(); + + // Parse timestamp with checked conversion + let timestamp = match node.attrs().optional_u64("t") { + Some(t) => i64::try_from(t) + .map_err(|_| anyhow!("notification timestamp {} exceeds i64::MAX", t))?, + None => 0, + }; + + // Per WhatsApp Web: Priority order is remove > add > update + // Only one operation is processed per notification + let operation = if let Some(remove_node) = optional_child(node, "remove") { + DeviceOperation::try_from_child(remove_node)? + } else if let Some(add_node) = optional_child(node, "add") { + DeviceOperation::try_from_child(add_node)? + } else if let Some(update_node) = optional_child(node, "update") { + DeviceOperation::try_from_child(update_node)? + } else { + return Err(anyhow!( + "device notification missing required operation (add/remove/update)" + )); + }; + + Ok(Self { + from, + lid_user, + stanza_id, + timestamp, + operation, + }) + } + + /// Get the user string for cache operations. + #[inline] + pub fn user(&self) -> &str { + &self.from.user + } + + /// Check if this notification provides a LID-PN mapping to learn. + /// + /// Returns `Some((lid, pn))` if: + /// - `lid` attribute is present and is a LID + /// - `from` attribute is a phone number (not LID) + /// + /// Per WhatsApp Web: mappings are learned when both are present. + pub fn lid_pn_mapping(&self) -> Option<(&str, &str)> { + let lid = self.lid_user.as_ref()?; + if !self.from.is_lid() && lid.is_lid() { + Some((&lid.user, &self.from.user)) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wacore_binary::builder::NodeBuilder; + + #[test] + fn test_device_notification_type_as_str() { + assert_eq!(DeviceNotificationType::Add.as_str(), "add"); + assert_eq!(DeviceNotificationType::Remove.as_str(), "remove"); + assert_eq!(DeviceNotificationType::Update.as_str(), "update"); + } + + #[test] + fn test_device_notification_type_try_from() { + assert_eq!( + DeviceNotificationType::try_from("add").unwrap(), + DeviceNotificationType::Add + ); + assert_eq!( + DeviceNotificationType::try_from("remove").unwrap(), + DeviceNotificationType::Remove + ); + assert!(DeviceNotificationType::try_from("invalid").is_err()); + } + + #[test] + fn test_parse_remove_notification() { + let node = NodeBuilder::new("notification") + .attr("type", "devices") + .attr("from", "185169143189667@lid") + .attr("id", "511477682") + .attr("t", "1769296817") + .children([NodeBuilder::new("remove") + .children([ + NodeBuilder::new("device") + .attr("jid", "185169143189667:75@lid") + .build(), + NodeBuilder::new("key-index-list") + .attr("ts", "1769296600") + .build(), + ]) + .build()]) + .build(); + + let parsed = DeviceNotification::try_parse(&node).unwrap(); + assert_eq!(parsed.from.user, "185169143189667"); + assert_eq!(parsed.stanza_id, "511477682"); + assert_eq!(parsed.timestamp, 1769296817); + + let op = &parsed.operation; + assert_eq!(op.operation_type, DeviceNotificationType::Remove); + assert_eq!(op.devices.len(), 1); + assert_eq!(op.devices[0].device_id(), 75); + assert_eq!(op.key_index.as_ref().unwrap().timestamp, 1769296600); + assert!(op.key_index.as_ref().unwrap().signed_bytes.is_none()); + } + + #[test] + fn test_parse_add_notification_with_key_bytes() { + let node = NodeBuilder::new("notification") + .attr("type", "devices") + .attr("from", "15551234567@s.whatsapp.net") + .attr("lid", "100000000000001@lid") + .attr("id", "123") + .attr("t", "1000") + .children([NodeBuilder::new("add") + .children([ + NodeBuilder::new("device") + .attr("jid", "15551234567:64@s.whatsapp.net") + .attr("key-index", "5") + .build(), + NodeBuilder::new("key-index-list") + .attr("ts", "999") + .bytes(vec![0x01, 0x02, 0x03]) + .build(), + ]) + .build()]) + .build(); + + let parsed = DeviceNotification::try_parse(&node).unwrap(); + + // Check LID-PN mapping detection + let (lid, pn) = parsed.lid_pn_mapping().unwrap(); + assert_eq!(lid, "100000000000001"); + assert_eq!(pn, "15551234567"); + + let op = &parsed.operation; + assert_eq!(op.operation_type, DeviceNotificationType::Add); + assert_eq!(op.devices[0].device_id(), 64); + assert_eq!(op.devices[0].key_index, Some(5)); + assert_eq!( + op.key_index.as_ref().unwrap().signed_bytes, + Some(vec![0x01, 0x02, 0x03]) + ); + } + + #[test] + fn test_parse_update_notification() { + let node = NodeBuilder::new("notification") + .attr("type", "devices") + .attr("from", "15551234567@s.whatsapp.net") + .attr("id", "456") + .attr("t", "2000") + .children([NodeBuilder::new("update") + .attr("hash", "contact_hash_value") + .build()]) + .build(); + + let parsed = DeviceNotification::try_parse(&node).unwrap(); + + let op = &parsed.operation; + assert_eq!(op.operation_type, DeviceNotificationType::Update); + assert_eq!(op.contact_hash, Some("contact_hash_value".to_string())); + assert!(op.devices.is_empty()); + } + + #[test] + fn test_lid_pn_mapping_not_detected_when_from_is_lid() { + // When from is a LID, we shouldn't learn a LID->PN mapping + // (both are LIDs, no phone number to learn) + let node = NodeBuilder::new("notification") + .attr("type", "devices") + .attr("from", "185169143189667@lid") + .attr("lid", "185169143189667@lid") + .attr("id", "123") + .attr("t", "1000") + .children([NodeBuilder::new("update").attr("hash", "test_hash").build()]) + .build(); + + let parsed = DeviceNotification::try_parse(&node).unwrap(); + // No mapping should be detected when from is also a LID + assert!(parsed.lid_pn_mapping().is_none()); + } + + #[test] + fn test_missing_key_index_list_fails() { + // Per WhatsApp Web: key-index-list is required for add/remove + let node = NodeBuilder::new("notification") + .attr("type", "devices") + .attr("from", "15551234567@s.whatsapp.net") + .attr("id", "123") + .attr("t", "1000") + .children([NodeBuilder::new("add") + .children([NodeBuilder::new("device") + .attr("jid", "15551234567:64@s.whatsapp.net") + .build()]) + .build()]) + .build(); + + let result = DeviceNotification::try_parse(&node); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("key-index-list")); + } + + #[test] + fn test_remove_without_timestamp_fails() { + // Per WhatsApp Web: timestamp is required for remove + let node = NodeBuilder::new("notification") + .attr("type", "devices") + .attr("from", "15551234567@s.whatsapp.net") + .attr("id", "123") + .attr("t", "1000") + .children([NodeBuilder::new("remove") + .children([ + NodeBuilder::new("device") + .attr("jid", "15551234567:64@s.whatsapp.net") + .build(), + NodeBuilder::new("key-index-list") + .attr("ts", "0") // Zero timestamp should fail for remove + .build(), + ]) + .build()]) + .build(); + + let result = DeviceNotification::try_parse(&node); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("timestamp is required") + ); + } + + #[test] + fn test_device_id_mismatch_fails() { + // Per WhatsApp Web: device ID must match between jid and lid attributes + let node = NodeBuilder::new("notification") + .attr("type", "devices") + .attr("from", "15551234567@s.whatsapp.net") + .attr("id", "123") + .attr("t", "1000") + .children([NodeBuilder::new("add") + .children([ + NodeBuilder::new("device") + .attr("jid", "15551234567:64@s.whatsapp.net") + .attr("lid", "100000000000001:99@lid") // Different device ID + .build(), + NodeBuilder::new("key-index-list").attr("ts", "999").build(), + ]) + .build()]) + .build(); + + let result = DeviceNotification::try_parse(&node); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("device id mismatch") + ); + } + + #[test] + fn test_device_with_matching_lid() { + // Device IDs match - should succeed + let node = NodeBuilder::new("notification") + .attr("type", "devices") + .attr("from", "15551234567@s.whatsapp.net") + .attr("id", "123") + .attr("t", "1000") + .children([NodeBuilder::new("add") + .children([ + NodeBuilder::new("device") + .attr("jid", "15551234567:64@s.whatsapp.net") + .attr("lid", "100000000000001:64@lid") // Same device ID + .build(), + NodeBuilder::new("key-index-list").attr("ts", "999").build(), + ]) + .build()]) + .build(); + + let parsed = DeviceNotification::try_parse(&node).unwrap(); + assert_eq!(parsed.operation.devices[0].device_id(), 64); + assert!(parsed.operation.devices[0].lid.is_some()); + } + + #[test] + fn test_no_operation_fails() { + // Per WhatsApp Web: at least one operation (add/remove/update) is required + let node = NodeBuilder::new("notification") + .attr("type", "devices") + .attr("from", "15551234567@s.whatsapp.net") + .attr("id", "123") + .attr("t", "1000") + .build(); // No operation children + + let result = DeviceNotification::try_parse(&node); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("missing required operation") + ); + } + + #[test] + fn test_remove_priority_over_add() { + // Per WhatsApp Web: priority is remove > add > update + // If both remove and add are present, remove should be processed + let node = NodeBuilder::new("notification") + .attr("type", "devices") + .attr("from", "15551234567@s.whatsapp.net") + .attr("id", "123") + .attr("t", "1000") + .children([ + NodeBuilder::new("add") + .children([ + NodeBuilder::new("device") + .attr("jid", "15551234567:64@s.whatsapp.net") + .build(), + NodeBuilder::new("key-index-list").attr("ts", "999").build(), + ]) + .build(), + NodeBuilder::new("remove") + .children([ + NodeBuilder::new("device") + .attr("jid", "15551234567:75@s.whatsapp.net") + .build(), + NodeBuilder::new("key-index-list").attr("ts", "888").build(), + ]) + .build(), + ]) + .build(); + + let parsed = DeviceNotification::try_parse(&node).unwrap(); + // Should process remove, not add + assert_eq!( + parsed.operation.operation_type, + DeviceNotificationType::Remove + ); + assert_eq!(parsed.operation.devices[0].device_id(), 75); + } + + #[test] + fn test_update_without_hash_fails() { + // Per WhatsApp Web: hash attribute is required for update + let node = NodeBuilder::new("notification") + .attr("type", "devices") + .attr("from", "15551234567@s.whatsapp.net") + .attr("id", "123") + .attr("t", "1000") + .children([NodeBuilder::new("update").build()]) // Missing hash attribute + .build(); + + let result = DeviceNotification::try_parse(&node); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("hash")); + } +} diff --git a/wacore/src/stanza/mod.rs b/wacore/src/stanza/mod.rs new file mode 100644 index 000000000..434948c99 --- /dev/null +++ b/wacore/src/stanza/mod.rs @@ -0,0 +1,7 @@ +//! Stanza types for WhatsApp protocol notifications. +//! +//! This module contains type-safe parsers for incoming notification stanzas. + +pub mod devices; + +pub use devices::*; diff --git a/wacore/src/types/events.rs b/wacore/src/types/events.rs index 5e8c665fe..8c14c4f66 100644 --- a/wacore/src/types/events.rs +++ b/wacore/src/types/events.rs @@ -199,18 +199,45 @@ pub enum DeviceListUpdateType { Update, } +impl From for DeviceListUpdateType { + fn from(t: crate::stanza::devices::DeviceNotificationType) -> Self { + match t { + crate::stanza::devices::DeviceNotificationType::Add => Self::Add, + crate::stanza::devices::DeviceNotificationType::Remove => Self::Remove, + crate::stanza::devices::DeviceNotificationType::Update => Self::Update, + } + } +} + +/// Device information from notification. +#[derive(Debug, Clone, Serialize)] +pub struct DeviceNotificationInfo { + /// Device ID (extracted from JID) + pub device_id: u32, + /// Optional key index + #[serde(skip_serializing_if = "Option::is_none")] + pub key_index: Option, +} + /// Device list update notification. /// Emitted when a user's device list changes (device added/removed/updated). #[derive(Debug, Clone, Serialize)] pub struct DeviceListUpdate { - /// The user whose device list changed + /// The user whose device list changed (from attribute) pub user: Jid, + /// Optional LID user (for LID-PN mapping) + #[serde(skip_serializing_if = "Option::is_none")] + pub lid_user: Option, /// Type of update (add/remove/update) pub update_type: DeviceListUpdateType, - /// List of device IDs affected - pub devices: Vec, - /// Hash for cache validation (if provided) - pub hash: Option, + /// Affected devices with detailed info + pub devices: Vec, + /// Key index info (for add/remove) + #[serde(skip_serializing_if = "Option::is_none")] + pub key_index: Option, + /// Contact hash (for update - used for contact lookup) + #[serde(skip_serializing_if = "Option::is_none")] + pub contact_hash: Option, } #[derive(Debug, Clone, Serialize)] diff --git a/wacore/src/types/lid_pn.rs b/wacore/src/types/lid_pn.rs index bec0b42a4..32ac5b834 100644 --- a/wacore/src/types/lid_pn.rs +++ b/wacore/src/types/lid_pn.rs @@ -33,6 +33,8 @@ pub enum LearningSource { BlocklistInactive, /// Mapping learned from device pairing (own JID <-> LID) Pairing, + /// Mapping learned from device notification (when `lid` attribute present) + DeviceNotification, /// Mapping learned from other/unknown source Other, } @@ -50,6 +52,7 @@ impl LearningSource { LearningSource::BlocklistActive => "blocklist_active", LearningSource::BlocklistInactive => "blocklist_inactive", LearningSource::Pairing => "pairing", + LearningSource::DeviceNotification => "device_notification", LearningSource::Other => "other", } } @@ -66,6 +69,7 @@ impl LearningSource { "blocklist_active" => LearningSource::BlocklistActive, "blocklist_inactive" => LearningSource::BlocklistInactive, "pairing" => LearningSource::Pairing, + "device_notification" => LearningSource::DeviceNotification, _ => LearningSource::Other, } } @@ -132,6 +136,7 @@ mod tests { (LearningSource::BlocklistActive, "blocklist_active"), (LearningSource::BlocklistInactive, "blocklist_inactive"), (LearningSource::Pairing, "pairing"), + (LearningSource::DeviceNotification, "device_notification"), (LearningSource::Other, "other"), ];