From 9878599e6a29bf2e9f255762b60e66475a8b1cf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:49:39 -0300 Subject: [PATCH 1/7] feat(groups): preserve extended protocol metadata --- src/features/groups.rs | 41 +++- src/features/mod.rs | 11 +- src/handlers/notification/groups.rs | 6 +- src/lib.rs | 22 +- tests/e2e/tests/groups.rs | 15 +- wacore/src/iq/groups.rs | 316 ++++++++++++++++++++++++++-- wacore/src/stanza/groups.rs | 50 ++++- wacore/src/types/events.rs | 17 +- 8 files changed, 421 insertions(+), 57 deletions(-) diff --git a/src/features/groups.rs b/src/features/groups.rs index 9ef8ef4c4..1e1a7e2d2 100644 --- a/src/features/groups.rs +++ b/src/features/groups.rs @@ -26,9 +26,9 @@ use wacore_binary::{Jid, JidExt as _}; use wacore::iq::groups::BatchGroupInfoResult as RawBatchResult; pub use wacore::iq::groups::{ - GroupCreateOptions, GroupDescription, GroupJoinError, GroupParticipantOptions, - GroupProfilePicture, GroupSubject, GrowthLockInfo, InviteInfoError, JoinGroupResult, - MemberAddMode, MemberLinkMode, MemberShareHistoryMode, MembershipApprovalMode, + GroupCreateOptions, GroupDescription, GroupEphemeralSettings, GroupJoinError, + GroupParticipantOptions, GroupProfilePicture, GroupSubject, GrowthLockInfo, InviteInfoError, + JoinGroupResult, MemberAddMode, MemberLinkMode, MemberShareHistoryMode, MembershipApprovalMode, MembershipRequest, ParticipantChangeResponse, ParticipantType, PictureType, }; @@ -95,32 +95,38 @@ pub enum BatchGroupResult { pub struct GroupMetadata { pub id: Jid, pub subject: String, + pub notify: Option, pub participants: Vec, pub addressing_mode: AddressingMode, /// Group creator JID. pub creator: Option, + pub creator_pn: Option, + pub creator_username: Option, + pub creator_country_code: Option, /// Group creation timestamp (Unix seconds). pub creation_time: Option, /// Subject modification timestamp (Unix seconds). pub subject_time: Option, /// Subject owner JID. pub subject_owner: Option, + pub subject_owner_pn: Option, + pub subject_owner_username: Option, /// Group description body text. pub description: Option, /// Description ID (for conflict detection when updating). pub description_id: Option, /// JID of the participant who set the description. pub description_owner: Option, + pub description_owner_pn: Option, + pub description_owner_username: Option, /// Timestamp when the description was set. pub description_time: Option, /// Whether the group is locked (only admins can edit group info). pub is_locked: bool, /// Whether announcement mode is enabled (only admins can send messages). pub is_announcement: bool, - /// Ephemeral message expiration in seconds (0 = disabled). - pub ephemeral_expiration: u32, - /// Disappearing mode trigger (from `trigger` attribute on ``). - pub ephemeral_trigger: Option, + /// Disappearing-message settings when the server includes an `` node. + pub ephemeral: Option, /// Whether membership approval is required to join. pub membership_approval: bool, /// Who can add members to the group. @@ -163,6 +169,8 @@ pub struct GroupMetadata { pub struct GroupParticipant { pub jid: Jid, pub phone_number: Option, + pub lid: Option, + pub username: Option, pub participant_type: ParticipantType, } @@ -181,6 +189,8 @@ impl From for GroupParticipant { Self { jid: p.jid, phone_number: p.phone_number, + lid: p.lid, + username: p.username, participant_type: p.participant_type, } } @@ -191,20 +201,27 @@ impl From for GroupMetadata { Self { id: group.id, subject: group.subject.into_string(), + notify: group.notify, participants: group.participants.into_iter().map(Into::into).collect(), addressing_mode: group.addressing_mode, creator: group.creator, + creator_pn: group.creator_pn, + creator_username: group.creator_username, + creator_country_code: group.creator_country_code, creation_time: group.creation_time, subject_time: group.subject_time, subject_owner: group.subject_owner, + subject_owner_pn: group.subject_owner_pn, + subject_owner_username: group.subject_owner_username, description: group.description, description_id: group.description_id, description_owner: group.description_owner, + description_owner_pn: group.description_owner_pn, + description_owner_username: group.description_owner_username, description_time: group.description_time, is_locked: group.is_locked, is_announcement: group.is_announcement, - ephemeral_expiration: group.ephemeral_expiration, - ephemeral_trigger: group.ephemeral_trigger, + ephemeral: group.ephemeral, membership_approval: group.membership_approval, member_add_mode: group.member_add_mode, member_link_mode: group.member_link_mode, @@ -1254,6 +1271,8 @@ mod tests { participants: vec![GroupParticipant { jid: participant_jid, phone_number: None, + lid: None, + username: None, participant_type: ParticipantType::Admin, }], ..Default::default() @@ -1285,6 +1304,8 @@ mod tests { participants: vec![GroupParticipant { jid: Jid::new("26263000000099", Server::Lid), phone_number: None, + lid: None, + username: None, participant_type: ParticipantType::Member, }], addressing_mode: AddressingMode::Lid, @@ -1309,6 +1330,8 @@ mod tests { participants: vec![GroupParticipant { jid: Jid::new("5521900000098", Server::Pn), phone_number: None, + lid: None, + username: None, participant_type: ParticipantType::Member, }], addressing_mode: AddressingMode::Pn, diff --git a/src/features/mod.rs b/src/features/mod.rs index cfd250ecc..f5087af4c 100644 --- a/src/features/mod.rs +++ b/src/features/mod.rs @@ -43,11 +43,12 @@ pub use contacts::{ pub use events::{EventCreationParams, EventResponseType, Events}; pub use groups::{ - BatchGroupResult, CreateGroupResult, GroupCreateOptions, GroupDescription, GroupError, - GroupJoinError, GroupMetadata, GroupParticipant, GroupParticipantOptions, GroupProfilePicture, - GroupSubject, Groups, GrowthLockInfo, InviteInfoError, JoinGroupResult, MemberAddMode, - MemberLinkMode, MemberShareHistoryMode, MembershipApprovalMode, MembershipRequest, - ParticipantChangeResponse, ParticipantType, PictureType, + BatchGroupResult, CreateGroupResult, GroupCreateOptions, GroupDescription, + GroupEphemeralSettings, GroupError, GroupJoinError, GroupMetadata, GroupParticipant, + GroupParticipantOptions, GroupProfilePicture, GroupSubject, Groups, GrowthLockInfo, + InviteInfoError, JoinGroupResult, MemberAddMode, MemberLinkMode, MemberShareHistoryMode, + MembershipApprovalMode, MembershipRequest, ParticipantChangeResponse, ParticipantType, + PictureType, }; pub use labels::Labels; diff --git a/src/handlers/notification/groups.rs b/src/handlers/notification/groups.rs index b6d983ca4..5c08946a5 100644 --- a/src/handlers/notification/groups.rs +++ b/src/handlers/notification/groups.rs @@ -129,7 +129,7 @@ pub(crate) async fn handle_group_notification(client: &Arc, node: Arc, node: Arc anyhow::Result<()> { "Announcement should be off initially" ); assert_eq!( - metadata.ephemeral_expiration, 0, + metadata + .ephemeral + .and_then(|settings| settings.expiration) + .unwrap_or(0), + 0, "Ephemeral should be disabled initially" ); assert!( @@ -462,7 +466,8 @@ async fn test_group_settings() -> anyhow::Result<()> { .await?; let metadata = client_a.client.groups().get_metadata(&group_jid).await?; assert_eq!( - metadata.ephemeral_expiration, 86400, + metadata.ephemeral.and_then(|settings| settings.expiration), + Some(86400), "Ephemeral should be 24h after set_ephemeral(86400)" ); info!("Ephemeral set to 24h - verified"); @@ -474,7 +479,8 @@ async fn test_group_settings() -> anyhow::Result<()> { .await?; let metadata = client_a.client.groups().get_metadata(&group_jid).await?; assert_eq!( - metadata.ephemeral_expiration, 604800, + metadata.ephemeral.and_then(|settings| settings.expiration), + Some(604800), "Ephemeral should be 7d after set_ephemeral(604800)" ); info!("Ephemeral set to 7d - verified"); @@ -486,7 +492,8 @@ async fn test_group_settings() -> anyhow::Result<()> { .await?; let metadata = client_a.client.groups().get_metadata(&group_jid).await?; assert_eq!( - metadata.ephemeral_expiration, 0, + metadata.ephemeral.and_then(|settings| settings.expiration), + Some(0), "Ephemeral should be disabled after set_ephemeral(0)" ); info!("Ephemeral disabled - verified"); diff --git a/wacore/src/iq/groups.rs b/wacore/src/iq/groups.rs index 4d53e0aa5..6de192087 100644 --- a/wacore/src/iq/groups.rs +++ b/wacore/src/iq/groups.rs @@ -500,6 +500,8 @@ pub struct GroupQueryRequest { pub struct GroupParticipantResponse { pub jid: Jid, pub phone_number: Option, + pub lid: Option, + pub username: Option, pub participant_type: ParticipantType, } @@ -513,6 +515,12 @@ impl ProtocolNode for GroupParticipantResponse { if let Some(pn) = self.phone_number { builder = builder.attr("phone_number", pn); } + if let Some(lid) = self.lid { + builder = builder.attr("lid", lid); + } + if let Some(username) = self.username { + builder = builder.attr("participant_username", username); + } if self.participant_type != ParticipantType::Member { builder = builder.attr("type", self.participant_type.as_str()); } @@ -528,6 +536,11 @@ impl ProtocolNode for GroupParticipantResponse { .optional_jid("jid") .ok_or_else(|| anyhow!("participant missing required 'jid' attribute"))?; let phone_number = attrs.optional_jid("phone_number"); + let lid = attrs.optional_jid("lid"); + let username = attrs + .optional_string("participant_username") + .or_else(|| attrs.optional_string("username")) + .map(|value| value.into_owned()); let participant_type = attrs .optional_string("type") .and_then(|s| ParticipantType::try_from(s.as_ref()).ok()) @@ -536,43 +549,99 @@ impl ProtocolNode for GroupParticipantResponse { Ok(Self { jid, phone_number, + lid, + username, participant_type, }) } } +/// Disappearing-message settings carried by a group's `` node. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct GroupEphemeralSettings { + pub expiration: Option, + pub trigger: Option, +} + +impl ProtocolNode for GroupEphemeralSettings { + fn tag(&self) -> &'static str { + "ephemeral" + } + + fn into_node(self) -> Node { + let mut builder = NodeBuilder::new("ephemeral"); + if let Some(expiration) = self.expiration { + builder = builder.attr("expiration", expiration); + } + if let Some(trigger) = self.trigger { + builder = builder.attr("trigger", trigger); + } + builder.build() + } + + fn try_from_node_ref(node: &NodeRef<'_>) -> Result { + if node.tag != "ephemeral" { + return Err(anyhow!("expected , got <{}>", node.tag)); + } + + let mut attrs = node.attrs(); + Ok(Self { + expiration: attrs + .optional_string("expiration") + .and_then(|value| value.parse().ok()), + trigger: attrs + .optional_string("trigger") + .and_then(|value| value.parse().ok()), + }) + } +} + /// Response from a group info query. #[derive(Debug, Clone)] #[non_exhaustive] pub struct GroupInfoResponse { pub id: Jid, pub subject: GroupSubject, + /// Optional display notification string (from `notify`). + pub notify: Option, pub addressing_mode: AddressingMode, pub participants: Vec, /// Group creator JID (from `creator` attribute). pub creator: Option, + /// Creator's phone-number JID when `creator` is a LID. + pub creator_pn: Option, + /// Creator's Meta username, when present. + pub creator_username: Option, + /// Creator's ISO country code, when present. + pub creator_country_code: Option, /// Group creation timestamp (from `creation` attribute). pub creation_time: Option, /// Subject modification timestamp (from `s_t` attribute). pub subject_time: Option, /// Subject owner JID (from `s_o` attribute). pub subject_owner: Option, + /// Subject owner's phone-number JID (from `s_o_pn`). + pub subject_owner_pn: Option, + /// Subject owner's Meta username (from `s_o_username`). + pub subject_owner_username: Option, /// Group description body text. pub description: Option, /// Description ID (for conflict detection when updating). pub description_id: Option, /// JID of the participant who set the description. pub description_owner: Option, + /// Description owner's phone-number JID. + pub description_owner_pn: Option, + /// Description owner's Meta username. + pub description_owner_username: Option, /// Timestamp when the description was set. pub description_time: Option, /// Whether the group is locked (only admins can edit group info). pub is_locked: bool, /// Whether announcement mode is enabled (only admins can send messages). pub is_announcement: bool, - /// Ephemeral message expiration in seconds (0 = disabled). - pub ephemeral_expiration: u32, - /// Disappearing mode trigger (0-20 range, from `trigger` attribute on ``). - pub ephemeral_trigger: Option, + /// Disappearing-message settings when an `` node is present. + pub ephemeral: Option, /// Whether membership approval is required to join. pub membership_approval: bool, /// Who can add members to the group. @@ -629,13 +698,8 @@ impl ProtocolNode for GroupInfoResponse { if self.is_announcement { children.push(NodeBuilder::new("announcement").build()); } - if self.ephemeral_expiration > 0 || self.ephemeral_trigger.is_some() { - let mut eph = - NodeBuilder::new("ephemeral").attr("expiration", self.ephemeral_expiration); - if let Some(trigger) = self.ephemeral_trigger { - eph = eph.attr("trigger", trigger); - } - children.push(eph.build()); + if let Some(ephemeral) = self.ephemeral { + children.push(ephemeral.into_node()); } if self.membership_approval { children.push( @@ -663,6 +727,8 @@ impl ProtocolNode for GroupInfoResponse { if self.description.is_some() || self.description_id.is_some() || self.description_owner.is_some() + || self.description_owner_pn.is_some() + || self.description_owner_username.is_some() || self.description_time.is_some() { let mut desc_builder = NodeBuilder::new("description"); @@ -672,6 +738,12 @@ impl ProtocolNode for GroupInfoResponse { if let Some(ref owner) = self.description_owner { desc_builder = desc_builder.attr("participant", owner); } + if let Some(ref owner_pn) = self.description_owner_pn { + desc_builder = desc_builder.attr("participant_pn", owner_pn); + } + if let Some(ref username) = self.description_owner_username { + desc_builder = desc_builder.attr("participant_username", username); + } if let Some(t) = self.description_time { desc_builder = desc_builder.attr("t", t); } @@ -745,9 +817,21 @@ impl ProtocolNode for GroupInfoResponse { .attr("subject", self.subject.as_str()) .attr("addressing_mode", self.addressing_mode.as_str()); + if let Some(notify) = self.notify { + builder = builder.attr("notify", notify); + } if let Some(creator) = self.creator { builder = builder.attr("creator", creator); } + if let Some(creator_pn) = self.creator_pn { + builder = builder.attr("creator_pn", creator_pn); + } + if let Some(creator_username) = self.creator_username { + builder = builder.attr("creator_username", creator_username); + } + if let Some(creator_country_code) = self.creator_country_code { + builder = builder.attr("creator_country_code", creator_country_code); + } if let Some(creation_time) = self.creation_time { builder = builder.attr("creation", creation_time); } @@ -757,6 +841,12 @@ impl ProtocolNode for GroupInfoResponse { if let Some(subject_owner) = self.subject_owner { builder = builder.attr("s_o", subject_owner); } + if let Some(subject_owner_pn) = self.subject_owner_pn { + builder = builder.attr("s_o_pn", subject_owner_pn); + } + if let Some(subject_owner_username) = self.subject_owner_username { + builder = builder.attr("s_o_username", subject_owner_username); + } if let Some(size) = self.size { builder = builder.attr("size", size); } @@ -787,6 +877,10 @@ impl ProtocolNode for GroupInfoResponse { .unwrap_or_default(), ); + let notify = attrs + .optional_string("notify") + .map(|value| value.into_owned()); + let addressing_mode = AddressingMode::try_from( attrs .optional_string("addressing_mode") @@ -795,9 +889,20 @@ impl ProtocolNode for GroupInfoResponse { )?; let creator = attrs.optional_jid("creator"); + let creator_pn = attrs.optional_jid("creator_pn"); + let creator_username = attrs + .optional_string("creator_username") + .map(|value| value.into_owned()); + let creator_country_code = attrs + .optional_string("creator_country_code") + .map(|value| value.into_owned()); let creation_time = attrs.optional_u64("creation"); let subject_time = attrs.optional_u64("s_t"); let subject_owner = attrs.optional_jid("s_o"); + let subject_owner_pn = attrs.optional_jid("s_o_pn"); + let subject_owner_username = attrs + .optional_string("s_o_username") + .map(|value| value.into_owned()); let size = attrs .optional_string("size") .and_then(|s| s.parse::().ok()); @@ -807,14 +912,10 @@ impl ProtocolNode for GroupInfoResponse { let is_locked = node.get_optional_child_by_tag(&["locked"]).is_some(); let is_announcement = node.get_optional_child_by_tag(&["announcement"]).is_some(); - let ephemeral_node = node.get_optional_child_by_tag(&["ephemeral"]); - let ephemeral_expiration = ephemeral_node - .and_then(|n| n.attrs().optional_string("expiration")) - .and_then(|s| s.parse::().ok()) - .unwrap_or(0); - let ephemeral_trigger = ephemeral_node - .and_then(|n| n.attrs().optional_string("trigger")) - .and_then(|s| s.parse::().ok()); + let ephemeral = node + .get_optional_child_by_tag(&["ephemeral"]) + .map(GroupEphemeralSettings::try_from_node_ref) + .transpose()?; let membership_approval = node .get_optional_child_by_tag(&["membership_approval_mode", "group_join"]) @@ -845,6 +946,11 @@ impl ProtocolNode for GroupInfoResponse { .map(|s| s.to_string()); let description_owner = description_node.and_then(|n| n.attrs().optional_jid("participant")); + let description_owner_pn = + description_node.and_then(|n| n.attrs().optional_jid("participant_pn")); + let description_owner_username = description_node + .and_then(|n| n.attrs().optional_string("participant_username")) + .map(|value| value.into_owned()); let description_time = description_node .and_then(|n| n.attrs().optional_string("t")) .and_then(|s| s.parse::().ok()); @@ -902,20 +1008,27 @@ impl ProtocolNode for GroupInfoResponse { Ok(Self { id, subject, + notify, addressing_mode, participants, creator, + creator_pn, + creator_username, + creator_country_code, creation_time, subject_time, subject_owner, + subject_owner_pn, + subject_owner_username, description, description_id, description_owner, + description_owner_pn, + description_owner_username, description_time, is_locked, is_announcement, - ephemeral_expiration, - ephemeral_trigger, + ephemeral, membership_approval, member_add_mode, member_link_mode, @@ -3890,6 +4003,167 @@ mod tests { assert_eq!(response.description_time, Some(1700000000)); } + #[test] + fn test_group_info_response_preserves_optional_wire_metadata() { + let node = NodeBuilder::new("group") + .attr("id", "120363000000000010@g.us") + .attr("subject", "Protocol fixture") + .attr("notify", "Fixture notification") + .attr("addressing_mode", "lid") + .attr("creator", "100000000000010@lid") + .attr("creator_pn", "15550000010@s.whatsapp.net") + .attr("creator_username", "fixture.creator") + .attr("creator_country_code", "US") + .attr("s_o", "100000000000011@lid") + .attr("s_o_pn", "15550000011@s.whatsapp.net") + .attr("s_o_username", "fixture.subject") + .children([ + NodeBuilder::new("description") + .attr("id", "fixture-description") + .attr("participant", "100000000000012@lid") + .attr("participant_pn", "15550000012@s.whatsapp.net") + .attr("participant_username", "fixture.description") + .attr("t", 1_700_000_012u64) + .children([NodeBuilder::new("body") + .string_content("Fixture description") + .build()]) + .build(), + NodeBuilder::new("ephemeral") + .attr("expiration", 0u32) + .attr("trigger", 4u32) + .build(), + NodeBuilder::new("participant") + .attr("jid", "100000000000013@lid") + .attr("phone_number", "15550000013@s.whatsapp.net") + .attr("participant_username", "fixture.member") + .attr("type", "superadmin") + .build(), + NodeBuilder::new("participant") + .attr("jid", "15550000014@s.whatsapp.net") + .attr("lid", "100000000000014@lid") + .attr("username", "fixture.fallback") + .attr("type", "admin") + .build(), + ]) + .build(); + + let response = GroupInfoResponse::try_from_node(&node).unwrap(); + assert_eq!(response.notify.as_deref(), Some("Fixture notification")); + assert_eq!( + response.creator_pn, + Some("15550000010@s.whatsapp.net".parse().unwrap()) + ); + assert_eq!( + response.creator_username.as_deref(), + Some("fixture.creator") + ); + assert_eq!(response.creator_country_code.as_deref(), Some("US")); + assert_eq!( + response.subject_owner_pn, + Some("15550000011@s.whatsapp.net".parse().unwrap()) + ); + assert_eq!( + response.subject_owner_username.as_deref(), + Some("fixture.subject") + ); + assert_eq!( + response.description_owner_pn, + Some("15550000012@s.whatsapp.net".parse().unwrap()) + ); + assert_eq!( + response.description_owner_username.as_deref(), + Some("fixture.description") + ); + assert_eq!( + response.ephemeral, + Some(GroupEphemeralSettings { + expiration: Some(0), + trigger: Some(4), + }) + ); + assert_eq!( + response.participants[0].participant_type, + ParticipantType::SuperAdmin + ); + assert_eq!( + response.participants[0].phone_number, + Some("15550000013@s.whatsapp.net".parse().unwrap()) + ); + assert_eq!( + response.participants[0].username.as_deref(), + Some("fixture.member") + ); + assert_eq!( + response.participants[1].lid, + Some("100000000000014@lid".parse().unwrap()) + ); + assert_eq!( + response.participants[1].username.as_deref(), + Some("fixture.fallback") + ); + + let round_trip = GroupInfoResponse::try_from_node(&response.into_node()).unwrap(); + assert_eq!(round_trip.ephemeral.unwrap().expiration, Some(0)); + assert_eq!( + round_trip.description_owner_username.as_deref(), + Some("fixture.description") + ); + assert_eq!( + round_trip.participants[0].participant_type, + ParticipantType::SuperAdmin + ); + } + + #[test] + fn test_group_info_response_distinguishes_absent_and_empty_ephemeral_nodes() { + let without_ephemeral = NodeBuilder::new("group") + .attr("id", "120363000000000020@g.us") + .attr("subject", "No ephemeral node") + .build(); + let with_empty_ephemeral = NodeBuilder::new("group") + .attr("id", "120363000000000021@g.us") + .attr("subject", "Empty ephemeral node") + .children([NodeBuilder::new("ephemeral").build()]) + .build(); + + let absent = GroupInfoResponse::try_from_node(&without_ephemeral).unwrap(); + let empty = GroupInfoResponse::try_from_node(&with_empty_ephemeral).unwrap(); + + assert!(absent.ephemeral.is_none()); + assert_eq!(empty.ephemeral, Some(GroupEphemeralSettings::default())); + assert!(empty.into_node().get_optional_child("ephemeral").is_some()); + } + + #[test] + fn test_group_info_response_serializes_description_identity_without_body() { + let node = NodeBuilder::new("group") + .attr("id", "120363000000000030@g.us") + .attr("subject", "Description identity") + .children([NodeBuilder::new("description") + .attr("participant_pn", "15550000030@s.whatsapp.net") + .attr("participant_username", "fixture.description.only") + .build()]) + .build(); + + let response = GroupInfoResponse::try_from_node(&node).unwrap(); + let serialized = response.into_node(); + let description = serialized + .get_optional_child("description") + .expect("description identity attributes must retain their node"); + + assert_eq!( + description.attrs().optional_jid("participant_pn"), + Some("15550000030@s.whatsapp.net".parse().unwrap()) + ); + assert_eq!( + description + .attrs() + .optional_string("participant_username") + .as_deref(), + Some("fixture.description.only") + ); + } + /// `parse_response` should overlay `is_parent_group` and /// `allow_non_admin_sub_group_creation` from the request when the server /// omits `` from a community-create reply (WA Web's CreateJob diff --git a/wacore/src/stanza/groups.rs b/wacore/src/stanza/groups.rs index de498ed3c..e4fd13548 100644 --- a/wacore/src/stanza/groups.rs +++ b/wacore/src/stanza/groups.rs @@ -34,10 +34,17 @@ pub enum MembershipRequestMethod { pub struct GroupNotification { /// Group JID (from `from` attribute) pub group_jid: Jid, + /// Notification stanza identifier (from `id`). + pub notification_id: Option, /// Admin/user who triggered the notification (from `participant` attribute) pub participant: Option, /// Phone number JID of the participant (from `participant_pn` attribute, for LID groups) pub participant_pn: Option, + /// Username of the participant (from `participant_username`, when username + /// addressing is enabled for the account). + pub participant_username: Option, + /// ISO country code supplied for the participant on newer group notifications. + pub participant_country_code: Option, /// Timestamp (from `t` attribute, unix seconds) pub timestamp: u64, /// Whether the group uses LID addressing mode (from `addressing_mode="lid"`) @@ -340,8 +347,15 @@ impl GroupNotification { pub fn try_from_node_ref(node: &NodeRef<'_>) -> Option { let mut attrs = node.attrs(); let group_jid = attrs.optional_jid("from")?; + let notification_id = attrs.optional_string("id").map(|value| value.into_owned()); let participant = attrs.optional_jid("participant"); let participant_pn = attrs.optional_jid("participant_pn"); + let participant_username = attrs + .optional_string("participant_username") + .map(|value| value.into_owned()); + let participant_country_code = attrs + .optional_string("participant_country_code") + .map(|value| value.into_owned()); let timestamp = attrs.optional_u64("t").unwrap_or(0); let is_lid_addressing_mode = node .get_attr("addressing_mode") @@ -355,8 +369,11 @@ impl GroupNotification { Some(Self { group_jid, + notification_id, participant, participant_pn, + participant_username, + participant_country_code, timestamp, is_lid_addressing_mode, actions, @@ -617,7 +634,10 @@ fn parse_participants(node: &NodeRef<'_>) -> Vec { .unwrap_or(GroupParticipantType::Participant), ); let lid = attrs.optional_jid("lid"); - let username = attrs.optional_string("username").map(|s| s.into_owned()); + let username = attrs + .optional_string("participant_username") + .or_else(|| attrs.optional_string("username")) + .map(|s| s.into_owned()); let join_time = attrs.optional_u64("join_time"); Some(GroupParticipantInfo { jid, @@ -729,6 +749,31 @@ mod tests { .build() } + #[test] + fn test_parse_root_participant_identity_attributes() { + let participant_pn: Jid = "5511888888888@s.whatsapp.net".parse().unwrap(); + let node = NodeBuilder::new("notification") + .attr("type", "w:gp2") + .attr("from", group_jid()) + .attr("id", "GP-ROOT-1") + .attr("participant", "271060335329480@lid") + .attr("participant_pn", participant_pn.clone()) + .attr("participant_username", "group-admin") + .attr("participant_country_code", "BR") + .attr("t", "1704067200") + .children(vec![NodeBuilder::new("announcement").build()]) + .build(); + + let notification = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap(); + assert_eq!(notification.notification_id.as_deref(), Some("GP-ROOT-1")); + assert_eq!(notification.participant_pn, Some(participant_pn)); + assert_eq!( + notification.participant_username.as_deref(), + Some("group-admin") + ); + assert_eq!(notification.participant_country_code.as_deref(), Some("BR")); + } + #[test] fn test_parse_add_notification() { let node = make_notification(vec![ @@ -826,7 +871,8 @@ mod tests { .attr("jid", "55510000001@s.whatsapp.net") .attr("type", "admin") .attr("lid", "99900000000001@lid") - .attr("username", "alice") + .attr("participant_username", "alice") + .attr("username", "fallback-alice") .attr("join_time", "1700000000") .build(), ]) diff --git a/wacore/src/types/events.rs b/wacore/src/types/events.rs index 2124016fe..7165179e5 100755 --- a/wacore/src/types/events.rs +++ b/wacore/src/types/events.rs @@ -641,8 +641,7 @@ pub enum Event { QrScannedWithoutMultidevice(QrScannedWithoutMultidevice), ClientOutdated(ClientOutdated), - /// One or more decrypted inbound messages, in arrival order (Baileys' - /// `messages.upsert` shape). Live traffic arrives as single-message + /// One or more decrypted inbound messages, in arrival order. Live traffic arrives as single-message /// batches; an offline drain delivers one batch per durable commit, so a /// consumer never sees a message that a registered durability hook has /// not committed. The `Arc` slice is shared with the hook call — same @@ -902,8 +901,7 @@ pub struct InboundMessage { pub info: Arc, } -/// How a [`MessageBatch`] was delivered. Mirrors Baileys' `messages.upsert` -/// `type` field (`notify` / `append`). This describes the delivery shape, +/// How a [`MessageBatch`] was delivered. This describes the delivery shape, /// not a message's provenance: whether a stanza came from the offline queue /// is `info.is_offline` on each [`InboundMessage`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] @@ -1388,12 +1386,23 @@ pub struct ContactSyncRequested { pub struct GroupUpdate { /// The group this update applies to pub group_jid: Jid, + /// Identifier of the source notification stanza. + #[serde(skip_serializing_if = "Option::is_none")] + pub notification_id: Option, + /// Zero-based emitted-action index within the source notification. + pub action_index: u32, /// The admin/user who triggered the change (`participant` attribute) #[serde(skip_serializing_if = "Option::is_none")] pub participant: Option, /// Phone number JID of the participant (for LID-addressed groups) #[serde(skip_serializing_if = "Option::is_none")] pub participant_pn: Option, + /// Username of the participant, when supplied by the group notification. + #[serde(skip_serializing_if = "Option::is_none")] + pub participant_username: Option, + /// Country code supplied for the participant by the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub participant_country_code: Option, /// When the change occurred pub timestamp: DateTime, /// Whether the group uses LID addressing mode From 1041b9ab31c82a869c48d4eb305a826b3dfd997a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:49:53 -0300 Subject: [PATCH 2/7] feat(core): expose shared cryptographic primitives --- wacore/src/bot_message.rs | 8 +-- wacore/src/crypto.rs | 119 +++++++++++++++++++++++++++++++++ wacore/src/download.rs | 11 +-- wacore/src/lib.rs | 1 + wacore/src/media_retry.rs | 5 +- wacore/src/pair.rs | 4 +- wacore/src/pair_code.rs | 16 ++--- wacore/src/secret_enc_addon.rs | 5 +- wacore/src/shortcake.rs | 14 ++-- wacore/src/store/device.rs | 2 +- wacore/src/voip/mod.rs | 9 +-- wacore/src/voip/ssrc.rs | 13 ++-- 12 files changed, 158 insertions(+), 49 deletions(-) create mode 100644 wacore/src/crypto.rs diff --git a/wacore/src/bot_message.rs b/wacore/src/bot_message.rs index 1a40853ff..9957c57e4 100644 --- a/wacore/src/bot_message.rs +++ b/wacore/src/bot_message.rs @@ -16,8 +16,6 @@ //! editing a prior reply. use anyhow::{Result, anyhow}; -use hkdf::Hkdf; -use sha2::Sha256; use crate::libsignal::crypto::{aes_256_gcm_decrypt, aes_256_gcm_encrypt}; @@ -50,9 +48,8 @@ fn derive_base_bot_key(message_secret: &[u8]) -> Result<[u8; KEY_SIZE]> { message_secret.len() )); } - let hk = Hkdf::::new(None, message_secret); let mut out = [0u8; KEY_SIZE]; - hk.expand(BOT_MESSAGE_INFO, &mut out) + crate::crypto::hkdf_sha256_into(message_secret, None, BOT_MESSAGE_INFO, &mut out) .map_err(|e| anyhow!("HKDF expand failed: {e}"))?; Ok(out) } @@ -68,9 +65,8 @@ fn derive_per_message_key( info.extend_from_slice(ctx.msg_id.as_bytes()); info.extend_from_slice(ctx.target_sender_user_jid.as_bytes()); info.extend_from_slice(ctx.bot_user_jid.as_bytes()); - let hk = Hkdf::::new(None, base_key); let mut out = [0u8; KEY_SIZE]; - hk.expand(&info, &mut out) + crate::crypto::hkdf_sha256_into(base_key, None, &info, &mut out) .expect("HKDF expand with 32-byte output never fails"); out } diff --git a/wacore/src/crypto.rs b/wacore/src/crypto.rs new file mode 100644 index 000000000..5f0b9649e --- /dev/null +++ b/wacore/src/crypto.rs @@ -0,0 +1,119 @@ +//! Runtime-agnostic cryptographic primitives shared across protocol features. + +use hkdf::Hkdf; +use sha2::Sha256; + +use crate::libsignal::protocol::{CurveError, KeyPair, PrivateKey}; + +const HKDF_SHA256_MAX_OUTPUT_LENGTH: usize = 255 * 32; + +/// Errors returned by the shared cryptographic helpers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum CryptoError { + /// The requested HKDF output exceeds the SHA-256 expansion limit. + #[error("HKDF-SHA256 output length is invalid")] + InvalidHkdfLength, +} + +/// Computes an MD5 digest. +pub fn md5_digest(input: &[u8]) -> [u8; 16] { + md5::compute(input).into() +} + +/// Derives `expanded_length` bytes using HKDF-SHA256. +pub fn hkdf_sha256( + input_key_material: &[u8], + expanded_length: usize, + salt: Option<&[u8]>, + info: &[u8], +) -> Result, CryptoError> { + if expanded_length > HKDF_SHA256_MAX_OUTPUT_LENGTH { + return Err(CryptoError::InvalidHkdfLength); + } + let mut output = vec![0; expanded_length]; + hkdf_sha256_into(input_key_material, salt, info, &mut output)?; + Ok(output) +} + +/// Derives HKDF-SHA256 output directly into a caller-provided buffer. +pub fn hkdf_sha256_into( + input_key_material: &[u8], + salt: Option<&[u8]>, + info: &[u8], + output: &mut [u8], +) -> Result<(), CryptoError> { + Hkdf::::new(salt, input_key_material) + .expand(info, output) + .map_err(|_| CryptoError::InvalidHkdfLength) +} + +/// Generates a Curve25519 key pair with the configured secure random source. +pub fn generate_curve_key_pair() -> KeyPair { + KeyPair::generate(&mut rand::make_rng::()) +} + +/// Signs `message` with the supplied Curve25519 private key. +pub fn calculate_curve_signature( + private_key: &PrivateKey, + message: &[u8], +) -> Result<[u8; 64], CurveError> { + private_key.calculate_signature(message, &mut rand::make_rng::()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hashes_and_expands_known_vectors() { + assert_eq!( + hex::encode(md5_digest(b"abc")), + "900150983cd24fb0d6963f7d28e17f72" + ); + + let ikm = [0x0bu8; 22]; + let salt = hex::decode("000102030405060708090a0b0c").unwrap(); + let info = hex::decode("f0f1f2f3f4f5f6f7f8f9").unwrap(); + let output = hkdf_sha256(&ikm, 42, Some(&salt), &info).unwrap(); + assert_eq!( + hex::encode(output), + "3cb25f25faacd57a90434f64d0362f2a\ + 2d2d0a90cf1a5a4c5db02d56ecc4c5bf\ + 34007208d5b887185865" + .replace(char::is_whitespace, "") + ); + } + + #[test] + fn writes_into_a_caller_owned_buffer() { + let mut output = [0u8; 32]; + hkdf_sha256_into(b"input", None, b"info", &mut output).unwrap(); + assert_eq!( + output.as_slice(), + hkdf_sha256(b"input", 32, None, b"info").unwrap() + ); + } + + #[test] + fn rejects_output_beyond_sha256_limit() { + assert_eq!( + hkdf_sha256(b"input", HKDF_SHA256_MAX_OUTPUT_LENGTH + 1, None, b"info"), + Err(CryptoError::InvalidHkdfLength) + ); + + let mut output = vec![0; HKDF_SHA256_MAX_OUTPUT_LENGTH + 1]; + assert_eq!( + hkdf_sha256_into(b"input", None, b"info", &mut output), + Err(CryptoError::InvalidHkdfLength) + ); + } + + #[test] + fn generated_keys_sign_with_the_shared_signal_implementation() { + let pair = generate_curve_key_pair(); + let signature = calculate_curve_signature(&pair.private_key, b"message").unwrap(); + assert!(pair.public_key.verify_signature(b"message", &signature)); + assert!(!pair.public_key.verify_signature(b"tampered", &signature)); + } +} diff --git a/wacore/src/download.rs b/wacore/src/download.rs index f959b13fa..480669b60 100644 --- a/wacore/src/download.rs +++ b/wacore/src/download.rs @@ -5,7 +5,6 @@ use crate::libsignal::crypto::{ use anyhow::{Result, anyhow}; use base64::Engine as _; use base64::prelude::*; -use hkdf::Hkdf; use hmac::Hmac; use hmac::Mac; use sha2::Sha256; @@ -487,10 +486,14 @@ impl DownloadUtils { media_key: &[u8], app_info: MediaType, ) -> Result<([u8; 16], [u8; 32], [u8; 32])> { - let hk = Hkdf::::new(None, media_key); let mut expanded = [0u8; 112]; - hk.expand(app_info.app_info().as_bytes(), &mut expanded) - .map_err(|e| anyhow!("HKDF expand failed: {e}"))?; + crate::crypto::hkdf_sha256_into( + media_key, + None, + app_info.app_info().as_bytes(), + &mut expanded, + ) + .map_err(|e| anyhow!("HKDF expand failed: {e}"))?; let iv: [u8; 16] = expanded[0..16] .try_into() .map_err(|_| anyhow!("HKDF output has unexpected length for IV"))?; diff --git a/wacore/src/lib.rs b/wacore/src/lib.rs index dc6c608b7..5c668f5d9 100644 --- a/wacore/src/lib.rs +++ b/wacore/src/lib.rs @@ -14,6 +14,7 @@ pub mod bot_message; pub mod client; pub mod client_profile; pub mod companion_reg; +pub mod crypto; pub mod download; pub mod iq; pub mod protocol; diff --git a/wacore/src/media_retry.rs b/wacore/src/media_retry.rs index 9ac35453d..6fe71ed00 100644 --- a/wacore/src/media_retry.rs +++ b/wacore/src/media_retry.rs @@ -12,9 +12,7 @@ use anyhow::{Result, anyhow}; use buffa::MessageView; -use hkdf::Hkdf; use rand::Rng; -use sha2::Sha256; use wacore_binary::Jid; use wacore_binary::builder::NodeBuilder; use wacore_binary::{Node, NodeContentRef, NodeRef}; @@ -40,9 +38,8 @@ pub enum MediaRetryResult { /// /// WA Web: `WACryptoHkdf.extractAndExpand(mediaKey, "WhatsApp Media Retry Notification", 32)` fn derive_media_retry_key(media_key: &[u8]) -> Result<[u8; 32]> { - let hk = Hkdf::::new(None, media_key); let mut key = [0u8; 32]; - hk.expand(MEDIA_RETRY_HKDF_INFO.as_bytes(), &mut key) + crate::crypto::hkdf_sha256_into(media_key, None, MEDIA_RETRY_HKDF_INFO.as_bytes(), &mut key) .map_err(|e| anyhow!("HKDF expand failed: {e}"))?; Ok(key) } diff --git a/wacore/src/pair.rs b/wacore/src/pair.rs index 608e0fd93..e3b3e8c04 100644 --- a/wacore/src/pair.rs +++ b/wacore/src/pair.rs @@ -3,7 +3,6 @@ use crate::libsignal::crypto::aes_256_gcm_encrypt; use crate::libsignal::protocol::{KeyPair, PublicKey}; use base64::Engine as _; use base64::prelude::*; -use hkdf::Hkdf; use hmac::{Hmac, Mac}; use sha2::Sha256; @@ -409,8 +408,7 @@ impl PairUtils { // Encrypt the final message let mut encryption_key = [0u8; 32]; - Hkdf::::new(None, &shared_secret) - .expand(b"WA-Ads-Key", &mut encryption_key) + crate::crypto::hkdf_sha256_into(&shared_secret, None, b"WA-Ads-Key", &mut encryption_key) .map_err(|_| anyhow::anyhow!("HKDF expand failed"))?; let nonce = [0u8; 12]; let mut encrypted = Vec::with_capacity(final_message.len() + 16); diff --git a/wacore/src/pair_code.rs b/wacore/src/pair_code.rs index 5428dc20c..88b94eea8 100644 --- a/wacore/src/pair_code.rs +++ b/wacore/src/pair_code.rs @@ -27,7 +27,6 @@ use crate::libsignal::crypto::{CryptoProviderError, aes_256_gcm_encrypt}; use crate::libsignal::protocol::{CurveError, KeyPair, PublicKey}; use aes::cipher::{KeyIvInit, StreamCipher}; use ctr::Ctr128BE; -use hkdf::Hkdf; use hmac::{Hmac, Mac}; use rand::RngExt; use sha2::Sha256; @@ -473,10 +472,8 @@ impl PairCodeUtils { combined_secret.extend_from_slice(&identity_shared); combined_secret.extend_from_slice(&random_bytes); - let hk_adv = Hkdf::::new(None, &combined_secret); let mut new_adv_secret = [0u8; 32]; - hk_adv - .expand(b"adv_secret", &mut new_adv_secret) + crate::crypto::hkdf_sha256_into(&combined_secret, None, b"adv_secret", &mut new_adv_secret) .map_err(|_| PairCodeError::AdvSecretKeyDerivation)?; // Prepare bundle: companion_identity_pub (32) + primary_identity_pub (32) + random_bytes (32) = 96 bytes @@ -491,11 +488,14 @@ impl PairCodeUtils { // Derive bundle encryption key using HKDF // HKDF(IKM=ephemeral_shared, salt=random_salt, info="link_code_pairing_key_bundle_encryption_key") - let hk_bundle = Hkdf::::new(Some(&key_bundle_salt), &ephemeral_shared); let mut enc_key = [0u8; 32]; - hk_bundle - .expand(b"link_code_pairing_key_bundle_encryption_key", &mut enc_key) - .map_err(|_| PairCodeError::BundleKeyDerivation)?; + crate::crypto::hkdf_sha256_into( + &ephemeral_shared, + Some(&key_bundle_salt), + b"link_code_pairing_key_bundle_encryption_key", + &mut enc_key, + ) + .map_err(|_| PairCodeError::BundleKeyDerivation)?; // Generate random IV for AES-GCM (12 bytes) let mut iv = [0u8; 12]; diff --git a/wacore/src/secret_enc_addon.rs b/wacore/src/secret_enc_addon.rs index 8c12d4cf4..58d2087d8 100644 --- a/wacore/src/secret_enc_addon.rs +++ b/wacore/src/secret_enc_addon.rs @@ -22,8 +22,6 @@ //! - everything else (edits, reactions, comments, poll add option) → empty use anyhow::{Result, anyhow}; -use hkdf::Hkdf; -use sha2::Sha256; use crate::libsignal::crypto::{aes_256_gcm_decrypt, aes_256_gcm_encrypt}; @@ -117,9 +115,8 @@ pub fn derive_use_case_secret( info.extend_from_slice(ctx.modification_sender.as_bytes()); info.extend_from_slice(ctx.modification_type.as_str().as_bytes()); - let hk = Hkdf::::new(None, message_secret); let mut key = [0u8; KEY_SIZE]; - hk.expand(&info, &mut key) + crate::crypto::hkdf_sha256_into(message_secret, None, &info, &mut key) .map_err(|e| anyhow!("HKDF expand failed: {e}"))?; Ok(key) } diff --git a/wacore/src/shortcake.rs b/wacore/src/shortcake.rs index 0fc8d08c2..0e795f64b 100644 --- a/wacore/src/shortcake.rs +++ b/wacore/src/shortcake.rs @@ -32,6 +32,7 @@ use crate::libsignal::crypto::aes_256_gcm_encrypt; use crate::libsignal::protocol::{CurveError, KeyPair, PublicKey}; use crate::pair_code::PairCodeUtils; use buffa::Enumeration; +#[cfg(test)] use hkdf::Hkdf; use hmac::{Hmac, KeyInit as _, Mac}; use rand::RngExt; @@ -186,10 +187,14 @@ impl ShortcakeUtils { "Companion Pairing {} with ref {ref_str}", device_type.to_i32() ); - let hk = Hkdf::::new(Some(salt.as_bytes()), shared_secret); let mut key = [0u8; 32]; - hk.expand(ENC_KEY_INFO, &mut key) - .map_err(|_| ShortcakeError::Hkdf("encryption_key"))?; + crate::crypto::hkdf_sha256_into( + shared_secret, + Some(salt.as_bytes()), + ENC_KEY_INFO, + &mut key, + ) + .map_err(|_| ShortcakeError::Hkdf("encryption_key"))?; Ok(key) } @@ -253,9 +258,8 @@ impl ShortcakeUtils { pub fn derive_pairing_handoff_hmac_key( prior_adv_secret: &[u8; 32], ) -> Result<[u8; 32], ShortcakeError> { - let hk = Hkdf::::new(None, prior_adv_secret); let mut key = [0u8; 32]; - hk.expand(HANDOFF_INFO, &mut key) + crate::crypto::hkdf_sha256_into(prior_adv_secret, None, HANDOFF_INFO, &mut key) .map_err(|_| ShortcakeError::Hkdf("handoff_key"))?; Ok(key) } diff --git a/wacore/src/store/device.rs b/wacore/src/store/device.rs index d601c2977..400f0a2d1 100644 --- a/wacore/src/store/device.rs +++ b/wacore/src/store/device.rs @@ -495,7 +495,7 @@ impl Device { version.secondary.unwrap_or(0), version.tertiary.unwrap_or(0) ); - let build_hash: [u8; 16] = md5::compute(version_str.as_bytes()).into(); + let build_hash = crate::crypto::md5_digest(version_str.as_bytes()); let reg_data = wa::client_payload::DevicePairingRegistrationData { e_regid: Some(self.registration_id.to_be_bytes().to_vec()), diff --git a/wacore/src/voip/mod.rs b/wacore/src/voip/mod.rs index fbe03ea8e..cb9435c54 100644 --- a/wacore/src/voip/mod.rs +++ b/wacore/src/voip/mod.rs @@ -62,18 +62,11 @@ pub use transport::{ // from these would silently produce a broken (or insecure) stack. They stay reachable only as // `#[doc(hidden)]` in their source modules so the in-tree benchmark crate can drive them. -use hkdf::Hkdf; -use sha2::Sha256; - /// HKDF-SHA256 (extract with `salt`, expand with `info`): the one KDF shape all of /// WhatsApp's VoIP key derivations reduce to. pub(crate) fn hkdf_sha256(salt: &[u8], ikm: &[u8], info: &[u8], len: usize) -> Vec { debug_assert!(len <= 255 * 32, "HKDF-SHA256 max output is 8160 bytes"); - let hk = Hkdf::::new(Some(salt), ikm); - let mut okm = vec![0u8; len]; - hk.expand(info, &mut okm) - .expect("HKDF length within bounds"); - okm + crate::crypto::hkdf_sha256(ikm, len, Some(salt), info).expect("HKDF length within bounds") } /// Device-qualified participant id used as HKDF `info` for both E2E-SRTP and SFrame: strip the diff --git a/wacore/src/voip/ssrc.rs b/wacore/src/voip/ssrc.rs index 047c49765..89d870a3b 100644 --- a/wacore/src/voip/ssrc.rs +++ b/wacore/src/voip/ssrc.rs @@ -1,15 +1,16 @@ //! SSRC derivation and participant-LID helpers for E2E HKDF `info`. -use hkdf::Hkdf; -use sha2::Sha256; - /// Participant / stream SSRC: HKDF-SHA256(salt=slot_word LE32, ikm=call_id, info=lid, 4), /// read back as a little-endian u32. pub fn derive_wasm_participant_ssrc(call_id: &str, lid: &str, slot_word: u32) -> u32 { - let hk = Hkdf::::new(Some(&slot_word.to_le_bytes()), call_id.as_bytes()); let mut okm = [0u8; 4]; - hk.expand(lid.as_bytes(), &mut okm) - .expect("4 bytes within HKDF limit"); + crate::crypto::hkdf_sha256_into( + call_id.as_bytes(), + Some(&slot_word.to_le_bytes()), + lid.as_bytes(), + &mut okm, + ) + .expect("4 bytes within HKDF limit"); u32::from_le_bytes(okm) } From 3d9c3ad88abd8db69c9fd9e9bfdf5bde295d1087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:50:04 -0300 Subject: [PATCH 3/7] feat(core): expose authenticated protocol payloads --- wacore/src/event.rs | 42 +++++++++++++++++++++++++++++++--- wacore/src/poll.rs | 56 +++++++++++++++++++++++++++++++++++++++------ 2 files changed, 88 insertions(+), 10 deletions(-) diff --git a/wacore/src/event.rs b/wacore/src/event.rs index 4eebd6e96..d32b8318b 100644 --- a/wacore/src/event.rs +++ b/wacore/src/event.rs @@ -58,19 +58,41 @@ pub fn decrypt_event_response_with_secret( event_creator_jid: &str, responder_jid: &str, ) -> Result { + let plaintext = decrypt_event_response_payload_with_secret( + enc_payload, + iv, + message_secret, + stanza_id, + event_creator_jid, + responder_jid, + )?; + Ok(waproto::codec::event_response_message_decode(&plaintext)?) +} + +/// Decrypt an event response and return its encoded protobuf payload. +/// +/// This keeps authentication and key derivation centralized while allowing a +/// caller to use its own protobuf decoder. +pub fn decrypt_event_response_payload_with_secret( + enc_payload: &[u8], + iv: &[u8], + message_secret: &[u8], + stanza_id: &str, + event_creator_jid: &str, + responder_jid: &str, +) -> Result> { // The IV length is validated downstream by decrypt_addon (try_into [u8; 12]). ensure!( message_secret.len() == MESSAGE_SECRET_SIZE, "message_secret must be {MESSAGE_SECRET_SIZE} bytes, got {}", message_secret.len() ); - let plaintext = decrypt_addon( + decrypt_addon( enc_payload, iv, message_secret, &event_response_addon_ctx(stanza_id, event_creator_jid, responder_jid), - )?; - Ok(waproto::codec::event_response_message_decode(&plaintext)?) + ) } #[cfg(test)] @@ -105,6 +127,20 @@ mod tests { .unwrap(); assert_eq!(out.response, Some(EventResponseType::Going)); assert_eq!(out.extra_guest_count, Some(2)); + + let plaintext = decrypt_event_response_payload_with_secret( + &enc, + &iv, + &secret, + "EVTID", + "5511777777777@s.whatsapp.net", + "5511888888888@s.whatsapp.net", + ) + .unwrap(); + assert_eq!( + plaintext, + waproto::codec::event_response_message_to_vec(&resp) + ); } #[test] diff --git a/wacore/src/poll.rs b/wacore/src/poll.rs index 354d8cd03..1fd3fe2e8 100644 --- a/wacore/src/poll.rs +++ b/wacore/src/poll.rs @@ -251,13 +251,33 @@ pub fn decrypt_poll_vote_with_secret( poll_creator_jid: &str, voter_jid: &str, ) -> Result>> { - let plaintext = decrypt_addon( + let plaintext = decrypt_poll_vote_payload_with_secret( + ciphertext, + message_secret, + stanza_id, + poll_creator_jid, + voter_jid, + )?; + decode_selected_options(&plaintext) +} + +/// Decrypt a poll vote and return its encoded `PollVoteMessage` payload. +/// +/// This is useful when the caller owns protobuf decoding or needs to preserve +/// fields unknown to this version of the core. +pub fn decrypt_poll_vote_payload_with_secret( + ciphertext: PollVoteCiphertext<'_>, + message_secret: &[u8], + stanza_id: &str, + poll_creator_jid: &str, + voter_jid: &str, +) -> Result> { + decrypt_addon( ciphertext.enc_payload, ciphertext.enc_iv, message_secret, &poll_vote_addon_ctx(stanza_id, poll_creator_jid, voter_jid), - )?; - decode_selected_options(&plaintext) + ) } fn visit_poll_vote_with_secret( @@ -272,11 +292,15 @@ fn visit_poll_vote_with_secret( where F: FnMut(&[u8]), { - let plaintext = decrypt_addon( - enc_payload, - iv, + let plaintext = decrypt_poll_vote_payload_with_secret( + PollVoteCiphertext { + enc_payload, + enc_iv: iv, + }, message_secret, - &poll_vote_addon_ctx(stanza_id, poll_creator_jid, voter_jid), + stanza_id, + poll_creator_jid, + voter_jid, )?; // Validate the entire plaintext BEFORE emitting anything: `visit` has // observable side effects in the caller and the caller has a fallback path @@ -340,6 +364,8 @@ mod tests { #[test] fn vote_encrypt_decrypt_roundtrip() { + use buffa::Message; + let secret = [0xCDu8; 32]; let stanza_id = "3EB0ABCD1234"; let creator = "creator@s.whatsapp.net"; @@ -364,6 +390,22 @@ mod tests { ) .unwrap(); assert_eq!(out, hashes); + + let plaintext = decrypt_poll_vote_payload_with_secret( + PollVoteCiphertext { + enc_payload: &enc, + enc_iv: &iv, + }, + &secret, + stanza_id, + creator, + voter, + ) + .unwrap(); + let vote_message = waproto::whatsapp::message::PollVoteMessage { + selected_options: hashes, + }; + assert_eq!(plaintext, vote_message.encode_to_vec()); } #[test] From e086751e458c18212c55f63ba5b10107ef382d18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:50:16 -0300 Subject: [PATCH 4/7] perf(cache): skip clocks for non-expiring entries --- src/portable_cache.rs | 36 +++++++++++++++++++++++++++++++----- wacore/src/time.rs | 6 ++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/portable_cache.rs b/src/portable_cache.rs index eb44437d1..23850fa11 100644 --- a/src/portable_cache.rs +++ b/src/portable_cache.rs @@ -233,6 +233,18 @@ where PortableCacheBuilder::new() } + /// Read the monotonic clock only for caches that can expire entries. + /// Non-expiring caches use a stable sentinel because their timestamps are + /// never observed, avoiding unnecessary clock reads on every operation. + #[inline] + fn entry_time(&self) -> Instant { + if self.ttl.is_some() || self.tti.is_some() { + Instant::now() + } else { + Instant::ZERO + } + } + fn is_expired(&self, entry: &CacheEntry, now: Instant) -> bool { if let Some(ttl) = self.ttl && now.saturating_duration_since(entry.inserted_at) >= ttl @@ -260,7 +272,7 @@ where K: Borrow, Q: Hash + Eq + ?Sized, { - let now = Instant::now(); + let now = self.entry_time(); // Fast path (no TTI): read lock only, no write needed. if self.tti.is_none() { @@ -293,7 +305,7 @@ where } pub async fn insert(&self, key: K, value: V) { - let now = Instant::now(); + let now = self.entry_time(); let mut guard = self.inner.write().await; if let Some(entry) = guard.map.get_mut(&key) { @@ -312,7 +324,7 @@ where /// Insert and return a clone of the value in one write lock. async fn insert_and_return(&self, key: K, value: V) -> V { - let now = Instant::now(); + let now = self.entry_time(); let mut guard = self.inner.write().await; if let Some(entry) = guard.map.get_mut(&key) { @@ -337,7 +349,7 @@ where K: Borrow, Q: Hash + Eq + ?Sized, { - let now = Instant::now(); + let now = self.entry_time(); let mut guard = self.inner.write().await; let owned_key = Self::find_key(&guard, key)?; let entry = guard.remove_key(&owned_key)?; @@ -544,7 +556,7 @@ where /// Evict expired entries and clean up unused init locks. pub async fn run_pending_tasks(&self) { - let now = Instant::now(); + let now = self.entry_time(); let mut guard = self.inner.write().await; guard.map.retain(|_, entry| !self.is_expired(entry, now)); @@ -598,6 +610,20 @@ mod tests { assert_eq!(cache.get("key1").await, Some("value1".to_string())); } + #[tokio::test] + async fn capacity_only_cache_uses_clock_free_timestamps() { + let cache = build_cache::(); + assert_eq!(cache.entry_time(), Instant::ZERO); + + cache.insert("key".into(), "value".into()).await; + assert_eq!(cache.get("key").await.as_deref(), Some("value")); + + let guard = cache.inner.read().await; + let entry = guard.map.get("key").expect("inserted cache entry"); + assert_eq!(entry.inserted_at, Instant::ZERO); + assert_eq!(entry.last_accessed_at, Instant::ZERO); + } + #[tokio::test] async fn test_update_existing_key() { let cache = build_cache::(); diff --git a/wacore/src/time.rs b/wacore/src/time.rs index c1e443807..2414eae94 100644 --- a/wacore/src/time.rs +++ b/wacore/src/time.rs @@ -275,6 +275,12 @@ fn default_monotonic_provider() -> Box { pub struct Instant(u64); impl Instant { + /// The origin of the configured monotonic clock. + /// + /// Useful as a storage sentinel when a component has time-based behavior + /// disabled and therefore must not read the platform clock. + pub const ZERO: Self = Self(0); + /// Capture the current monotonic instant. #[inline] pub fn now() -> Self { From 3a1c36126be7192b9fb10edadefdf354f16e141e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:50:28 -0300 Subject: [PATCH 5/7] fix(types): serialize message timestamps as Unix seconds --- wacore/src/types/message.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/wacore/src/types/message.rs b/wacore/src/types/message.rs index 61dcc574a..ceefc3796 100644 --- a/wacore/src/types/message.rs +++ b/wacore/src/types/message.rs @@ -316,6 +316,7 @@ pub struct MessageInfo { pub server_id: MessageServerId, pub r#type: String, pub push_name: String, + #[serde(with = "chrono::serde::ts_seconds")] pub timestamp: DateTime, pub category: MessageCategory, pub multicast: bool, @@ -412,6 +413,10 @@ mod tests { assert!(!root.contains_key("verified_name")); assert!(!root.contains_key("device_sent_meta")); assert!(!root.contains_key("ephemeral_expiration")); + assert_eq!( + root.get("timestamp").and_then(|value| value.as_i64()), + Some(0) + ); } #[test] From d67c11d53c15caf56bf21a8e560164557da0b3d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:51:13 -0300 Subject: [PATCH 6/7] docs(core): keep consumer guidance implementation-neutral --- src/bot.rs | 9 ++++----- src/features/chat_actions.rs | 4 ++-- src/prekeys.rs | 2 +- src/send/mod.rs | 3 +-- wacore/src/send/group.rs | 2 +- wacore/src/send/tests.rs | 4 ++-- 6 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/bot.rs b/src/bot.rs index e6ea84984..29709ceae 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -288,7 +288,7 @@ pub enum EventDelivery { Concurrent, /// Events are delivered to the callbacks strictly in arrival order through a /// single bounded mailbox drained by one task — the ordered `messages.upsert` - /// contract of WA Web (`preserveOrder`), whatsmeow and Baileys. Bounds + /// contract used by interoperable clients. Bounds /// memory: when the mailbox is full the event is dropped and counted in /// [`StatsSnapshot::events_dropped`](wacore::stats::StatsSnapshot::events_dropped) /// instead of blocking the receive pipeline or growing without limit. @@ -1059,8 +1059,8 @@ impl BotBuilder { /// Choose how registered callbacks receive events. Defaults to /// [`EventDelivery::Concurrent`]; use [`EventDelivery::Ordered`] for - /// in-arrival-order, bounded delivery (the WA Web / whatsmeow / Baileys - /// contract). Only affects the closure-based callbacks, not raw + /// in-arrival-order, bounded delivery. Only affects the closure-based + /// callbacks, not raw /// [`with_event_handler`](Self::with_event_handler) handlers, which always /// run inline on the dispatch path. pub fn with_event_delivery(mut self, delivery: EventDelivery) -> Self { @@ -1465,8 +1465,7 @@ mod tests { } /// `EventDelivery::Ordered` delivers events to a callback in arrival order — - /// the WA Web / whatsmeow / Baileys contract the concurrent default can't - /// promise. + /// the ordered consumer contract the concurrent default can't promise. #[tokio::test] async fn ordered_delivery_preserves_arrival_order() { let client = test_client().await; diff --git a/src/features/chat_actions.rs b/src/features/chat_actions.rs index 2ce7caf01..c10f10592 100644 --- a/src/features/chat_actions.rs +++ b/src/features/chat_actions.rs @@ -38,8 +38,8 @@ const MUTE_INDEFINITE: i64 = -1; pub type SyncActionMessageRange = wa::sync_action_value::SyncActionMessageRange; -/// Enables multi-device conflict resolution. `None` is safe (matches whatsmeow/Baileys). -/// Only WA Web (with a full message DB) populates this. +/// Enables multi-device conflict resolution. `None` is safe for clients without +/// a complete message database; callers with one can populate the range. pub fn message_range( last_message_timestamp: i64, last_system_message_timestamp: Option, diff --git a/src/prekeys.rs b/src/prekeys.rs index b0b9fd6d1..641e4de73 100644 --- a/src/prekeys.rs +++ b/src/prekeys.rs @@ -750,7 +750,7 @@ impl Client { /// Force-refresh the server's one-time pre-key pool with a fresh batch. /// /// Intended for callers that just restored a device from an external source - /// (e.g., migrating a Baileys session into an `InMemoryBackend`). The server + /// into an `InMemoryBackend`. The server /// may still hold pre-key IDs whose private key material the caller cannot /// reconstruct; any `pkmsg` referencing those IDs will fail forever with /// `InvalidPreKeyId`. Uploading a fresh batch gives the server new IDs the diff --git a/src/send/mod.rs b/src/send/mod.rs index f1413feda..025f9db7f 100644 --- a/src/send/mod.rs +++ b/src/send/mod.rs @@ -3956,8 +3956,7 @@ mod tests { } /// DM: `` is prepended before the ``. The - /// order matters — this is the shape the upstream Baileys - /// reproducer emits. + /// order matters because it is part of the wire shape. #[test] fn dm_emits_bot_before_biz() { let nodes = build_extra_stanza_nodes( diff --git a/wacore/src/send/group.rs b/wacore/src/send/group.rs index ff4acb1a4..403fa7730 100644 --- a/wacore/src/send/group.rs +++ b/wacore/src/send/group.rs @@ -154,7 +154,7 @@ pub async fn prepare_group_stanza( }); // Generate reporting token if the message type supports it. - // For groups, both sender_jid and remote_jid are the group JID (to_jid) per Baileys implementation. + // For groups, both sender_jid and remote_jid are the destination group JID. // Reuse the message's own secret when the caller set one (e.g. polls) instead of minting a fresh // one that would overwrite it, matching WA Web (the reporting token derives from messageSecret). let existing_secret = crate::reporting_token::extract_message_secret(message); diff --git a/wacore/src/send/tests.rs b/wacore/src/send/tests.rs index 1c5542b66..ae58c40c0 100644 --- a/wacore/src/send/tests.rs +++ b/wacore/src/send/tests.rs @@ -667,8 +667,8 @@ fn test_cloud_api_device_without_prekey() { /// # Why filter hosted devices from groups? /// /// WhatsApp Web explicitly excludes hosted devices from group message fanout. -/// From the JS code (`getFanOutList`): -/// ```javascript +/// From the reference client (`getFanOutList`): +/// ```text /// var isHosted = e.id === 99 || e.isHosted === true; /// var includeInFanout = !isHosted || isOneToOneChat; /// ``` From 4e49880638088dc7844d2e608f9ca1bb39efc7da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= <55464917+jlucaso1@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:41:42 -0300 Subject: [PATCH 7/7] feat(core): expose session and group protocol state --- src/client.rs | 91 +- src/client/lid_pn.rs | 373 +++++- src/client/lifecycle.rs | 9 +- src/client/sessions.rs | 65 +- src/client/voip.rs | 61 +- src/features/community.rs | 167 ++- src/features/contacts.rs | 15 + src/features/groups.rs | 175 ++- src/features/mex.rs | 46 + src/features/mod.rs | 16 +- src/features/rotate_key.rs | 158 ++- src/features/signal.rs | 1009 +++++++++++++++-- src/handlers/notification/groups.rs | 75 +- src/keepalive.rs | 9 + src/lib.rs | 28 +- src/lid_pn_cache.rs | 16 +- src/message.rs | 5 - src/message/special.rs | 98 +- src/prekeys.rs | 117 +- src/request.rs | 203 +++- src/retry.rs | 28 +- tests/e2e/tests/groups.rs | 9 +- wacore/binary/src/attrs.rs | 14 +- wacore/libsignal/src/protocol/local_field.rs | 1 + wacore/libsignal/src/protocol/sender_keys.rs | 2 +- .../libsignal/src/protocol/state/session.rs | 3 +- wacore/src/crypto.rs | 10 +- wacore/src/event.rs | 5 +- wacore/src/iq/business.rs | 56 +- wacore/src/iq/contacts.rs | 30 +- wacore/src/iq/groups.rs | 917 ++++++++++++++- wacore/src/poll.rs | 30 +- wacore/src/secret_enc_addon.rs | 17 +- wacore/src/stanza/groups.rs | 75 +- wacore/src/store/signal_cache.rs | 238 +++- wacore/src/types/events.rs | 23 + wacore/src/types/message.rs | 2 +- 37 files changed, 3643 insertions(+), 553 deletions(-) diff --git a/src/client.rs b/src/client.rs index 45a3ac354..3562d9ab5 100644 --- a/src/client.rs +++ b/src/client.rs @@ -25,6 +25,7 @@ use futures::FutureExt; #[cfg(test)] use std::borrow::Cow; use std::collections::{HashMap, HashSet}; +use std::num::NonZeroU64; use wacore::xml::{DisplayableNode, DisplayableNodeRef}; use wacore_binary::JidExt; @@ -529,9 +530,95 @@ pub(crate) struct OfflineSyncMetrics { pub start_time: std::sync::Mutex>, } +type ResponseWaiterSender = futures::channel::oneshot::Sender>; + +struct ResponseWaiterEntry { + generation: NonZeroU64, + sender: ResponseWaiterSender, +} + /// Map of pending IQ/ack response waiters, keyed by request id. -pub(crate) type ResponseWaiterMap = - HashMap>>; +/// +/// Every registration carries a unique generation so guarded IQ cleanup cannot +/// remove a newer waiter that reused the same explicit ID. +#[derive(Default)] +pub(crate) struct ResponseWaiterMap { + entries: HashMap, + last_generation: u64, +} + +impl ResponseWaiterMap { + fn next_generation(&mut self) -> NonZeroU64 { + loop { + self.last_generation = self.last_generation.wrapping_add(1); + if let Some(generation) = NonZeroU64::new(self.last_generation) { + return generation; + } + } + } + + pub(crate) fn try_insert_guarded( + &mut self, + request_id: String, + sender: ResponseWaiterSender, + ) -> Option { + use std::collections::hash_map::Entry; + + let generation = self.next_generation(); + match self.entries.entry(request_id) { + Entry::Vacant(entry) => { + entry.insert(ResponseWaiterEntry { generation, sender }); + Some(generation) + } + Entry::Occupied(_) => None, + } + } + + pub(crate) fn insert( + &mut self, + request_id: String, + sender: ResponseWaiterSender, + ) -> Option { + let generation = self.next_generation(); + self.entries + .insert(request_id, ResponseWaiterEntry { generation, sender }) + .map(|entry| entry.sender) + } + + pub(crate) fn remove(&mut self, request_id: &str) -> Option { + self.entries.remove(request_id).map(|entry| entry.sender) + } + + pub(crate) fn remove_guarded(&mut self, request_id: &str, cleanup_generation: NonZeroU64) { + if self + .entries + .get(request_id) + .is_some_and(|entry| entry.generation == cleanup_generation) + { + self.entries.remove(request_id); + } + } + + /// Drop every pending sender and release the map allocation without + /// resetting the generation sequence. Guards owned by the drained requests + /// may outlive a disconnect and must never match a later registration. + pub(crate) fn clear(&mut self) { + self.entries = HashMap::new(); + } + + #[cfg(test)] + pub(crate) fn contains_key(&self, request_id: &str) -> bool { + self.entries.contains_key(request_id) + } + + pub(crate) fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub(crate) fn len(&self) -> usize { + self.entries.len() + } +} pub struct Client { pub(crate) runtime: Arc, diff --git a/src/client/lid_pn.rs b/src/client/lid_pn.rs index 5b2343da2..865a36351 100644 --- a/src/client/lid_pn.rs +++ b/src/client/lid_pn.rs @@ -94,18 +94,22 @@ enum RecordOutcome { /// Already durable in both directions; nothing to do. Skipped, /// Written to (or re-affirmed in) the cache; the caller should persist it. - /// `is_new` drives the PN→LID device/session migration. - Written { entry: LidPnEntry, is_new: bool }, + /// `needs_migration` preserves the PN→LID device/session migration until + /// the mapping is durably persisted. + Written { + entry: LidPnEntry, + needs_migration: bool, + }, /// An observational source conflicted with a known LID; the phone should be /// re-resolved via a live LID query rather than trusting this pair. NeedsUsync, } -/// Outcome of recording a batch: entries to persist (with their `is_new` +/// Outcome of recording a batch: entries to persist (with their migration /// flags) plus phones that need a live LID re-query. struct BatchRecordOutcome { entries: Vec, - is_new_flags: Vec, + migration_flags: Vec, usync_phones: Vec, } @@ -173,12 +177,38 @@ impl Client { self.spawn_lid_usync_reconcile(vec![phone_number.to_string()]); Ok(()) } - RecordOutcome::Written { entry, is_new } => { - self.persist_and_migrate_lid_pn(entry, is_new).await + RecordOutcome::Written { + entry, + needs_migration, + } => { + self.persist_and_migrate_lid_pn(entry, needs_migration) + .await } } } + /// Durably add a batch of linked-identifier mappings and run the same + /// registry/session migrations as the single-entry path. + pub async fn add_lid_pn_mappings( + &self, + mappings: Vec<(String, String)>, + source: LearningSource, + ) -> Result { + let BatchRecordOutcome { + entries, + migration_flags, + usync_phones, + } = self.record_lid_pn_batch_in_memory(mappings, source).await; + self.spawn_lid_usync_reconcile(usync_phones); + + let count = entries.len(); + if !entries.is_empty() { + self.persist_and_migrate_lid_pn_batch(entries, migration_flags) + .await?; + } + Ok(count) + } + /// Hot-path variant: cache is updated synchronously (so a subsequent /// `resolve_encryption_jid` sees the mapping), DB write + migrations run /// in a detached task. Matches WA Web's `warmUpLidPnMapping` + the @@ -194,7 +224,7 @@ impl Client { /// Use [`add_lid_pn_mapping`] when the caller needs a durable guarantee. /// /// Concurrent calls for the same phone number may both observe - /// `is_new_mapping = true` and each spawn a persist task. The downstream + /// `needs_migration = true` and each spawn a persist task. The downstream /// work tolerates this: /// - `put_lid_mapping` is an upsert /// - `migrate_device_registry_on_lid_discovery` no-ops after the PN-keyed @@ -209,7 +239,7 @@ impl Client { source: LearningSource, is_offline: bool, ) { - let (entry, is_new_mapping) = match self + let (entry, needs_migration) = match self .record_lid_pn_in_memory(lid, phone_number, source) .await { @@ -218,7 +248,10 @@ impl Client { self.spawn_lid_usync_reconcile(vec![phone_number.to_string()]); return; } - RecordOutcome::Written { entry, is_new } => (entry, is_new), + RecordOutcome::Written { + entry, + needs_migration, + } => (entry, needs_migration), }; if is_offline { return; @@ -227,7 +260,7 @@ impl Client { self.runtime .spawn(Box::pin(async move { if let Err(err) = client - .persist_and_migrate_lid_pn(entry, is_new_mapping) + .persist_and_migrate_lid_pn(entry, needs_migration) .await { log::warn!("Background LID-PN persist failed: {err}"); @@ -262,7 +295,7 @@ impl Client { ) { let BatchRecordOutcome { entries, - is_new_flags, + migration_flags, usync_phones, } = self.record_lid_pn_batch_in_memory(mappings, source).await; @@ -280,7 +313,7 @@ impl Client { self.runtime .spawn(Box::pin(async move { if let Err(err) = client - .persist_and_migrate_lid_pn_batch(entries, is_new_flags) + .persist_and_migrate_lid_pn_batch(entries, migration_flags) .await { log::warn!("Background LID-PN batch persist failed: {err}"); @@ -340,8 +373,8 @@ impl Client { /// migration-sync handler (which awaits persistence instead). Each pair /// runs through [`Self::record_lid_pn_in_memory`] under the source's write /// policy. Dedups by phone_number (last lid wins) — otherwise the same - /// phone appearing twice in one batch yields is_new=true for the first - /// (lid_A) and is_new=false for the second (lid_B), so signal migration + /// phone appearing twice in one batch requests migration for the first + /// (lid_A) but not the second (lid_B), so signal migration /// runs for lid_A while the persisted mapping ends up pointing at lid_B. /// (WA Web instead records the superseded entry with created_at=0; dropping /// it is equivalent for the resolved PN→LID mapping.) @@ -358,7 +391,7 @@ impl Client { } let mut entries: Vec = Vec::with_capacity(deduped.len()); - let mut is_new_flags: Vec = Vec::with_capacity(deduped.len()); + let mut migration_flags: Vec = Vec::with_capacity(deduped.len()); let mut usync_phones: Vec = Vec::new(); for (phone_number, lid) in deduped { match self @@ -366,16 +399,19 @@ impl Client { .await { RecordOutcome::Skipped => {} - RecordOutcome::Written { entry, is_new } => { + RecordOutcome::Written { + entry, + needs_migration, + } => { entries.push(entry); - is_new_flags.push(is_new); + migration_flags.push(needs_migration); } RecordOutcome::NeedsUsync => usync_phones.push(phone_number), } } BatchRecordOutcome { entries, - is_new_flags, + migration_flags, usync_phones, } } @@ -401,11 +437,14 @@ impl Client { // Re-warm/re-affirm durability for a pair that is already the cached // mapping (exact, or reverse-only after a bounded-cache PN eviction). // Precedes the write/conflict branches so a self-consistent pair is - // neither re-migrated as a fresh directed write nor re-queried as a - // conflict; no migration runs, the PN↔LID association is unchanged. + // not re-queried as a conflict. A still-unpersisted pair retains its + // pending discovery migration across retries. let same_pair_forward_evicted = current_lid.is_none() && reverse_pn.as_deref() == Some(phone_number); if exact || same_pair_forward_evicted { + // The pair may only be cached because a prior batch write failed. + // Preserve its discovery migration until persistence succeeds. + let needs_migration = !self.lid_pn_cache.is_persisted(phone_number, lid).await; let existing = match self.lid_pn_cache.get_entry_by_phone(phone_number).await { Some(entry) => Some(entry), None => self.lid_pn_cache.get_entry_by_lid(lid).await, @@ -415,7 +454,7 @@ impl Client { self.lid_pn_cache.add(&entry).await; RecordOutcome::Written { entry, - is_new: false, + needs_migration, } } None => RecordOutcome::Skipped, @@ -436,7 +475,7 @@ impl Client { self.lid_pn_cache.add(&entry).await; return RecordOutcome::Written { entry, - is_new: current_lid.is_none(), + needs_migration: current_lid.is_none(), }; } @@ -448,11 +487,20 @@ impl Client { RecordOutcome::Skipped } - #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.session.persist_migrate_lid_pn", level = "debug", skip_all, fields(is_new = is_new_mapping), err(Debug)))] + #[cfg_attr( + feature = "tracing", + tracing::instrument( + name = "wa.session.persist_migrate_lid_pn", + level = "debug", + skip_all, + fields(needs_migration), + err(Debug) + ) + )] async fn persist_and_migrate_lid_pn( &self, entry: LidPnEntry, - is_new_mapping: bool, + needs_migration: bool, ) -> Result<()> { use anyhow::anyhow; @@ -476,7 +524,7 @@ impl Client { .mark_persisted(&storage_entry.phone_number, &storage_entry.lid) .await; - if is_new_mapping { + if needs_migration { self.migrate_device_registry_on_lid_discovery( &storage_entry.phone_number, &storage_entry.lid, @@ -496,10 +544,10 @@ impl Client { async fn persist_and_migrate_lid_pn_batch( &self, entries: Vec, - is_new_flags: Vec, + migration_flags: Vec, ) -> Result<()> { let storage = self.persist_lid_pn_batch(entries).await?; - self.migrate_lid_pn_batch(storage, is_new_flags).await; + self.migrate_lid_pn_batch(storage, migration_flags).await; Ok(()) } @@ -543,9 +591,13 @@ impl Client { /// the persist so callers on the message pipeline can await durability but /// defer this part — each new mapping walks up to MIGRATION_DEVICE_RANGE /// per-address locks, which must not stall the global processing permit. - async fn migrate_lid_pn_batch(&self, storage: Vec, is_new_flags: Vec) { - for (entry, is_new) in storage.iter().zip(is_new_flags.iter()) { - if *is_new { + async fn migrate_lid_pn_batch( + &self, + storage: Vec, + migration_flags: Vec, + ) { + for (entry, needs_migration) in storage.iter().zip(migration_flags.iter()) { + if *needs_migration { self.migrate_device_registry_on_lid_discovery(&entry.phone_number, &entry.lid) .await; self.migrate_signal_sessions_on_lid_discovery(&entry.phone_number, &entry.lid) @@ -721,7 +773,7 @@ impl Client { // MIGRATION_DEVICE_RANGE locks per mapping would stall it. let BatchRecordOutcome { entries, - is_new_flags, + migration_flags, usync_phones, } = self .record_lid_pn_batch_in_memory( @@ -741,7 +793,7 @@ impl Client { let client = Arc::clone(self); self.runtime .spawn(Box::pin(async move { - client.migrate_lid_pn_batch(storage, is_new_flags).await; + client.migrate_lid_pn_batch(storage, migration_flags).await; })) .detach(); } @@ -824,6 +876,31 @@ impl Client { pn: &str, lid: &str, ) -> bool { + use log::warn; + + let outcome = self + .migrate_signal_sessions(&Jid::pn(pn), &Jid::lid(lid)) + .await; + let migrated_sessions = outcome.migrated != 0; + if outcome.has_state_changes() + || self + .signal_cache + .has_pending_pairwise_writes_for_user(pn) + .await + { + let backend = self.persistence_manager.backend(); + if let Err(error) = self.signal_cache.flush(backend.as_ref()).await { + warn!("Failed to flush signal cache after migration: {error:?}"); + } + } + migrated_sessions + } + + pub(crate) async fn migrate_signal_sessions( + &self, + from: &Jid, + to: &Jid, + ) -> crate::features::SignalSessionMigration { use log::{info, warn}; use wacore::types::jid::JidExt; @@ -835,19 +912,17 @@ impl Client { // find nothing. On a lookup error, fall through to the full scan. if let Ok(false) = self .signal_cache - .has_state_for_user(pn, backend.as_ref()) + .has_state_for_user(&from.user, backend.as_ref()) .await { - return false; + return crate::features::SignalSessionMigration::default(); } - let mut migrated = false; + let mut outcome = crate::features::SignalSessionMigration::default(); for device_id in 0..MIGRATION_DEVICE_RANGE { - // `&str` → `CompactString` is inline for ≤24-byte user parts - // (all PN/LID identifiers fit), so no String intermediate. - let pn_jid = Jid::pn_device(pn, device_id); - let lid_jid = Jid::lid_device(lid, device_id); + let pn_jid = from.with_device(device_id); + let lid_jid = to.with_device(device_id); let pn_proto = pn_jid.to_protocol_address(); let lid_proto = lid_jid.to_protocol_address(); @@ -869,18 +944,27 @@ impl Client { // PN wins on conflict — mirrors whatsmeow's `MigratePNToLID` // (`ON CONFLICT DO UPDATE SET session=excluded.session`). - if let Ok(Some(session)) = self + match self .signal_cache .get_session(&pn_proto, backend.as_ref()) .await { - self.signal_cache.put_session(&lid_proto, session).await; - self.signal_cache.delete_session(&pn_proto).await; - migrated = true; - info!( - "Migrated session {} -> {} (PN wins on conflict)", - pn_proto, lid_proto - ); + Ok(Some(session)) => { + outcome.total += 1; + self.signal_cache.put_session(&lid_proto, session).await; + self.signal_cache.delete_session(&pn_proto).await; + outcome.migrated += 1; + info!( + "Migrated session {} -> {} (PN wins on conflict)", + pn_proto, lid_proto + ); + } + Ok(None) => {} + Err(error) => { + outcome.total += 1; + outcome.skipped += 1; + warn!("Skipping session migration for {}: {error:?}", pn_proto); + } } // Identity uses LID-wins (the inverse of session). For the same @@ -894,12 +978,12 @@ impl Client { // Match the LID lookup result explicitly so a transient read // failure isn't collapsed with `Ok(None)` and used as license // to overwrite a potentially-valid LID identity. - if let Ok(Some(identity_data)) = self + match self .signal_cache .get_identity(&pn_proto, backend.as_ref()) .await { - match self + Ok(Some(identity_data)) => match self .signal_cache .get_identity(&lid_proto, backend.as_ref()) .await @@ -909,29 +993,32 @@ impl Client { .put_identity(&lid_proto, &identity_data) .await; self.signal_cache.delete_identity(&pn_proto).await; - migrated = true; + outcome.migrated_identities += 1; info!("Migrated identity {} -> {}", pn_proto, lid_proto); } Ok(Some(_)) => { // LID-wins: existing LID identity preserved; drop the PN copy. self.signal_cache.delete_identity(&pn_proto).await; + outcome.discarded_identities += 1; } Err(e) => { + outcome.skipped_identities += 1; warn!( "Skipping identity migration {} -> {}: \ failed to read LID identity: {e:?}", pn_proto, lid_proto ); } + }, + Ok(None) => {} + Err(error) => { + outcome.skipped_identities += 1; + warn!("Skipping identity migration for {}: {error:?}", pn_proto); } } } - // Flush migrated state to backend so it survives restarts - if let Err(e) = self.signal_cache.flush(backend.as_ref()).await { - warn!("Failed to flush signal cache after migration: {e:?}"); - } - migrated + outcome } /// Look up the LID↔phone mapping for a JID. Cache-aside: falls back to @@ -1004,8 +1091,10 @@ impl Client { mod tests { use super::*; use crate::lid_pn_cache::LearningSource; - use crate::test_utils::create_test_client; + use crate::test_utils::{create_test_client, create_test_client_with_backend}; use std::sync::Arc; + use wacore::store::in_memory::InMemoryBackend; + use wacore::store::traits::SignalStore; use wacore_binary::Server; /// Fixture: test client with one cached peer LID-PN mapping. @@ -1204,7 +1293,10 @@ mod tests { assert!(matches!( outcome, - RecordOutcome::Written { is_new: false, .. } + RecordOutcome::Written { + needs_migration: false, + .. + } )); assert_eq!( client.lid_pn_cache.get_current_lid(phone).await.as_deref(), @@ -1238,10 +1330,10 @@ mod tests { ); } - /// An exact re-learn of a not-yet-durable pair re-affirms durability - /// (WA Web dirty-set flush analog) without a migration. + /// An exact re-learn of a not-yet-durable pair retains the migration work + /// until its retry successfully persists the mapping. #[tokio::test] - async fn test_record_exact_match_heals_without_migration() { + async fn test_record_exact_match_preserves_pending_migration() { let client = create_test_client().await; let phone = "5511900000040"; let lid = "200000000000040"; @@ -1255,8 +1347,14 @@ mod tests { .await; assert!( - matches!(outcome, RecordOutcome::Written { is_new: false, .. }), - "an exact re-learn re-affirms durability, no migration" + matches!( + outcome, + RecordOutcome::Written { + needs_migration: true, + .. + } + ), + "an exact re-learn must retain its pending migration" ); assert_eq!( client.lid_pn_cache.get_current_lid(phone).await.as_deref(), @@ -1264,6 +1362,35 @@ mod tests { ); } + /// A failed batch persist leaves the pair cached but not durable. Retrying + /// the same batch must retain its discovery migration instead of treating + /// the cached pair as old. + #[tokio::test] + async fn test_record_batch_retry_preserves_pending_migration() { + let client = create_test_client().await; + let phone = "5511900000041"; + let lid = "200000000000041"; + let mapping = || vec![(lid.to_string(), phone.to_string())]; + + let first = client + .record_lid_pn_batch_in_memory(mapping(), LearningSource::Other) + .await; + assert_eq!(first.migration_flags, vec![true]); + + // Do not persist `first`: this models the failed batch write. + let retry = client + .record_lid_pn_batch_in_memory(mapping(), LearningSource::Other) + .await; + assert_eq!(retry.migration_flags, vec![true]); + + client.lid_pn_cache.mark_persisted(phone, lid).await; + let durable = client + .record_lid_pn_batch_in_memory(mapping(), LearningSource::Other) + .await; + assert!(durable.entries.is_empty()); + assert!(durable.migration_flags.is_empty()); + } + /// A stale source (`MigrationSyncOld`) writes, but with `created_at = 0`, /// so the cache's most-recent-wins keeps a fresher mapping for the phone. #[tokio::test] @@ -1312,7 +1439,10 @@ mod tests { assert!(matches!( outcome, - RecordOutcome::Written { is_new: true, .. } + RecordOutcome::Written { + needs_migration: true, + .. + } )); assert_eq!( client.lid_pn_cache.get_current_lid(phone).await.as_deref(), @@ -1910,6 +2040,39 @@ mod tests { assert_eq!(client.lid_pn_cache.lid_count().await, 0); } + #[tokio::test] + async fn test_add_lid_pn_mappings_deduplicates_and_is_durable_on_return() { + let client: Arc = create_test_client().await; + let phone = "5511900012345"; + let stale_lid = "200000000001234"; + let current_lid = "200000000001235"; + + let written = client + .add_lid_pn_mappings( + vec![ + (stale_lid.to_owned(), phone.to_owned()), + (current_lid.to_owned(), phone.to_owned()), + ], + LearningSource::Other, + ) + .await + .unwrap(); + + assert_eq!(written, 1); + let persisted = client + .persistence_manager + .backend() + .get_lid_mapping(current_lid) + .await + .unwrap() + .expect("mapping must be durable when the call returns"); + assert_eq!(persisted.phone_number, phone); + assert_eq!( + client.resolve_encryption_jid(&Jid::pn(phone)).await.user, + current_lid + ); + } + /// Online (`is_offline = false`) batch must persist the mapping to the /// backend AND run `migrate_device_registry_on_lid_discovery` for each /// newly learned PN. Polls until the detached task completes. @@ -2369,4 +2532,88 @@ mod tests { "second call finds the PN side already drained" ); } + + /// Identity cleanup still needs a durable flush, but cannot make a failed + /// session decrypt succeed and must not request a retry. + #[tokio::test] + async fn identity_only_migration_flushes_without_requesting_decrypt_retry() { + use wacore::types::jid::JidExt as _; + + let client: Arc = create_test_client().await; + let pn = "5500000002222"; + let lid = "133333333333333"; + let pn_addr = Jid::pn_device(pn.to_string(), 0).to_protocol_address(); + let lid_addr = Jid::lid_device(lid.to_string(), 0).to_protocol_address(); + let backend = client.persistence_manager.backend(); + + client.signal_cache.put_identity(&pn_addr, &[7; 32]).await; + client.signal_cache.put_identity(&lid_addr, &[8; 32]).await; + client.signal_cache.flush(backend.as_ref()).await.unwrap(); + + assert!( + !client + .migrate_signal_sessions_on_lid_discovery(pn, lid) + .await, + "discarding only the stale PN identity cannot help a decrypt retry" + ); + assert_eq!(backend.load_identity(pn_addr.as_str()).await.unwrap(), None); + assert_eq!( + backend.load_identity(lid_addr.as_str()).await.unwrap(), + Some([8; 32]), + "the destination identity must win and the cleanup must be durable" + ); + } + + #[tokio::test] + async fn lid_discovery_retries_pending_migration_flush() { + use wacore::libsignal::protocol::SessionRecord; + use wacore::types::jid::JidExt as _; + + let backend = Arc::new(InMemoryBackend::new()); + let client = create_test_client_with_backend(backend.clone()).await; + let pn = "5500000003333"; + let lid = "144444444444444"; + let pn_addr = Jid::pn_device(pn, 0).to_protocol_address(); + let lid_addr = Jid::lid_device(lid, 0).to_protocol_address(); + client + .signal_cache + .put_session( + &pn_addr, + SessionRecord::deserialize(&tagged_session_blob(9)).unwrap(), + ) + .await; + client.signal_cache.flush(backend.as_ref()).await.unwrap(); + + backend.set_fail_session_writes(true); + assert!( + client + .migrate_signal_sessions_on_lid_discovery(pn, lid) + .await, + "the first pass moved a session in memory" + ); + backend.set_fail_session_writes(false); + let attempts_before_retry = backend.session_batch_write_count(); + + assert!( + !client + .migrate_signal_sessions_on_lid_discovery(pn, lid) + .await, + "a durability retry must not request another decrypt attempt" + ); + assert!(backend.session_batch_write_count() > attempts_before_retry); + assert!( + backend + .get_session(pn_addr.as_str()) + .await + .unwrap() + .is_none() + ); + assert!( + backend + .get_session(lid_addr.as_str()) + .await + .unwrap() + .is_some() + ); + } } diff --git a/src/client/lifecycle.rs b/src/client/lifecycle.rs index 84b5dc2d9..4d7de6912 100644 --- a/src/client/lifecycle.rs +++ b/src/client/lifecycle.rs @@ -140,7 +140,7 @@ impl Client { transport_factory, noise_socket: Arc::new(Mutex::new(None)), - response_waiters: Arc::new(std::sync::Mutex::new(HashMap::new())), + response_waiters: Arc::new(std::sync::Mutex::new(ResponseWaiterMap::default())), node_waiters: std::sync::Mutex::new(Vec::new()), node_waiter_count: AtomicUsize::new(0), sent_node_waiters: std::sync::Mutex::new(Vec::new()), @@ -906,9 +906,10 @@ impl Client { let waiter_count = { let mut waiters_map = self.response_waiters_guard(); let count = waiters_map.len(); - // Replace with new map to release backing storage; old senders drop - // here, causing receivers to get RecvError → InternalChannelClosed. - *waiters_map = HashMap::new(); + // Release the backing storage while preserving the generation + // sequence; an old request guard may drop after reconnect and must + // not match a new waiter that reused the same explicit ID. + waiters_map.clear(); count }; if waiter_count > 0 { diff --git a/src/client/sessions.rs b/src/client/sessions.rs index 75398f741..5801bc78e 100644 --- a/src/client/sessions.rs +++ b/src/client/sessions.rs @@ -1,9 +1,13 @@ //! E2E Session management for Client. use anyhow::Result; +use rand::rngs::StdRng; use std::sync::Arc; use std::sync::atomic::Ordering; use std::time::Duration; +use wacore::libsignal::protocol::{ + IdentityChange, PreKeyBundle, SignalProtocolError, UsePQRatchet, process_prekey_bundle, +}; use wacore::libsignal::store::SessionStore; use wacore::types::jid::JidExt; use wacore_binary::Jid; @@ -12,6 +16,38 @@ use super::Client; use crate::types::events::{Event, OfflineSyncCompleted}; impl Client { + /// Install a supplied pre-key bundle into the shared Signal cache. + /// + /// The caller owns batching and the final durability flush. Keeping those + /// outside lets multi-device establishment reuse one adapter and one flush. + pub(crate) async fn install_prekey_bundle_cached( + &self, + jid: &Jid, + bundle: &PreKeyBundle, + adapter: &mut crate::store::signal_adapter::SignalProtocolStoreAdapter, + rng: &mut StdRng, + ) -> Result { + let signal_address = jid.to_protocol_address(); + let session_mutex = self.session_lock_for(signal_address.as_str()).await; + let session_guard = session_mutex.lock().await; + + let identity_change = process_prekey_bundle( + &signal_address, + &mut adapter.session_store, + &mut adapter.identity_store, + bundle, + rng, + UsePQRatchet::No, + ) + .await?; + + drop(session_guard); + if identity_change == IdentityChange::ReplacedExisting { + self.react_to_local_identity_change(jid); + } + Ok(identity_change) + } + /// WA Web: `WAWebOfflineResumeConst.OFFLINE_STANZA_TIMEOUT_MS = 60000` pub(crate) const DEFAULT_OFFLINE_SYNC_TIMEOUT: Duration = Duration::from_secs(60); @@ -338,9 +374,6 @@ impl Client { /// Returns the number of sessions successfully established. #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.session.fetch_establish", level = "debug", skip_all, fields(count = jids.len()), err(Debug)))] async fn fetch_and_establish_sessions(&self, jids: &[Jid]) -> Result { - use wacore::libsignal::protocol::{UsePQRatchet, process_prekey_bundle}; - use wacore::types::jid::JidExt; - if jids.is_empty() { return Ok(0); } @@ -350,6 +383,7 @@ impl Client { .await?; let mut adapter = self.signal_adapter().await; + let mut rng = rand::make_rng::(); let mut success_count = 0; let mut missing_count = 0; @@ -357,30 +391,13 @@ impl Client { for jid in jids { if let Some(bundle) = prekey_bundles.get(&jid.normalize_for_prekey_bundle()) { - let signal_addr = jid.to_protocol_address(); - - // Acquire per-sender session lock to prevent race with concurrent message decryption. - let session_mutex = self.session_lock_for(signal_addr.as_str()).await; - let _session_guard = session_mutex.lock().await; - - match process_prekey_bundle( - &signal_addr, - &mut adapter.session_store, - &mut adapter.identity_store, - bundle, - &mut rand::make_rng::(), - UsePQRatchet::No, - ) - .await + match self + .install_prekey_bundle_cached(jid, bundle, &mut adapter, &mut rng) + .await { - Ok(identity_change) => { + Ok(_) => { success_count += 1; log::debug!("Successfully established session with {}", jid.observe()); - if identity_change - == wacore::libsignal::protocol::IdentityChange::ReplacedExisting - { - self.react_to_local_identity_change(jid); - } } Err(e) => { failed_count += 1; diff --git a/src/client/voip.rs b/src/client/voip.rs index 432aafab9..9f68b63ba 100644 --- a/src/client/voip.rs +++ b/src/client/voip.rs @@ -83,12 +83,30 @@ pub enum CallError { impl Voip<'_> { /// Reject an incoming call. Fire-and-forget — no server response is expected. pub async fn reject(&self, incoming: &IncomingCall) -> Result<(), CallError> { - let call_id = incoming.action.call_id(); + self.reject_call( + incoming.action.call_id(), + &incoming.from, + incoming.action.call_creator(), + ) + .await + } + + /// Reject a call when its signaling identifiers are already available. + /// `peer` is the outer `` target, while `call_creator` is the + /// action's `call-creator` attribute; preserve them separately because + /// they may differ for companion-device signaling. + /// Fire-and-forget — no server response is expected. + pub async fn reject_call( + &self, + call_id: &str, + peer: &Jid, + call_creator: &Jid, + ) -> Result<(), CallError> { if call_id.is_empty() { return Err(CallError::EmptyCallId); } let id = self.client.generate_request_id(); - let stanza = build_reject(call_id, &incoming.from, incoming.action.call_creator(), &id); + let stanza = build_reject(call_id, peer, call_creator, &id); // Consume the ringing flag BEFORE the async send: a caller processed while we await // the send would otherwise hit take_ringing first and surface a phantom missed call for a call // we already declined (WA Web deletes it from _ringingCalls on reject). No-op if never ringing. @@ -226,6 +244,10 @@ mod tests { Jid::new("111111111111111", Server::Lid) } + fn call_creator() -> Jid { + Jid::new("222222222222222", Server::Lid) + } + fn incoming_reject() -> IncomingCall { IncomingCall::new_for_test( caller(), @@ -256,6 +278,41 @@ mod tests { assert_eq!(count.load(Ordering::SeqCst), 1); } + #[tokio::test] + async fn reject_call_sends_stanza_without_event_context() { + let (client, count) = make_client_with_count().await; + let waiter = client.wait_for_sent_node(crate::client::NodeFilter::tag("call")); + let peer = caller(); + let creator = call_creator(); + client + .voip() + .reject_call("CALL-ID-0001", &peer, &creator) + .await + .expect("reject should send"); + assert_eq!(count.load(Ordering::SeqCst), 1); + + let sent = waiter.await.expect("reject stanza should be observable"); + let call = sent.as_node_ref(); + assert_eq!( + call.attrs().optional_string("to").as_deref(), + Some(peer.to_string().as_str()) + ); + let reject = &call.children().expect("call action")[0]; + assert_eq!(reject.tag, "reject"); + assert_eq!( + reject.attrs().optional_string("call-id").as_deref(), + Some("CALL-ID-0001") + ); + assert_eq!( + reject.attrs().optional_string("call-creator").as_deref(), + Some(creator.to_string().as_str()) + ); + assert_eq!( + reject.attrs().optional_string("count").as_deref(), + Some("0") + ); + } + #[tokio::test] async fn terminate_sends_stanza() { let (client, count) = make_client_with_count().await; diff --git a/src/features/community.rs b/src/features/community.rs index 59fbcca73..98e93c9e5 100644 --- a/src/features/community.rs +++ b/src/features/community.rs @@ -7,12 +7,14 @@ use crate::client::Client; use crate::features::groups::GroupError; use crate::features::groups::GroupMetadata; use crate::features::groups::GroupParticipant; +use crate::features::groups::GroupParticipantOptions; +use crate::features::groups::ParticipantChangeResponse; use crate::features::mex::{MexError, mex_request}; use crate::request::IqError; use log::warn; use thiserror::Error; use wacore::iq::groups::{ - DeleteCommunityIq, GetLinkedGroupsParticipantsIq, GroupCreateIq, GroupCreateOptions, + CommunityParticipatingIq, DeleteCommunityIq, GetLinkedGroupsParticipantsIq, GroupCreateOptions, JoinLinkedGroupIq, LinkSubgroupsIq, QueryLinkedGroupIq, UnlinkSubgroupsIq, }; use wacore::iq::mex_operations::{fetch_all_subgroups, query_subgroup_participant_count}; @@ -93,6 +95,10 @@ pub struct CommunitySubgroup { pub id: Jid, pub subject: String, pub participant_count: Option, + /// Server-reported subgroup creation timestamp, when available. + pub creation: Option, + /// Server-reported subgroup creator, when available. + pub owner: Option, pub is_default_sub_group: bool, pub is_general_chat: bool, } @@ -158,11 +164,12 @@ impl<'a> Community<'a> { ..Default::default() }; - let group = self + let mut metadata = self .client - .execute(GroupCreateIq::new(create_options)) - .await?; - let mut metadata = GroupMetadata::from(group); + .groups() + .create_group(create_options) + .await? + .metadata; if let Some(desc_text) = description && let Ok(desc) = wacore::iq::groups::GroupDescription::new(&desc_text) @@ -177,6 +184,27 @@ impl<'a> Community<'a> { Ok(CreateCommunityResult { metadata }) } + /// Create a subgroup already linked to a parent group. + pub async fn create_subgroup( + &self, + name: impl Into, + participants: &[Jid], + parent_jid: impl Into, + ) -> Result { + let options = GroupCreateOptions { + subject: name.into(), + participants: participants + .iter() + .cloned() + .map(GroupParticipantOptions::new) + .collect(), + linked_parent: Some(parent_jid.into()), + ..Default::default() + }; + let metadata = self.client.groups().create_group(options).await?.metadata; + Ok(CreateCommunityResult { metadata }) + } + /// Deactivate (delete) a community. Subgroups are unlinked but not deleted. pub async fn deactivate(&self, community_jid: impl Into) -> Result<(), CommunityError> { let community_jid = &community_jid.into(); @@ -186,6 +214,19 @@ impl<'a> Community<'a> { Ok(()) } + /// Remove participants from the parent and all linked groups. + pub async fn remove_participants( + &self, + community_jid: impl Into, + participants: &[Jid], + ) -> Result, CommunityError> { + Ok(self + .client + .groups() + .remove_participants_including_linked_groups(community_jid, participants) + .await?) + } + /// Link existing groups as subgroups of a community. pub async fn link_subgroups( &self, @@ -294,6 +335,27 @@ impl<'a> Community<'a> { Ok(subgroups) } + /// Fetch all parent groups the account currently participates in. + pub async fn get_participating( + &self, + ) -> Result, CommunityError> { + let response = self.client.execute(CommunityParticipatingIq::new()).await?; + let mut result: std::collections::HashMap = response + .groups + .into_iter() + .map(|community| { + let id = community.id.clone(); + (id, GroupMetadata::from(community)) + }) + .collect(); + + for metadata in result.values_mut() { + self.client.groups().fill_participant_pns(metadata).await; + } + + Ok(result) + } + /// Fetch participant counts per subgroup via MEX (GraphQL). pub async fn get_subgroup_participant_counts( &self, @@ -388,6 +450,32 @@ impl<'a> Community<'a> { } } +fn json_u64(value: &serde_json::Value) -> Option { + value + .as_u64() + .or_else(|| value.as_str()?.parse::().ok()) +} + +fn json_jid(value: &serde_json::Value) -> Option { + if let Some(value) = value.as_str() { + return value.parse().ok(); + } + + let object = value.as_object()?; + ["id", "lid", "pn"] + .into_iter() + .filter_map(|field| object.get(field)?.as_str()) + .find_map(|value| value.parse().ok()) +} + +fn json_bool(value: &serde_json::Value) -> Option { + value.as_bool().or_else(|| match value.as_str()? { + "1" | "true" => Some(true), + "0" | "false" => Some(false), + _ => None, + }) +} + fn parse_subgroup_node(node: &serde_json::Value, is_default: bool) -> Option { let id_str = node.get("id")?.as_str()?; let jid: Jid = id_str.parse().ok()?; @@ -407,20 +495,33 @@ fn parse_subgroup_node(node: &serde_json::Value, is_default: bool) -> Option Contacts<'a> { &self, jid: &Jid, preview: bool, + ) -> Result, ContactError> { + self.get_profile_picture_with_timeout(jid, preview, None) + .await + } + + /// Fetch a profile picture with an optional request timeout override. + pub async fn get_profile_picture_with_timeout( + &self, + jid: &Jid, + preview: bool, + timeout: Option, ) -> Result, ContactError> { debug!( "get_profile_picture: fetching {} picture for {}", @@ -209,6 +221,9 @@ impl<'a> Contacts<'a> { ProfilePictureType::Full }; let mut spec = ProfilePictureSpec::new(jid, picture_type); + if let Some(timeout) = timeout { + spec = spec.with_timeout(timeout); + } // Skip own JID: server never responds when tctoken is sent for self let is_own_jid = { diff --git a/src/features/groups.rs b/src/features/groups.rs index 1e1a7e2d2..fbb99205b 100644 --- a/src/features/groups.rs +++ b/src/features/groups.rs @@ -15,10 +15,10 @@ use wacore::iq::groups::{ GetGroupInviteLinkIq, GetGroupProfilePicturesIq, GetMembershipRequestsIq, GroupCreateIq, GroupInfoOutcome, GroupInfoResponse, GroupParticipantResponse, GroupParticipatingIq, GroupQueryIq, LeaveGroupIq, MembershipRequestActionIq, PromoteParticipantsIq, - RemoveParticipantsIq, RevokeRequestCodeIq, SetAllowAdminReportsIq, SetGroupAnnouncementIq, - SetGroupDescriptionIq, SetGroupEphemeralIq, SetGroupHistoryIq, SetGroupLockedIq, - SetGroupMembershipApprovalIq, SetGroupSubjectIq, SetMemberAddModeIq, - SetNoFrequentlyForwardedIq, normalize_participants, + RemoveParticipantsIncludingLinkedGroupsIq, RemoveParticipantsIq, RevokeRequestCodeIq, + SetAllowAdminReportsIq, SetGroupAnnouncementIq, SetGroupDescriptionIq, SetGroupEphemeralIq, + SetGroupHistoryIq, SetGroupLockedIq, SetGroupMembershipApprovalIq, SetGroupSubjectIq, + SetMemberAddModeIq, SetNoFrequentlyForwardedIq, normalize_participants, }; use wacore::iq::mex_operations::update_group_property; use wacore::types::message::AddressingMode; @@ -26,10 +26,11 @@ use wacore_binary::{Jid, JidExt as _}; use wacore::iq::groups::BatchGroupInfoResult as RawBatchResult; pub use wacore::iq::groups::{ - GroupCreateOptions, GroupDescription, GroupEphemeralSettings, GroupJoinError, - GroupParticipantOptions, GroupProfilePicture, GroupSubject, GrowthLockInfo, InviteInfoError, - JoinGroupResult, MemberAddMode, MemberLinkMode, MemberShareHistoryMode, MembershipApprovalMode, - MembershipRequest, ParticipantChangeResponse, ParticipantType, PictureType, + GroupAppealStatus, GroupCreateOptions, GroupDescription, GroupEphemeralSettings, + GroupJoinError, GroupParticipantDetails, GroupParticipantOptions, GroupProfilePicture, + GroupSubject, GrowthLockInfo, InviteInfoError, JoinGroupResult, MemberAddMode, MemberLinkMode, + MemberShareHistoryMode, MembershipApprovalMode, MembershipRequest, ParticipantChangeResponse, + ParticipantType, PictureType, }; /// Error returned by group operations (metadata queries, participant and @@ -105,6 +106,10 @@ pub struct GroupMetadata { pub creator_country_code: Option, /// Group creation timestamp (Unix seconds). pub creation_time: Option, + pub participant_version_id: Option, + pub admin_version_id: Option, + pub open_thread_id: Option, + pub has_missing_participant_identification: bool, /// Subject modification timestamp (Unix seconds). pub subject_time: Option, /// Subject owner JID. @@ -137,6 +142,7 @@ pub struct GroupMetadata { pub size: Option, /// Whether this group is a community parent group. pub is_parent_group: bool, + pub parent_membership_approval_required: bool, /// JID of the parent community (for subgroups). pub parent_group_jid: Option, /// Whether this is the default announcement subgroup of a community. @@ -153,6 +159,10 @@ pub struct GroupMetadata { pub growth_locked: Option, /// Whether the group is suspended. pub is_suspended: bool, + pub suspension_can_auto_file: bool, + pub appeal_status: Option, + pub appeal_update_time: Option, + pub is_support_group: bool, /// Whether admin reports are allowed. pub allow_admin_reports: bool, /// Whether the group is hidden. @@ -161,8 +171,14 @@ pub struct GroupMetadata { pub is_incognito: bool, /// Whether group history is enabled. pub has_group_history: bool, + pub is_auto_add_disabled: bool, + pub has_capi: bool, + pub evolution_version: Option, + pub has_group_safety_check: bool, + pub participant_label_enabled: bool, /// Whether limit sharing is enabled. pub is_limit_sharing_enabled: bool, + pub limit_sharing_trigger: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -170,8 +186,9 @@ pub struct GroupParticipant { pub jid: Jid, pub phone_number: Option, pub lid: Option, - pub username: Option, + pub username: Option, pub participant_type: ParticipantType, + pub details: Option>, } impl GroupParticipant { @@ -192,6 +209,7 @@ impl From for GroupParticipant { lid: p.lid, username: p.username, participant_type: p.participant_type, + details: p.details, } } } @@ -209,6 +227,10 @@ impl From for GroupMetadata { creator_username: group.creator_username, creator_country_code: group.creator_country_code, creation_time: group.creation_time, + participant_version_id: group.participant_version_id, + admin_version_id: group.admin_version_id, + open_thread_id: group.open_thread_id, + has_missing_participant_identification: group.has_missing_participant_identification, subject_time: group.subject_time, subject_owner: group.subject_owner, subject_owner_pn: group.subject_owner_pn, @@ -227,6 +249,7 @@ impl From for GroupMetadata { member_link_mode: group.member_link_mode, size: group.size, is_parent_group: group.is_parent_group, + parent_membership_approval_required: group.parent_membership_approval_required, parent_group_jid: group.parent_group_jid, is_default_sub_group: group.is_default_sub_group, is_general_chat: group.is_general_chat, @@ -235,11 +258,21 @@ impl From for GroupMetadata { member_share_history_mode: group.member_share_history_mode, growth_locked: group.growth_locked, is_suspended: group.is_suspended, + suspension_can_auto_file: group.suspension_can_auto_file, + appeal_status: group.appeal_status, + appeal_update_time: group.appeal_update_time, + is_support_group: group.is_support_group, allow_admin_reports: group.allow_admin_reports, is_hidden_group: group.is_hidden_group, is_incognito: group.is_incognito, has_group_history: group.has_group_history, + is_auto_add_disabled: group.is_auto_add_disabled, + has_capi: group.has_capi, + evolution_version: group.evolution_version, + has_group_safety_check: group.has_group_safety_check, + participant_label_enabled: group.participant_label_enabled, is_limit_sharing_enabled: group.is_limit_sharing_enabled, + limit_sharing_trigger: group.limit_sharing_trigger, } } } @@ -254,6 +287,12 @@ pub struct Groups<'a> { client: &'a Client, } +#[derive(Clone, Copy)] +enum ParticipantRemovalScope { + Group, + LinkedGroups, +} + impl<'a> Groups<'a> { pub(crate) fn new(client: &'a Client) -> Self { Self { client } @@ -371,7 +410,7 @@ impl<'a> Groups<'a> { /// LID-addressed groups, so consumers keying data by PN would treat current /// members as absent. No-op outside LID-addressed groups or when the PN is /// already present; unknown mappings leave the participant untouched. - async fn fill_participant_pns(&self, meta: &mut GroupMetadata) { + pub(super) async fn fill_participant_pns(&self, meta: &mut GroupMetadata) { if meta.addressing_mode != AddressingMode::Lid { return; } @@ -586,6 +625,17 @@ impl<'a> Groups<'a> { .client .execute(RemoveParticipantsIq::new(jid, participants)) .await?; + self.apply_participant_removals(jid, &result, ParticipantRemovalScope::Group) + .await; + Ok(result) + } + + async fn apply_participant_removals( + &self, + jid: &Jid, + result: &[ParticipantChangeResponse], + scope: ParticipantRemovalScope, + ) { let accepted: Vec<&str> = result .iter() .filter(|r| r.is_ok()) @@ -593,19 +643,51 @@ impl<'a> Groups<'a> { .collect(); if !accepted.is_empty() { let group_cache = self.client.get_group_cache().await; - if let Some(info) = group_cache.get(jid).await { - let mut info = Arc::unwrap_or_clone(info); - info.remove_participants(&accepted); - self.client.persist_group_metadata(jid, &info).await; - group_cache.insert(jid.clone(), Arc::new(info)).await; - } else { - // Cache expired: can't patch in place, so drop the now-stale blob. - self.client.invalidate_persisted_group_metadata(jid).await; + match scope { + ParticipantRemovalScope::Group => { + if let Some(info) = group_cache.get(jid).await { + let mut info = Arc::unwrap_or_clone(info); + info.remove_participants(&accepted); + self.client.persist_group_metadata(jid, &info).await; + group_cache.insert(jid.clone(), Arc::new(info)).await; + } else { + // Cache expired: can't patch in place, so drop the now-stale blob. + self.client.invalidate_persisted_group_metadata(jid).await; + } + } + ParticipantRemovalScope::LinkedGroups => { + // The response carries no subgroup IDs, and the lean send + // cache intentionally stores no hierarchy. Invalidate the + // known parent here; the per-subgroup remove notifications + // carry the affected JIDs, patch their own cache entries, + // and rotate their sender-key chains without evicting + // unrelated groups. + group_cache.invalidate(jid).await; + self.client.invalidate_persisted_group_metadata(jid).await; + } } self.client .rotate_sender_key_on_participant_remove(jid, &accepted) .await; } + } + + /// Remove participants from a parent group and all of its linked groups. + pub async fn remove_participants_including_linked_groups( + &self, + jid: impl Into, + participants: &[Jid], + ) -> Result, GroupError> { + let jid = &jid.into(); + let result = self + .client + .execute(RemoveParticipantsIncludingLinkedGroupsIq::new( + jid, + participants, + )) + .await?; + self.apply_participant_removals(jid, &result, ParticipantRemovalScope::LinkedGroups) + .await; Ok(result) } @@ -613,7 +695,7 @@ impl<'a> Groups<'a> { &self, jid: impl Into, participants: &[Jid], - ) -> Result<(), GroupError> { + ) -> Result, GroupError> { let jid = &jid.into(); Ok(self .client @@ -625,7 +707,7 @@ impl<'a> Groups<'a> { &self, jid: impl Into, participants: &[Jid], - ) -> Result<(), GroupError> { + ) -> Result, GroupError> { let jid = &jid.into(); Ok(self .client @@ -1037,6 +1119,17 @@ impl<'a> Groups<'a> { group_jid: impl Into, label: impl Into, ) -> Result<(), GroupError> { + self.update_member_label_with_id(group_jid, label) + .await + .map(|_| ()) + } + + /// Set or clear the member label and return the sent message ID. + pub async fn update_member_label_with_id( + &self, + group_jid: impl Into, + label: impl Into, + ) -> Result { let group_jid = &group_jid.into(); if !group_jid.is_group() { return Err(GroupError::InvalidRequest(format!( @@ -1049,11 +1142,12 @@ impl<'a> Groups<'a> { // as an extra node — otherwise the member_label appdata/tag_reason attrs // never reach the wire. let (_edit, meta) = crate::send::infer_stanza_metadata(&msg); + let message_id = self.client.generate_message_id(); self.client .send_message_impl( group_jid.clone(), &msg, - None, + Some(message_id.clone()), false, false, None, @@ -1061,7 +1155,7 @@ impl<'a> Groups<'a> { None, ) .await?; - Ok(()) + Ok(message_id) } async fn resolve_participant_tokens(&self, jids: &[Jid]) -> Vec { @@ -1274,6 +1368,7 @@ mod tests { lid: None, username: None, participant_type: ParticipantType::Admin, + details: None, }], ..Default::default() }; @@ -1307,6 +1402,7 @@ mod tests { lid: None, username: None, participant_type: ParticipantType::Member, + details: None, }], addressing_mode: AddressingMode::Lid, ..Default::default() @@ -1333,6 +1429,7 @@ mod tests { lid: None, username: None, participant_type: ParticipantType::Member, + details: None, }], addressing_mode: AddressingMode::Pn, ..Default::default() @@ -1439,6 +1536,40 @@ mod tests { assert_eq!(a.participants.len(), 2); } + #[tokio::test] + async fn linked_removal_preserves_unrelated_group_cache_entries() { + use wacore::protocol::ProtocolNode; + use wacore_binary::builder::NodeBuilder; + + let client = crate::test_utils::create_test_client().await; + let parent: Jid = "120363000000000001@g.us".parse().unwrap(); + let unrelated: Jid = "120363000000000002@g.us".parse().unwrap(); + let removed: Jid = "15550000001@s.whatsapp.net".parse().unwrap(); + let cache = client.get_group_cache().await; + for jid in [&parent, &unrelated] { + cache + .insert( + jid.clone(), + Arc::new(GroupInfo::new(vec![removed.clone()], AddressingMode::Pn)), + ) + .await; + } + let response = ParticipantChangeResponse::try_from_node( + &NodeBuilder::new("participant") + .attr("jid", &removed) + .build(), + ) + .expect("participant response should parse"); + + client + .groups() + .apply_participant_removals(&parent, &[response], ParticipantRemovalScope::LinkedGroups) + .await; + + assert!(cache.get(&parent).await.is_none()); + assert!(cache.get(&unrelated).await.is_some()); + } + #[tokio::test] async fn invalidate_persisted_group_metadata_drops_blob() { // The cache-miss branch of add/remove/leave relies on this to drop a now-stale diff --git a/src/features/mex.rs b/src/features/mex.rs index 9de9385b0..8290460f1 100644 --- a/src/features/mex.rs +++ b/src/features/mex.rs @@ -7,10 +7,12 @@ use crate::request::IqError; use serde::Serialize; use thiserror::Error; use wacore::iq::mex::MexQuerySpec; +use wacore::iq::mex_operations::fetch_reachout_timelock; use wacore_binary::jid::JidError; // Re-export types from wacore pub use wacore::iq::mex::{MexDoc, MexErrorExtensions, MexGraphQLError, MexResponse}; +pub use wacore::iq::mex_operations::fetch_reachout_timelock::Xwa2FetchAccountReachoutTimelock as ReachoutTimelock; /// Error types for MEX operations. #[derive(Debug, Error)] @@ -113,6 +115,12 @@ impl<'a> Mex<'a> { self.execute_request(request).await } + /// Fetch the account's current reachout-timelock state. + pub async fn fetch_reachout_timelock(&self) -> Result { + let response = self.query(mex_request!(fetch_reachout_timelock {})).await?; + decode_reachout_timelock(response.data) + } + #[inline] async fn execute_request( &self, @@ -142,6 +150,18 @@ impl<'a> Mex<'a> { } } +fn decode_reachout_timelock(data: Option) -> Result { + let data = data.ok_or_else(|| { + MexError::PayloadParsing("reachout timelock response missing data".into()) + })?; + let response: fetch_reachout_timelock::Response = serde_json::from_value(data)?; + response + .xwa2_fetch_account_reachout_timelock + .ok_or_else(|| { + MexError::PayloadParsing("reachout timelock response missing account state".into()) + }) +} + impl Client { #[inline] pub fn mex(&self) -> Mex<'_> { @@ -276,6 +296,32 @@ mod tests { assert_eq!(users[0]["jid"], "551199887766@s.whatsapp.net"); } + #[test] + fn test_reachout_timelock_response() { + let result = decode_reachout_timelock(Some(json!({ + "xwa2_fetch_account_reachout_timelock": { + "is_active": true, + "time_enforcement_ends": "1770000000", + "enforcement_type": "DEFAULT" + } + }))) + .expect("reachout payload"); + + assert_eq!(result.is_active, Some(true)); + assert_eq!(result.time_enforcement_ends.as_deref(), Some("1770000000")); + assert_eq!(result.enforcement_type.as_deref(), Some("DEFAULT")); + assert!(matches!( + decode_reachout_timelock(None), + Err(MexError::PayloadParsing(_)) + )); + assert!(matches!( + decode_reachout_timelock(Some(json!({ + "xwa2_fetch_account_reachout_timelock": null + }))), + Err(MexError::PayloadParsing(_)) + )); + } + #[test] fn test_mex_error_extensions_all_fields() { let json_str = r#"{ diff --git a/src/features/mod.rs b/src/features/mod.rs index f5087af4c..42b79ff06 100644 --- a/src/features/mod.rs +++ b/src/features/mod.rs @@ -43,12 +43,12 @@ pub use contacts::{ pub use events::{EventCreationParams, EventResponseType, Events}; pub use groups::{ - BatchGroupResult, CreateGroupResult, GroupCreateOptions, GroupDescription, + BatchGroupResult, CreateGroupResult, GroupAppealStatus, GroupCreateOptions, GroupDescription, GroupEphemeralSettings, GroupError, GroupJoinError, GroupMetadata, GroupParticipant, - GroupParticipantOptions, GroupProfilePicture, GroupSubject, Groups, GrowthLockInfo, - InviteInfoError, JoinGroupResult, MemberAddMode, MemberLinkMode, MemberShareHistoryMode, - MembershipApprovalMode, MembershipRequest, ParticipantChangeResponse, ParticipantType, - PictureType, + GroupParticipantDetails, GroupParticipantOptions, GroupProfilePicture, GroupSubject, Groups, + GrowthLockInfo, InviteInfoError, JoinGroupResult, MemberAddMode, MemberLinkMode, + MemberShareHistoryMode, MembershipApprovalMode, MembershipRequest, ParticipantChangeResponse, + ParticipantType, PictureType, }; pub use labels::Labels; @@ -59,7 +59,9 @@ pub use media_reupload::{ pub use message_edit::{EncryptedEdit, SecretEncKind, SecretEncrypted}; -pub use mex::{Mex, MexError, MexErrorExtensions, MexGraphQLError, MexRequest, MexResponse}; +pub use mex::{ + Mex, MexError, MexErrorExtensions, MexGraphQLError, MexRequest, MexResponse, ReachoutTimelock, +}; pub use newsletter::{ Newsletter, NewsletterError, NewsletterMessage, NewsletterMessageType, NewsletterMetadata, @@ -74,7 +76,7 @@ pub use profile::{Profile, ProfileError, SetProfilePictureResponse}; pub use status::{Status, StatusPrivacySetting, StatusSendOptions}; -pub use signal::{Signal, SignalError}; +pub use signal::{Signal, SignalError, SignalSessionInfo, SignalSessionMigration}; pub use wacore::message_processing::EncType; pub use tctoken::{TcToken, TcTokenError}; diff --git a/src/features/rotate_key.rs b/src/features/rotate_key.rs index 3e05b518c..a62c65127 100644 --- a/src/features/rotate_key.rs +++ b/src/features/rotate_key.rs @@ -71,7 +71,7 @@ impl Client { } if should_rotate_signed_pre_key(last, now) { - self.rotate_signed_pre_key().await?; + self.rotate_signed_pre_key_inner().await?; } Ok(()) } @@ -87,10 +87,14 @@ impl Client { /// ambiguous transport error (the server may have accepted `new_id`) leaves /// the staged key decryptable via the load fallback; a definitive rejection /// just leaves the current key in place to retry — never advancing the - /// cadence or pruning the key the server still hands out. A single-flight - /// lock ([`maybe_rotate_signed_pre_key`]) keeps overlapping tasks from - /// racing this sequence. - pub(crate) async fn rotate_signed_pre_key(&self) -> Result<(), anyhow::Error> { + /// cadence or pruning the key the server still hands out. Calls are + /// serialized with the automatic rotation path. + pub async fn rotate_signed_pre_key(&self) -> Result<(), anyhow::Error> { + let _guard = self.signed_pre_key_rotation_lock.lock().await; + self.rotate_signed_pre_key_inner().await + } + + async fn rotate_signed_pre_key_inner(&self) -> Result<(), anyhow::Error> { let snapshot = self.persistence_manager.get_device_snapshot(); let now = wacore::time::now_millis(); let backend = self.persistence_manager.backend(); @@ -170,52 +174,53 @@ impl Client { .map_err(|e| anyhow::anyhow!("failed to retain old signed pre-key: {e}"))?; // WA Web reads 406 = bad key, 409 = server validation fail, >=500 = - // transient; none warrant hard-failing login or advancing local state. - // On any failure the staged candidate stays put for the next retry. - match self + // transient; none advance local state or fail the automatic login path. + // Deterministic rejections discard the candidate; retryable and + // ambiguous failures retain it verbatim for the next attempt. + let upload_result = self .execute(RotateSignedPreKeySpec::new( new_id, new_kp.public_key, signature.to_vec(), )) - .await - { + .await; + match upload_result { Ok(()) => {} - Err(IqError::ServerError { code, text, .. }) => { - // WA Web treats 406 (bad key) and 409 (validation fail) as - // deterministic rejections of THIS key; reusing the staged - // candidate on retry would then wedge rotation forever (old_id - // never advances, so new_id is recomputed the same). Drop it to - // force a fresh mint — and REQUIRE the cleanup: if the remove - // fails, propagate so we never silently leave the rejected key - // staged. Every other code (rate limits, transient 5xx, …) is - // retryable, so keep the staged key for a plain retry. - if code == 406 || code == 409 { - backend.remove_signed_prekey(new_id).await.map_err(|e| { - anyhow::anyhow!( - "failed to drop rejected staged signed pre-key {new_id}: {e}" - ) - })?; - log::warn!( - "signed pre-key rotation rejected (code={code}, text='{text}'); \ - discarded the rejected key, will remint on a later connect" - ); + Err(error) => { + if let IqError::ServerError { code, text, .. } = &error { + // WA Web treats 406 (bad key) and 409 (validation fail) as + // deterministic rejections of THIS key; reusing the staged + // candidate on retry would then wedge rotation forever (old_id + // never advances, so new_id is recomputed the same). Drop it to + // force a fresh mint — and REQUIRE the cleanup: if the remove + // fails, propagate so we never silently leave the rejected key + // staged. Every other code (rate limits, transient 5xx, …) is + // retryable, so keep the staged key for a plain retry. + if *code == 406 || *code == 409 { + backend.remove_signed_prekey(new_id).await.map_err(|e| { + anyhow::anyhow!( + "failed to drop rejected staged signed pre-key {new_id}: {e}" + ) + })?; + log::warn!( + "signed pre-key rotation rejected (code={code}, text='{text}'); \ + discarded the rejected key, will remint on a later connect" + ); + } else { + log::warn!( + "signed pre-key rotation upload rejected (code={code}, text='{text}'); \ + keeping the staged key, will retry on a later connect" + ); + } } else { + // Ambiguous transport failure: the server may have accepted the + // key, so keep the staged candidate and reuse it on retry. log::warn!( - "signed pre-key rotation upload rejected (code={code}, text='{text}'); \ + "signed pre-key rotation upload failed: {error:?}; \ keeping the staged key, will retry on a later connect" ); } - return Ok(()); - } - Err(e) => { - // Ambiguous transport failure: the server may have accepted the - // key, so keep the staged candidate and reuse it on retry. - log::warn!( - "signed pre-key rotation upload failed: {e:?}; \ - keeping the staged key, will retry on a later connect" - ); - return Ok(()); + return Err(error.into()); } } @@ -299,4 +304,77 @@ mod tests { // At and beyond the 24-bit border, wrap back to 1. assert_eq!(next_signed_pre_key_id(MAX_SIGNED_PRE_KEY_ID), 1); } + + #[tokio::test] + async fn public_rotation_uses_the_single_flight_lock() { + let client = crate::test_utils::create_test_client().await; + let guard = client.signed_pre_key_rotation_lock.lock().await; + + assert!( + tokio::time::timeout( + std::time::Duration::from_millis(100), + client.rotate_signed_pre_key(), + ) + .await + .is_err(), + "manual rotation must serialize with an active rotation" + ); + drop(guard); + } + + #[tokio::test] + async fn due_automatic_rotation_does_not_relock_its_single_flight_guard() { + let client = crate::test_utils::create_test_client().await; + let snapshot = client.persistence_manager.get_device_snapshot(); + let staged_id = next_signed_pre_key_id(snapshot.signed_pre_key_id); + let due_baseline = + wacore::time::now_millis().saturating_sub(SIGNED_PRE_KEY_ROTATION_INTERVAL_MS); + client + .persistence_manager + .process_command(DeviceCommand::SetSignedPreKeyRotationBaseline(due_baseline)) + .await; + client + .persistence_manager + .flush() + .await + .expect("persist due rotation baseline"); + + let error = tokio::time::timeout( + std::time::Duration::from_secs(5), + client.maybe_rotate_signed_pre_key(), + ) + .await + .expect("automatic rotation must not recursively acquire its held lock") + .expect_err("the disconnected test client must fail at upload"); + assert!( + error + .downcast_ref::() + .is_some_and(|error| matches!(error, IqError::NotConnected)) + ); + assert!( + client + .persistence_manager + .backend() + .load_signed_prekey(staged_id) + .await + .expect("load staged signed pre-key") + .is_some(), + "the due path must reach the inner rotation flow before upload" + ); + } + + #[tokio::test] + async fn public_rotation_reports_upload_failure() { + let client = crate::test_utils::create_test_client().await; + + let error = client + .rotate_signed_pre_key() + .await + .expect_err("manual rotation must report a failed upload"); + assert!( + error + .downcast_ref::() + .is_some_and(|error| matches!(error, IqError::NotConnected)) + ); + } } diff --git a/src/features/signal.rs b/src/features/signal.rs index ee2cfc18c..4f987ac38 100644 --- a/src/features/signal.rs +++ b/src/features/signal.rs @@ -4,8 +4,10 @@ use thiserror::Error; use wacore::libsignal::protocol::{ - CiphertextMessage, PreKeySignalMessage, SenderKeyStore, SignalMessage, SignalProtocolError, - UsePQRatchet, message_decrypt, message_encrypt, + CiphertextMessage, DecryptionResult, IdentityChange, PreKeyBundle, PreKeySignalMessage, + PublicKey, SENDERKEY_MESSAGE_CURRENT_VERSION, SenderKeyDistributionMessage, SenderKeyStore, + SignalMessage, SignalProtocolError, UsePQRatchet, message_decrypt, message_encrypt, + process_sender_key_distribution_message, }; use wacore::message_processing::EncType; use wacore::messages::MessageUtils; @@ -26,11 +28,98 @@ pub enum SignalError { /// or message-secret envelope passed to the pairwise decrypt path). #[error("unsupported signal operation: {0}")] Unsupported(String), + /// The operation is supported but one of its inputs is malformed. + #[error("invalid signal input: {0}")] + InvalidInput(String), /// Catch-all for internal failures (device resolution, cache flush). #[error(transparent)] Internal(#[from] anyhow::Error), } +/// Read-only information from a currently open pairwise session. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SignalSessionInfo { + /// Local base key identifying the active session state. + pub base_key: Vec, + /// Remote registration identifier recorded by the session. + pub registration_id: u32, +} + +/// Result of moving pairwise session state between address namespaces. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct SignalSessionMigration { + /// Pairwise sessions moved to the destination namespace. + pub migrated: usize, + /// Pairwise session lookups skipped after a storage error. + pub skipped: usize, + /// Pairwise sessions found or unsuccessfully queried. + pub total: usize, + /// Identity records moved when the destination had no identity. + pub migrated_identities: usize, + /// Source identity records removed in favor of an existing destination. + pub discarded_identities: usize, + /// Identity lookups skipped after a storage error. + pub skipped_identities: usize, +} + +impl SignalSessionMigration { + /// Whether any source state was moved or removed. + pub fn has_state_changes(self) -> bool { + self.migrated != 0 || self.migrated_identities != 0 || self.discarded_identities != 0 + } +} + +fn decode_sender_key_distribution( + bytes: &[u8], +) -> Result { + match SenderKeyDistributionMessage::try_from(bytes) { + Ok(message) => Ok(message), + Err(primary_error) => { + let fallback = waproto::codec::sender_key_distribution_message_decode(bytes) + .map_err(|fallback_error| { + SignalError::InvalidInput(format!( + "sender-key distribution decode failed: primary={primary_error}; fallback={fallback_error}" + )) + })?; + let signing_key = fallback.signing_key.ok_or_else(|| { + SignalError::InvalidInput("sender-key distribution is missing signing_key".into()) + })?; + let id = fallback.id.ok_or_else(|| { + SignalError::InvalidInput("sender-key distribution is missing id".into()) + })?; + let iteration = fallback.iteration.ok_or_else(|| { + SignalError::InvalidInput("sender-key distribution is missing iteration".into()) + })?; + let chain_key: [u8; 32] = fallback + .chain_key + .ok_or_else(|| { + SignalError::InvalidInput("sender-key distribution is missing chain_key".into()) + })? + .try_into() + .map_err(|value: Vec| { + SignalError::InvalidInput(format!( + "sender-key distribution chain_key must be 32 bytes, got {}", + value.len() + )) + })?; + let signing_key = + PublicKey::from_djb_public_key_bytes(&signing_key).map_err(|error| { + SignalError::InvalidInput(format!( + "sender-key distribution signing_key is invalid: {error}" + )) + })?; + Ok(SenderKeyDistributionMessage::new( + SENDERKEY_MESSAGE_CURRENT_VERSION, + id, + iteration, + chain_key, + signing_key, + )?) + } + } +} + /// Feature handle for Signal protocol operations. pub struct Signal<'a> { client: &'a Client, @@ -41,35 +130,277 @@ impl<'a> Signal<'a> { Self { client } } + async fn session_info_at(&self, jid: &Jid) -> Result, SignalError> { + let address = jid.to_protocol_address(); + let session_mutex = self.client.session_lock_for(address.as_str()).await; + let _session_guard = session_mutex.lock().await; + let device = self.client.persistence_manager.get_device_snapshot(); + let Some(session) = self + .client + .signal_cache + .peek_session(&address, &*device.backend) + .await? + else { + return Ok(None); + }; + let base_key = session.alice_base_key()?; + let registration_id = session.remote_registration_id()?; + Ok(Some(SignalSessionInfo { + base_key: base_key.to_vec(), + registration_id, + })) + } + + /// Move a legacy source-namespace session only after a resolved lookup + /// misses. Successful steady-state operations therefore pay no migration + /// preflight, while startup state stored under PN remains recoverable. + async fn migrate_legacy_pairwise_state( + &self, + source: &Jid, + resolved: &Jid, + ) -> Result { + if source.server == resolved.server { + return Ok(false); + } + Ok(self.migrate_sessions(source, resolved).await?.migrated != 0) + } + + async fn encrypt_pairwise_at( + &self, + jid: &Jid, + plaintext: &[u8], + ) -> Result { + let address = jid.to_protocol_address(); + let lock = self.client.session_lock_for(address.as_str()).await; + let _guard = lock.lock().await; + let mut adapter = self.client.signal_adapter().await; + Ok(message_encrypt( + plaintext, + &address, + &mut adapter.session_store, + &mut adapter.identity_store, + ) + .await?) + } + + async fn decrypt_pairwise_at( + &self, + jid: &Jid, + parsed: &CiphertextMessage, + ) -> Result { + let address = jid.to_protocol_address(); + let lock = self.client.session_lock_for(address.as_str()).await; + let _guard = lock.lock().await; + let mut adapter = self.client.signal_adapter().await; + let mut rng = rand::make_rng::(); + let decrypted = message_decrypt( + parsed, + &address, + &mut adapter.session_store, + &mut adapter.identity_store, + &mut adapter.pre_key_store, + &adapter.signed_pre_key_store, + &mut rng, + UsePQRatchet::No, + ) + .await?; + + // A pkmsg consumed prekey is reported, not deleted by the decrypt; + // buffer it so the caller's flush removes it atomically with the + // promoted session. + if let Some(prekey_id) = decrypted.consumed_prekey_id { + adapter + .pre_key_store + .buffer_consumed_prekey(prekey_id, &address) + .await; + } + Ok(decrypted) + } + + async fn delete_pairwise_state_at(&self, jid: &Jid) { + let address = jid.to_protocol_address(); + let lock = self.client.session_lock_for(address.as_str()).await; + let _guard = lock.lock().await; + self.client.signal_cache.delete_session(&address).await; + self.client.signal_cache.delete_identity(&address).await; + } + + /// Install a supplied pairwise pre-key bundle and durably expose the new + /// session before returning. + pub async fn install_prekey_bundle( + &self, + jid: &Jid, + bundle: &PreKeyBundle, + ) -> Result { + let resolved = self.client.resolve_encryption_jid(jid).await; + let mut adapter = self.client.signal_adapter().await; + let mut rng = rand::make_rng::(); + let identity_change = self + .client + .install_prekey_bundle_cached(&resolved, bundle, &mut adapter, &mut rng) + .await?; + self.client.flush_signal_cache_batch_safe().await?; + Ok(identity_change) + } + + /// Process a sender-key distribution and durably expose it before return. + pub async fn process_sender_key_distribution( + &self, + group_jid: &Jid, + sender_jid: &Jid, + distribution: &[u8], + ) -> Result<(), SignalError> { + self.process_sender_key_distribution_cached(group_jid, sender_jid, distribution) + .await?; + self.client.flush_signal_cache_batch_safe().await?; + Ok(()) + } + + /// Cache-only variant for the inbound message pipeline, whose enclosing + /// commit owns the batched durability flush. + pub(crate) async fn process_sender_key_distribution_cached( + &self, + group_jid: &Jid, + sender_jid: &Jid, + distribution: &[u8], + ) -> Result<(), SignalError> { + let distribution = decode_sender_key_distribution(distribution)?; + let sender_address = sender_jid.to_non_ad().to_protocol_address(); + let sender_key_name = make_sender_key_name(group_jid, &sender_address); + let mut store = self.client.sender_key_adapter().await; + let chain_lock = store.sender_key_lock(&sender_key_name).await; + let chain_guard = chain_lock.lock().await; + + process_sender_key_distribution_message(&sender_key_name, &distribution, &mut store) + .await?; + drop(chain_guard); + Ok(()) + } + + /// Create the current sender-key distribution for a group. + pub async fn sender_key_distribution( + &self, + group_jid: &Jid, + sender_jid: &Jid, + ) -> Result, SignalError> { + let sender_address = sender_jid.to_non_ad().to_protocol_address(); + let sender_key_name = make_sender_key_name(group_jid, &sender_address); + let mut store = self.client.sender_key_adapter().await; + let chain_lock = store.sender_key_lock(&sender_key_name).await; + let chain_guard = chain_lock.lock().await; + let distribution = wacore::send::create_sender_key_distribution_message_for_group( + &mut store, + &sender_key_name, + ) + .await?; + drop(chain_guard); + self.client.persist_signal_state_pre_wire().await?; + Ok(distribution) + } + + /// Check whether sender-key state exists for a group and sender. + pub async fn has_sender_key( + &self, + group_jid: &Jid, + sender_jid: &Jid, + ) -> Result { + let sender_address = sender_jid.to_non_ad().to_protocol_address(); + let sender_key_name = make_sender_key_name(group_jid, &sender_address); + let device = self.client.persistence_manager.get_device_snapshot(); + Ok(self + .client + .signal_cache + .get_sender_key(&sender_key_name, &*device.backend) + .await? + .is_some()) + } + + /// Delete one sender-key chain and make the removal durable before returning. + pub async fn delete_sender_key( + &self, + group_jid: &Jid, + sender_jid: &Jid, + ) -> Result<(), SignalError> { + let sender_address = sender_jid.to_non_ad().to_protocol_address(); + let sender_key_name = make_sender_key_name(group_jid, &sender_address); + let backend = self.client.persistence_manager.backend(); + self.client + .signal_cache + .delete_sender_key_durable(&sender_key_name, backend.as_ref()) + .await?; + Ok(()) + } + + /// Inspect the currently open pairwise session for a JID. + pub async fn session_info(&self, jid: &Jid) -> Result, SignalError> { + let resolved = self.client.resolve_encryption_jid(jid).await; + let info = self.session_info_at(&resolved).await?; + if info.is_some() || !self.migrate_legacy_pairwise_state(jid, &resolved).await? { + return Ok(info); + } + self.session_info_at(&resolved).await + } + + /// Move pairwise session state from a phone-number namespace to its linked + /// identifier namespace across known device slots. + pub async fn migrate_sessions( + &self, + from: &Jid, + to: &Jid, + ) -> Result { + if !matches!( + (from.server, to.server), + (wacore_binary::Server::Pn, wacore_binary::Server::Lid) + | ( + wacore_binary::Server::Hosted, + wacore_binary::Server::HostedLid + ) + ) { + return Err(SignalError::InvalidInput( + "source and destination must be matching phone and linked-identifier namespaces" + .into(), + )); + } + let outcome = self.client.migrate_signal_sessions(from, to).await; + if outcome.has_state_changes() + || self + .client + .signal_cache + .has_pending_pairwise_writes_for_user(&from.user) + .await + { + self.client.flush_signal_cache_batch_safe().await?; + } + Ok(outcome) + } + /// Encrypt plaintext for a single recipient using the Signal protocol. /// /// Returns `(EncType, ciphertext_bytes)`. The caller is responsible /// for padding if needed; this method encrypts raw bytes. /// - /// PN JIDs are resolved to LID when a LID session exists, matching - /// the internal send path. + /// PN JIDs are resolved to LID, with a legacy PN session migrated lazily + /// if the resolved lookup misses. pub async fn encrypt_message( &self, jid: &Jid, plaintext: &[u8], ) -> Result<(EncType, Vec), SignalError> { - // Resolve PN→LID to use the correct Signal session (matches send path) let encryption_jid = self.client.resolve_encryption_jid(jid).await; - let signal_addr = encryption_jid.to_protocol_address(); - - let lock = self.client.session_lock_for(signal_addr.as_str()).await; - let _guard = lock.lock().await; - let mut adapter = self.client.signal_adapter().await; - - let encrypted = message_encrypt( - plaintext, - &signal_addr, - &mut adapter.session_store, - &mut adapter.identity_store, - ) - .await?; + let encrypted = match self.encrypt_pairwise_at(&encryption_jid, plaintext).await { + Ok(encrypted) => encrypted, + Err(error @ SignalError::Protocol(SignalProtocolError::SessionNotFound(_))) => { + if !self + .migrate_legacy_pairwise_state(jid, &encryption_jid) + .await? + { + return Err(error); + } + self.encrypt_pairwise_at(&encryption_jid, plaintext).await? + } + Err(error) => return Err(error), + }; - drop(_guard); // Same pre-wire gate as the send path: the caller transmits these // bytes, so a raised lease must be durable before they leave here. self.client.persist_signal_state_pre_wire().await?; @@ -89,8 +420,8 @@ impl<'a> Signal<'a> { /// Returns raw padded plaintext. Use [`MessageUtils::unpad_message_ref`] /// with the stanza's `v` attribute if WhatsApp message unpadding is needed. /// - /// PN JIDs are resolved to LID when a LID session exists, matching - /// the internal receive path. + /// PN JIDs are resolved to LID, with a legacy PN session migrated lazily + /// if the resolved lookup misses. pub async fn decrypt_message( &self, jid: &Jid, @@ -117,35 +448,20 @@ impl<'a> Signal<'a> { }; let encryption_jid = self.client.resolve_encryption_jid(jid).await; - let signal_addr = encryption_jid.to_protocol_address(); - - let lock = self.client.session_lock_for(signal_addr.as_str()).await; - let _guard = lock.lock().await; - let mut adapter = self.client.signal_adapter().await; - let mut rng = rand::make_rng::(); - - let decrypted = message_decrypt( - &parsed, - &signal_addr, - &mut adapter.session_store, - &mut adapter.identity_store, - &mut adapter.pre_key_store, - &adapter.signed_pre_key_store, - &mut rng, - UsePQRatchet::No, - ) - .await?; - - // A pkmsg consumed prekey is reported, not deleted by the decrypt; buffer - // it so the flush below removes it atomically with the promoted session. - if let Some(prekey_id) = decrypted.consumed_prekey_id { - adapter - .pre_key_store - .buffer_consumed_prekey(prekey_id, &signal_addr) - .await; - } + let decrypted = match self.decrypt_pairwise_at(&encryption_jid, &parsed).await { + Ok(decrypted) => decrypted, + Err(error @ SignalError::Protocol(SignalProtocolError::SessionNotFound(_))) => { + if !self + .migrate_legacy_pairwise_state(jid, &encryption_jid) + .await? + { + return Err(error); + } + self.decrypt_pairwise_at(&encryption_jid, &parsed).await? + } + Err(error) => return Err(error), + }; - drop(_guard); self.client.flush_signal_cache_batch_safe().await?; Ok(decrypted.plaintext) @@ -154,10 +470,10 @@ impl<'a> Signal<'a> { /// Encrypt plaintext for a group using sender keys. /// /// Returns `(Option, ciphertext_bytes)`. The SKDM is `Some` - /// only when a new sender key was created (first encrypt for this group - /// or after key rotation). Callers must distribute the SKDM to all group - /// participants when present. This matches WA Web which only creates - /// SKDM on first group encrypt or after sender key rotation. + /// while a new sender key still requires distribution (first encrypt for + /// this group, after key rotation, or a retry whose earlier durability + /// gate failed). Callers must distribute the SKDM to all group participants + /// when present. /// /// Concurrent calls for the same `(group, sender)` are serialized on the /// sender-key chain, so the SKDM and the skmsg can't be split across keys. @@ -187,34 +503,50 @@ impl<'a> Signal<'a> { .await? .is_some(); - let mut adapter = self.client.signal_adapter().await; + let mut store = self.client.sender_key_adapter().await; let mut rng = rand::make_rng::(); - let skdm_bytes = if !key_exists { - Some( - wacore::send::create_sender_key_distribution_message_for_group( - &mut adapter.sender_key_store, + let pending_distribution = self + .client + .signal_cache + .pending_sender_key_distribution(&sender_key_name) + .await; + let skdm_bytes = if let Some(distribution) = pending_distribution { + Some(distribution.as_ref().to_vec()) + } else if !key_exists { + let distribution = wacore::send::create_sender_key_distribution_message_for_group( + &mut store, + &sender_key_name, + ) + .await?; + self.client + .signal_cache + .cache_pending_sender_key_distribution( &sender_key_name, + std::sync::Arc::from(distribution.clone()), ) - .await?, - ) + .await; + Some(distribution) } else { None }; - let ciphertext = wacore::send::encrypt_group_message( - &mut adapter.sender_key_store, - &sender_key_name, - plaintext, - &mut rng, - ) - .await?; + let ciphertext = + wacore::send::encrypt_group_message(&mut store, &sender_key_name, plaintext, &mut rng) + .await?; // The durability gate can need the processing permit, whose holder may // need this chain lock. drop(_chain_guard); self.client.persist_signal_state_pre_wire().await?; + if let Some(distribution) = &skdm_bytes { + self.client + .signal_cache + .clear_pending_sender_key_distribution(&sender_key_name, distribution) + .await; + } + Ok((skdm_bytes, ciphertext.into_serialized().into_vec())) } @@ -233,19 +565,13 @@ impl<'a> Signal<'a> { let sender_key_name = make_sender_key_name(group_jid, &sender_jid.to_non_ad().to_protocol_address()); - let mut adapter = self.client.signal_adapter().await; - let chain_lock = adapter - .sender_key_store - .sender_key_lock(&sender_key_name) - .await; + let mut store = self.client.sender_key_adapter().await; + let chain_lock = store.sender_key_lock(&sender_key_name).await; let _chain_guard = chain_lock.lock().await; - let plaintext = wacore::libsignal::protocol::group_decrypt( - ciphertext, - &mut adapter.sender_key_store, - &sender_key_name, - ) - .await?; + let plaintext = + wacore::libsignal::protocol::group_decrypt(ciphertext, &mut store, &sender_key_name) + .await?; drop(_chain_guard); self.client.flush_signal_cache_batch_safe().await?; @@ -261,6 +587,15 @@ impl<'a> Signal<'a> { let resolved = self.client.resolve_encryption_jid(jid).await; let signal_addr = resolved.to_protocol_address(); let device_snapshot = self.client.persistence_manager.get_device_snapshot(); + let exists = self + .client + .signal_cache + .has_session(&signal_addr, &*device_snapshot.backend) + .await + .map_err(|e| SignalError::Internal(e.context("session check failed")))?; + if exists || !self.migrate_legacy_pairwise_state(jid, &resolved).await? { + return Ok(exists); + } self.client .signal_cache .has_session(&signal_addr, &*device_snapshot.backend) @@ -274,19 +609,15 @@ impl<'a> Signal<'a> { /// and identity as a paired operation. Changes are flushed to the /// persistent backend before returning. /// - /// PN JIDs are resolved to LID when a LID mapping exists, matching - /// the encrypt/decrypt paths. + /// When a supplied PN JID resolves to LID, both namespace representations + /// are removed so legacy PN state cannot be migrated back after deletion. pub async fn delete_sessions(&self, jids: &[Jid]) -> Result<(), SignalError> { for jid in jids { let resolved = self.client.resolve_encryption_jid(jid).await; - let addr = resolved.to_protocol_address(); - - let lock = self.client.session_lock_for(addr.as_str()).await; - let _guard = lock.lock().await; - - // WA Web removes session + identity together (deleteRemoteSession) - self.client.signal_cache.delete_session(&addr).await; - self.client.signal_cache.delete_identity(&addr).await; + self.delete_pairwise_state_at(jid).await; + if resolved != *jid { + self.delete_pairwise_state_at(&resolved).await; + } } self.client.flush_signal_cache_batch_safe().await?; @@ -364,9 +695,10 @@ mod tests { use std::sync::atomic::Ordering; use wacore::store::in_memory::InMemoryBackend; - use wacore::store::traits::{DeviceInfo, DeviceListRecord}; + use wacore::store::traits::{DeviceInfo, DeviceListRecord, SignalStore}; use wacore_binary::Server; + use crate::lid_pn_cache::LearningSource; use crate::test_utils::seed_peer_session; async fn memory_client() -> (Arc, Arc) { @@ -381,6 +713,456 @@ mod tests { (client, backend) } + fn peer_prekey_bundle(registration_id: u32, device_id: u32) -> PreKeyBundle { + use wacore::libsignal::protocol::{IdentityKeyPair, KeyPair}; + + let mut rng = rand::make_rng::(); + let identity = IdentityKeyPair::generate(&mut rng); + let signed_prekey = KeyPair::generate(&mut rng); + let prekey = KeyPair::generate(&mut rng); + let signature = identity + .private_key() + .calculate_signature(&signed_prekey.public_key.serialize(), &mut rng) + .expect("signed prekey signature"); + PreKeyBundle::new( + registration_id, + device_id.into(), + Some((7u32.into(), prekey.public_key)), + 9u32.into(), + signed_prekey.public_key, + signature.to_vec(), + *identity.identity_key(), + ) + .expect("prekey bundle") + } + + async fn seed_legacy_pn_session_with_mapping( + client: &Arc, + pn: &Jid, + lid: &Jid, + registration_id: u32, + ) { + client + .signal() + .install_prekey_bundle( + pn, + &peer_prekey_bundle(registration_id, u32::from(pn.device)), + ) + .await + .expect("install legacy PN session"); + client + .lid_pn_cache + .warm_up([crate::lid_pn_cache::LidPnEntry::new( + lid.user.to_string(), + pn.user.to_string(), + LearningSource::Other, + )]) + .await; + } + + #[tokio::test] + async fn supplied_prekey_bundle_exposes_session_info() { + let (client, _) = memory_client().await; + let peer = Jid::pn_device("15550002000", 2); + let bundle = peer_prekey_bundle(4242, u32::from(peer.device)); + + client + .signal() + .install_prekey_bundle(&peer, &bundle) + .await + .expect("install bundle"); + + assert!(client.signal().validate_session(&peer).await.unwrap()); + let info = client + .signal() + .session_info(&peer) + .await + .unwrap() + .expect("open session info"); + assert_eq!(info.registration_id, 4242); + assert!(!info.base_key.is_empty()); + } + + #[tokio::test] + async fn supplied_prekey_bundle_uses_known_lid_namespace() { + let (client, backend) = memory_client().await; + let pn = Jid::pn_device("15550002002", 2); + let lid = Jid::lid_device("100000000000002", 2); + client + .add_lid_pn_mapping(&lid.user, &pn.user, LearningSource::PeerPnMessage) + .await + .unwrap(); + + client + .signal() + .install_prekey_bundle(&pn, &peer_prekey_bundle(4244, u32::from(pn.device))) + .await + .expect("install mapped bundle"); + + assert!(client.signal().validate_session(&pn).await.unwrap()); + assert!( + backend + .get_session(pn.to_protocol_address().as_str()) + .await + .unwrap() + .is_none(), + "the obsolete phone-number slot must stay empty" + ); + assert!( + backend + .get_session(lid.to_protocol_address().as_str()) + .await + .unwrap() + .is_some(), + "the installed session must be durable in the resolved namespace" + ); + } + + #[tokio::test] + async fn facade_lookup_migrates_a_legacy_pn_session_on_lid_miss() { + let (client, backend) = memory_client().await; + let pn = Jid::pn_device("15550002003", 3); + let lid = Jid::lid_device("100000000000003", 3); + seed_legacy_pn_session_with_mapping(&client, &pn, &lid, 4245).await; + + assert!( + backend + .get_session(lid.to_protocol_address().as_str()) + .await + .unwrap() + .is_none(), + "the fixture must begin with state only in the PN namespace" + ); + assert!(client.signal().validate_session(&pn).await.unwrap()); + assert_eq!( + client + .signal() + .session_info(&pn) + .await + .unwrap() + .expect("migrated session info") + .registration_id, + 4245 + ); + assert!( + backend + .get_session(pn.to_protocol_address().as_str()) + .await + .unwrap() + .is_none() + ); + assert!( + backend + .get_session(lid.to_protocol_address().as_str()) + .await + .unwrap() + .is_some() + ); + } + + #[tokio::test] + async fn facade_encrypt_retries_after_migrating_a_legacy_pn_session() { + let (client, backend) = memory_client().await; + let pn = Jid::pn_device("15550002004", 4); + let lid = Jid::lid_device("100000000000004", 4); + seed_legacy_pn_session_with_mapping(&client, &pn, &lid, 4246).await; + + let (_, ciphertext) = client + .signal() + .encrypt_message(&pn, b"legacy namespace") + .await + .expect("encrypt after lazy migration"); + assert!(!ciphertext.is_empty()); + assert!( + backend + .get_session(pn.to_protocol_address().as_str()) + .await + .unwrap() + .is_none() + ); + assert!( + backend + .get_session(lid.to_protocol_address().as_str()) + .await + .unwrap() + .is_some() + ); + } + + #[tokio::test] + async fn delete_sessions_removes_legacy_and_resolved_namespaces() { + let (client, backend) = memory_client().await; + let pn = Jid::pn_device("15550002005", 5); + let lid = Jid::lid_device("100000000000005", 5); + seed_legacy_pn_session_with_mapping(&client, &pn, &lid, 4247).await; + let pn_address = pn.to_protocol_address(); + let lid_address = lid.to_protocol_address(); + + assert!( + backend + .get_session(pn_address.as_str()) + .await + .unwrap() + .is_some() + ); + assert!( + backend + .load_identity(pn_address.as_str()) + .await + .unwrap() + .is_some() + ); + + client + .signal() + .delete_sessions(std::slice::from_ref(&pn)) + .await + .expect("delete both known namespaces"); + + for address in [&pn_address, &lid_address] { + assert!( + backend + .get_session(address.as_str()) + .await + .unwrap() + .is_none() + ); + assert!( + backend + .load_identity(address.as_str()) + .await + .unwrap() + .is_none() + ); + } + assert!(!client.signal().validate_session(&pn).await.unwrap()); + } + + #[tokio::test] + async fn session_info_waits_for_pairwise_mutations() { + let (client, _) = memory_client().await; + let peer = Jid::pn_device("15550002001", 2); + let bundle = peer_prekey_bundle(4243, u32::from(peer.device)); + + client + .signal() + .install_prekey_bundle(&peer, &bundle) + .await + .expect("install bundle"); + + let address = peer.to_protocol_address(); + let session_mutex = client.session_lock_for(address.as_str()).await; + let session_guard = session_mutex.lock().await; + assert!( + tokio::time::timeout( + std::time::Duration::from_millis(100), + client.signal().session_info(&peer), + ) + .await + .is_err(), + "inspection must not observe a session while a pairwise mutation owns it" + ); + + drop(session_guard); + assert!(client.signal().session_info(&peer).await.unwrap().is_some()); + } + + #[tokio::test] + async fn session_migration_reports_moves_for_both_user_namespaces() { + for (from_server, to_server) in [ + (Server::Pn, Server::Lid), + (Server::Hosted, Server::HostedLid), + ] { + let (client, _) = memory_client().await; + let from = Jid::new("15550003000", from_server).with_device(3); + let to = Jid::new("100000000000003", to_server).with_device(3); + let bundle = peer_prekey_bundle(4343, 3); + client + .signal() + .install_prekey_bundle(&from, &bundle) + .await + .expect("install source session"); + + let outcome = client + .signal() + .migrate_sessions(&from, &to) + .await + .expect("migrate session"); + assert_eq!(outcome.migrated, 1); + assert_eq!(outcome.skipped, 0); + assert_eq!(outcome.total, 1); + assert_eq!(outcome.migrated_identities, 1); + assert_eq!(outcome.discarded_identities, 0); + assert_eq!(outcome.skipped_identities, 0); + assert!(outcome.has_state_changes()); + assert!(client.signal().session_info(&from).await.unwrap().is_none()); + assert!(client.signal().session_info(&to).await.unwrap().is_some()); + } + } + + #[tokio::test] + async fn session_migration_rejects_mismatched_namespaces() { + let (client, _) = memory_client().await; + for (from, to) in [ + ( + Jid::new("15550003000", Server::Pn), + Jid::new("100000000000003", Server::HostedLid), + ), + ( + Jid::new("15550003000", Server::Hosted), + Jid::new("100000000000003", Server::Lid), + ), + ] { + assert!( + matches!( + client.signal().migrate_sessions(&from, &to).await, + Err(SignalError::InvalidInput(_)) + ), + "mismatched namespace pair {from} -> {to} must be rejected" + ); + } + } + + #[tokio::test] + async fn session_migration_retries_pending_durability_after_flush_failure() { + let (client, backend) = memory_client().await; + let from = Jid::new("15550003001", Server::Pn); + let to = Jid::new("100000000000004", Server::Lid); + let from_device = from.with_device(4); + let to_device = to.with_device(4); + client + .signal() + .install_prekey_bundle(&from_device, &peer_prekey_bundle(4344, 4)) + .await + .expect("install source session"); + + backend.set_fail_session_writes(true); + assert!( + client.signal().migrate_sessions(&from, &to).await.is_err(), + "the injected durability failure must reach the caller" + ); + assert!( + client + .signal_cache + .has_pending_pairwise_writes_for_user(&from.user) + .await + ); + + backend.set_fail_session_writes(false); + let attempts_before_retry = backend.session_batch_write_count(); + let retry = client + .signal() + .migrate_sessions(&from, &to) + .await + .expect("retry pending migration flush"); + + assert!( + !retry.has_state_changes(), + "the cache already reflects the move" + ); + assert!(backend.session_batch_write_count() > attempts_before_retry); + assert!( + backend + .get_session(from_device.to_protocol_address().as_str()) + .await + .unwrap() + .is_none() + ); + assert!( + backend + .get_session(to_device.to_protocol_address().as_str()) + .await + .unwrap() + .is_some() + ); + assert!( + !client + .signal_cache + .has_pending_pairwise_writes_for_user(&from.user) + .await + ); + } + + #[tokio::test] + async fn sender_key_distribution_roundtrip_uses_shared_store() { + let (sender, sender_backend) = memory_client().await; + let (receiver, _) = memory_client().await; + let group = Jid::new("120363000000000001", Server::Group); + let author = Jid::new("15550001000", Server::Pn); + + assert!( + !sender + .signal() + .has_sender_key(&group, &author) + .await + .unwrap() + ); + + let distribution = sender + .signal() + .sender_key_distribution(&group, &author) + .await + .expect("create distribution"); + assert!(!distribution.is_empty()); + assert!( + sender + .signal() + .has_sender_key(&group, &author) + .await + .unwrap() + ); + + receiver + .signal() + .process_sender_key_distribution(&group, &author, &distribution) + .await + .expect("process distribution"); + + let (_, ciphertext) = sender + .signal() + .encrypt_group_message(&group, b"sender-key payload") + .await + .expect("group encrypt"); + let plaintext = receiver + .signal() + .decrypt_group_message(&group, &author, &ciphertext) + .await + .expect("group decrypt"); + assert_eq!(plaintext, b"sender-key payload"); + + let sender_key_name = make_sender_key_name(&group, &author.to_protocol_address()); + let chain_lock = sender.signal_cache.sender_key_lock(&sender_key_name).await; + let chain_guard = chain_lock.lock().await; + let signal = sender.signal(); + let mut deletion = Box::pin(signal.delete_sender_key(&group, &author)); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(100), &mut deletion) + .await + .is_err(), + "deletion must wait for an in-flight chain mutation" + ); + drop(chain_guard); + tokio::time::timeout(std::time::Duration::from_secs(5), deletion) + .await + .expect("delete must finish after the chain unlocks") + .expect("delete sender key"); + assert!( + !sender + .signal() + .has_sender_key(&group, &author) + .await + .unwrap() + ); + assert!( + sender_backend + .get_sender_key(sender_key_name.cache_key()) + .await + .unwrap() + .is_none(), + "delete must be durable before returning" + ); + } + #[tokio::test] async fn group_encrypt_flushes_only_at_sender_key_lease_boundaries() { use wacore::libsignal::protocol::consts::SENDER_CHAIN_RESERVATION_BATCH; @@ -428,6 +1210,55 @@ mod tests { .store(false, Ordering::Release); } + #[tokio::test] + async fn group_encrypt_retry_preserves_distribution_after_flush_failure() { + let (sender, backend) = memory_client().await; + let (receiver, _) = memory_client().await; + let group = Jid::new("120363000000000002", Server::Group); + let author = Jid::new("15550001000", Server::Pn); + + backend.set_fail_sender_key_writes(true); + assert!( + sender + .signal() + .encrypt_group_message(&group, b"failed attempt") + .await + .is_err(), + "the injected durability failure must reach the caller" + ); + + backend.set_fail_sender_key_writes(false); + let (distribution, ciphertext) = sender + .signal() + .encrypt_group_message(&group, b"retry payload") + .await + .expect("retry group encryption"); + let distribution = distribution.expect("retry must retain the pending distribution"); + receiver + .signal() + .process_sender_key_distribution(&group, &author, &distribution) + .await + .expect("process retained distribution"); + assert_eq!( + receiver + .signal() + .decrypt_group_message(&group, &author, &ciphertext) + .await + .expect("decrypt retry ciphertext"), + b"retry payload" + ); + + let (distribution, _) = sender + .signal() + .encrypt_group_message(&group, b"warm payload") + .await + .expect("warm group encryption"); + assert!( + distribution.is_none(), + "the retained distribution must clear after a successful retry" + ); + } + #[tokio::test] async fn participant_fanout_reuses_durable_session_leases() { let (client, backend) = memory_client().await; diff --git a/src/handlers/notification/groups.rs b/src/handlers/notification/groups.rs index 5c08946a5..731e38d3f 100644 --- a/src/handlers/notification/groups.rs +++ b/src/handlers/notification/groups.rs @@ -7,6 +7,11 @@ use wacore::types::events::{GroupUpdate, MexNotification}; use wacore_binary::NodeContentRef; use wacore_binary::{NodeRef, OwnedNodeRef}; +#[inline] +fn clone_or_take_last(value: &mut Option, is_last: bool) -> Option { + if is_last { value.take() } else { value.clone() } +} + /// Sync is fire-and-forget (spawned), so this is not async -- it parses /// collection nodes synchronously and spawns the async sync task. pub(crate) fn handle_server_sync_notification( @@ -116,7 +121,7 @@ pub(crate) fn handle_server_sync_notification( tracing::instrument(name = "wa.notif.group", level = "debug", skip_all) )] pub(crate) async fn handle_group_notification(client: &Arc, node: Arc) { - let notification = match GroupNotification::try_from_node_ref(node.get()) { + let mut notification = match GroupNotification::try_from_node_ref(node.get()) { Some(n) => n, None => { warn!(target: "Client/Group", "w:gp2 notification missing 'from' attribute"); @@ -129,13 +134,33 @@ pub(crate) async fn handle_group_notification(client: &Arc, node: Arc Some(cache.await), + None => None, + }; + let action_count = actions.len(); + + for (action_index, action) in actions.into_iter().enumerate() { // Granularly patch group cache instead of invalidating — matches WA Web's // addParticipantInfo / removeParticipantInfo pattern and avoids a // group metadata IQ round-trip. match &action { GroupNotificationAction::Add { participants, .. } => { - let group_cache = client.get_group_cache().await; + let group_cache = group_cache + .as_ref() + .expect("participant actions initialize the group cache"); if let Some(info) = group_cache.get(¬ification.group_jid).await { let mut info = Arc::unwrap_or_clone(info); info.add_participants( @@ -168,7 +193,9 @@ pub(crate) async fn handle_group_notification(client: &Arc, node: Arc { let users: Vec<&str> = participants.iter().map(|p| p.jid.user.as_str()).collect(); - let group_cache = client.get_group_cache().await; + let group_cache = group_cache + .as_ref() + .expect("participant actions initialize the group cache"); if let Some(info) = group_cache.get(¬ification.group_jid).await { let mut info = Arc::unwrap_or_clone(info); info.remove_participants(&users); @@ -220,9 +247,9 @@ pub(crate) async fn handle_group_notification(client: &Arc, node: Arc here would let the // next send rebuild the sender key against the old participant // list (missing the migrated JID, still targeting the old one). - client - .get_group_cache() - .await + group_cache + .as_ref() + .expect("participant actions initialize the group cache") .invalidate(¬ification.group_jid) .await; client @@ -238,17 +265,41 @@ pub(crate) async fn handle_group_notification(client: &Arc, node: Arc KeepaliveResult { | IqError::Disconnected(_) | IqError::NotConnected | IqError::InternalChannelClosed + | IqError::DuplicateRequestId(_) | IqError::EncodeError(_) => KeepaliveResult::FatalFailure, // Exhaustive: forces a compile error when new IqError variants are added // so the developer must decide the classification. @@ -321,6 +322,14 @@ mod tests { ); } + #[test] + fn test_classify_duplicate_request_id_is_fatal() { + assert_eq!( + classify_keepalive_error(&IqError::DuplicateRequestId("duplicate".into())), + KeepaliveResult::FatalFailure, + ); + } + #[test] fn test_classify_socket_error_is_fatal() { assert_eq!( diff --git a/src/lib.rs b/src/lib.rs index b6460f1dd..95de556bc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -145,19 +145,21 @@ pub use features::{ ChatStateError, ChatStateType, Chatstate, Comments, Community, CommunityError, CommunitySubgroup, ContactError, Contacts, CreateCommunityOptions, CreateCommunityResult, CreateGroupResult, EncryptedEdit, EventCreationParams, EventResponseType, Events, - GroupCreateOptions, GroupDescription, GroupEphemeralSettings, GroupError, GroupJoinError, - GroupMetadata, GroupParticipant, GroupParticipantOptions, GroupProfilePicture, GroupSubject, - GroupType, Groups, GrowthLockInfo, InviteInfoError, IsOnWhatsAppResult, JoinGroupResult, - Labels, LinkSubgroupsResult, MediaRetryResult, MediaReupload, MediaReuploadError, - MediaReuploadRequest, MemberAddMode, MemberLinkMode, MemberShareHistoryMode, - MembershipApprovalMode, MembershipRequest, Mex, MexError, MexErrorExtensions, MexRequest, - MexResponse, Newsletter, NewsletterError, NewsletterMessage, NewsletterMessageType, - NewsletterMetadata, NewsletterReactionCount, NewsletterRole, NewsletterState, - NewsletterVerification, ParticipantChangeResponse, ParticipantType, PictureType, PollError, - Presence, PresenceError, PresenceStatus, Profile, ProfileError, ProfilePicture, SecretEncKind, - SecretEncrypted, SetProfilePictureResponse, Signal, SignalError, Status, StatusPrivacySetting, - StatusSendOptions, SyncActionMessageRange, TcToken, TcTokenError, UnlinkSubgroupsResult, - UserInfo, UsyncSubprotocolError, VerifiedName, group_type, message_key, message_range, + GroupAppealStatus, GroupCreateOptions, GroupDescription, GroupEphemeralSettings, GroupError, + GroupJoinError, GroupMetadata, GroupParticipant, GroupParticipantDetails, + GroupParticipantOptions, GroupProfilePicture, GroupSubject, GroupType, Groups, GrowthLockInfo, + InviteInfoError, IsOnWhatsAppResult, JoinGroupResult, Labels, LinkSubgroupsResult, + MediaRetryResult, MediaReupload, MediaReuploadError, MediaReuploadRequest, MemberAddMode, + MemberLinkMode, MemberShareHistoryMode, MembershipApprovalMode, MembershipRequest, Mex, + MexError, MexErrorExtensions, MexRequest, MexResponse, Newsletter, NewsletterError, + NewsletterMessage, NewsletterMessageType, NewsletterMetadata, NewsletterReactionCount, + NewsletterRole, NewsletterState, NewsletterVerification, ParticipantChangeResponse, + ParticipantType, PictureType, PollError, Presence, PresenceError, PresenceStatus, Profile, + ProfileError, ProfilePicture, ReachoutTimelock, SecretEncKind, SecretEncrypted, + SetProfilePictureResponse, Signal, SignalError, SignalSessionInfo, SignalSessionMigration, + Status, StatusPrivacySetting, StatusSendOptions, SyncActionMessageRange, TcToken, TcTokenError, + UnlinkSubgroupsResult, UserInfo, UsyncSubprotocolError, VerifiedName, group_type, message_key, + message_range, }; pub mod bot; diff --git a/src/lid_pn_cache.rs b/src/lid_pn_cache.rs index 6fd3fd897..cf94f0cd9 100644 --- a/src/lid_pn_cache.rs +++ b/src/lid_pn_cache.rs @@ -57,7 +57,7 @@ pub struct LidPnCache { /// remap (or a stale detached write that marks late) only matches its own /// LID, never a newer un-persisted one. `Arc` value: the hot-path check /// clones a refcount and compares in place, no payload copy. In-memory only; - /// cold after restart just replays the idempotent upsert. + /// mappings loaded from persistent storage are marked during warm-up. persisted: TypedCache, Arc>, } @@ -265,6 +265,17 @@ impl LidPnCache { for entry in entries { self.add(&entry).await; + // `warm_up` only accepts durable rows. Mark the pair that won the + // PN-side timestamp resolution so a live re-learn neither writes + // it again nor repeats discovery migrations. + if self + .pn_to_entry + .get(&*entry.phone_number) + .await + .is_some_and(|current| current.lid == entry.lid) + { + self.mark_persisted(&entry.phone_number, &entry.lid).await; + } count += 1; } @@ -438,6 +449,9 @@ mod tests { assert_eq!(cache.get_current_lid("pn1").await.as_deref(), Some("lid1")); assert_eq!(cache.get_current_lid("pn2").await.as_deref(), Some("lid2")); assert_eq!(cache.get_current_lid("pn3").await.as_deref(), Some("lid3")); + assert!(cache.can_skip_relearn("pn1", "lid1").await); + assert!(cache.can_skip_relearn("pn2", "lid2").await); + assert!(cache.can_skip_relearn("pn3", "lid3").await); } #[tokio::test] diff --git a/src/message.rs b/src/message.rs index b4537fa72..396396cc3 100644 --- a/src/message.rs +++ b/src/message.rs @@ -5,18 +5,13 @@ use log::{debug, warn}; use std::sync::Arc; use std::sync::atomic::{AtomicU8, Ordering}; use wacore::libsignal::crypto::DecryptionError; -use wacore::libsignal::protocol::SenderKeyDistributionMessage; use wacore::libsignal::protocol::SenderKeyStore; use wacore::libsignal::protocol::group_decrypt; -use wacore::libsignal::protocol::process_sender_key_distribution_message; use wacore::libsignal::protocol::{ CiphertextMessage, DecryptionResult, IdentityChange, OwnedCiphertextMessage, PreKeySignalMessage, SignalMessage, SignalProtocolError, UsePQRatchet, message_decrypt, message_decrypt_owned, }; -use wacore::libsignal::protocol::{ - PublicKey as SignalPublicKey, SENDERKEY_MESSAGE_CURRENT_VERSION, -}; use wacore::message_processing::EncType; use wacore::protocol::nack::NackReason; use wacore::types::jid::{JidExt, make_sender_key_name}; diff --git a/src/message/special.rs b/src/message/special.rs index d04da72fd..16b567fc7 100644 --- a/src/message/special.rs +++ b/src/message/special.rs @@ -380,100 +380,10 @@ impl Client { sender_jid: &Jid, axolotl_bytes: &[u8], ) { - let skdm = match SenderKeyDistributionMessage::try_from(axolotl_bytes) { - Ok(msg) => msg, - Err(e1) => { - match waproto::codec::sender_key_distribution_message_decode(axolotl_bytes) { - Ok(go_msg) => { - let (Some(signing_key), Some(id), Some(iteration), Some(chain_key)) = ( - go_msg.signing_key.as_ref(), - go_msg.id, - go_msg.iteration, - go_msg.chain_key.as_ref(), - ) else { - log::warn!( - "Go SKDM from {} missing required fields (signing_key={}, id={}, iteration={}, chain_key={})", - sender_jid.observe(), - go_msg.signing_key.is_some(), - go_msg.id.is_some(), - go_msg.iteration.is_some(), - go_msg.chain_key.is_some() - ); - return; - }; - let chain_key_arr: [u8; 32] = match chain_key.as_slice().try_into() { - Ok(arr) => arr, - Err(_) => { - log::error!( - "Invalid chain_key length {} from Go SKDM from {}", - chain_key.len(), - sender_jid.observe() - ); - return; - } - }; - match SignalPublicKey::from_djb_public_key_bytes(signing_key) { - Ok(pub_key) => { - match SenderKeyDistributionMessage::new( - SENDERKEY_MESSAGE_CURRENT_VERSION, - id, - iteration, - chain_key_arr, - pub_key, - ) { - Ok(skdm) => skdm, - Err(e) => { - log::error!( - "Failed to construct SKDM from Go format from {}: {:?} (original parse error: {:?})", - sender_jid.observe(), - e, - e1 - ); - return; - } - } - } - Err(e) => { - log::error!( - "Failed to parse public key from Go SKDM for {}: {:?} (original parse error: {:?})", - sender_jid.observe(), - e, - e1 - ); - return; - } - } - } - Err(e2) => { - log::error!( - "Failed to parse SenderKeyDistributionMessage (standard and Go fallback) from {}: primary: {:?}, fallback: {:?}", - sender_jid.observe(), - e1, - e2 - ); - return; - } - } - } - }; - - // Normalize to bare sender for consistent sender key addressing. - let sender_bare = sender_jid.to_non_ad(); - let sender_address = sender_bare.to_protocol_address(); - - let sender_key_name = make_sender_key_name(group_jid, &sender_address); - - // Route through the signal cache adapter so the sender key is immediately visible - // in the cache for subsequent group_decrypt calls within the same message batch. - // Only the sender-key store is needed here, so build it standalone instead of - // the full five-store adapter. - let mut sender_key_store = self.sender_key_adapter().await; - let chain_lock = sender_key_store.sender_key_lock(&sender_key_name).await; - let _chain_guard = chain_lock.lock().await; - - if let Err(e) = - process_sender_key_distribution_message(&sender_key_name, &skdm, &mut sender_key_store) - .await + if let Err(e) = self + .signal() + .process_sender_key_distribution_cached(group_jid, sender_jid, axolotl_bytes) + .await { log::error!( "Failed to process SenderKeyDistributionMessage from {}: {:?}", diff --git a/src/prekeys.rs b/src/prekeys.rs index 641e4de73..56802fc67 100644 --- a/src/prekeys.rs +++ b/src/prekeys.rs @@ -284,10 +284,21 @@ impl Client { r } - /// Ensure the server has enough pre-keys, uploading if below threshold. - /// When `force` is true, skips the count guard (used by digest key repair). - #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.session.upload_pre_keys", level = "debug", skip_all, fields(force = force), err(Debug)))] - pub(crate) async fn upload_pre_keys(&self, force: bool) -> Result<(), anyhow::Error> { + #[cfg_attr( + feature = "tracing", + tracing::instrument( + name = "wa.session.upload_pre_keys", + level = "debug", + skip_all, + fields(force = force, wanted = ?wanted), + err(Debug) + ) + )] + async fn upload_pre_keys_with_count( + &self, + force: bool, + wanted: Option, + ) -> Result<(), anyhow::Error> { // Decision is should_upload_pre_keys(force, count), but a forced upload short-circuits // and skips the server-count IQ entirely: WA Web's handlePreKeyLow uploads // unconditionally, so a stale or transiently-failing count must never block or delay @@ -306,7 +317,10 @@ impl Client { log::debug!("Server has {server_count} pre-keys, uploading."); } - self.upload_pre_keys_inner().await + match wanted { + Some(wanted) => self.upload_pre_keys_inner_with_count(wanted).await, + None => self.upload_pre_keys_inner().await, + } } /// Get-or-generate ONE one-time prekey, mirroring WA Web's @@ -463,7 +477,12 @@ impl Client { ) )] async fn upload_pre_keys_inner(&self) -> Result<(), anyhow::Error> { - self.upload_pre_keys_pass(true).await + let wanted = self.wanted_pre_key_count.load(Ordering::Relaxed); + self.upload_pre_keys_inner_with_count(wanted).await + } + + async fn upload_pre_keys_inner_with_count(&self, wanted: usize) -> Result<(), anyhow::Error> { + self.upload_pre_keys_pass(true, wanted).await } /// One upload pass. `allow_collapse_retry` permits a single inline rerun @@ -471,14 +490,17 @@ impl Client { /// login path logs and moves on) still ends the pass with fresh keys; the /// rerun cannot hit the empty branch again because the collapsed plan /// generates a full batch. - async fn upload_pre_keys_pass(&self, allow_collapse_retry: bool) -> Result<(), anyhow::Error> { + async fn upload_pre_keys_pass( + &self, + allow_collapse_retry: bool, + configured: usize, + ) -> Result<(), anyhow::Error> { // INVARIANT: every caller holds `prekey_upload_lock` (login, prekey-low // notification, refresh, digest repair), serializing the watermark math // with the retry-receipt single-key path. let device_snapshot = self.persistence_manager.get_device_snapshot(); let backend = device_snapshot.backend.clone(); - let configured = self.wanted_pre_key_count.load(Ordering::Relaxed); let wanted = clamp_wanted_pre_key_count(configured); if wanted != configured { log::warn!("wanted_pre_key_count {configured} out of range, clamped to {wanted}"); @@ -602,7 +624,7 @@ impl Client { plan.window_start, plan.new_next ); - return Box::pin(self.upload_pre_keys_pass(false)).await; + return Box::pin(self.upload_pre_keys_pass(false, configured)).await; } anyhow::bail!("no prekey available to upload"); } @@ -706,17 +728,35 @@ impl Client { /// Verified against WA Web JS: `{ algo: { type: "fibonacci", first: 1e3, second: 2e3 }, max: 61e4 }` /// /// When `force` is true, bypasses the count guard (used by digest repair path). - #[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.session.upload_pre_keys_retry", level = "debug", skip_all, fields(force = force), err(Debug)))] pub(crate) async fn upload_pre_keys_with_retry( &self, force: bool, + ) -> Result<(), anyhow::Error> { + self.upload_pre_keys_with_retry_count(force, None).await + } + + #[cfg_attr( + feature = "tracing", + tracing::instrument( + name = "wa.session.upload_pre_keys_retry", + level = "debug", + skip_all, + fields(force = force, wanted = ?wanted), + err(Debug) + ) + )] + async fn upload_pre_keys_with_retry_count( + &self, + force: bool, + wanted: Option, ) -> Result<(), anyhow::Error> { let mut delay_a: u64 = 1; let mut delay_b: u64 = 2; const MAX_DELAY_SECS: u64 = 610; loop { - match self.upload_pre_keys(force).await { + let result = self.upload_pre_keys_with_count(force, wanted).await; + match result { Ok(()) => { log::info!("Pre-key upload succeeded"); // Operation-level outcome: one emit per logical upload, not per attempt. @@ -773,6 +813,40 @@ impl Client { self.upload_pre_keys_with_retry(true).await } + /// Force-refresh the server pool using a caller-selected batch size without + /// changing the client's configured background replenishment size. The + /// count is clamped to the same protocol-safe bounds as regular uploads. + #[cfg_attr( + feature = "tracing", + tracing::instrument( + name = "wa.session.refresh_pre_keys_with_count", + level = "debug", + skip_all, + fields(count = count), + err(Debug) + ) + )] + pub async fn refresh_pre_keys_with_count(&self, count: usize) -> Result<(), anyhow::Error> { + let _guard = self.prekey_upload_lock.lock().await; + self.upload_pre_keys_with_retry_count(true, Some(count)) + .await + } + + /// Ensure the server pool is above the low-water mark. + #[cfg_attr( + feature = "tracing", + tracing::instrument( + name = "wa.session.ensure_pre_keys", + level = "debug", + skip_all, + err(Debug) + ) + )] + pub async fn ensure_pre_keys(&self) -> Result<(), anyhow::Error> { + let _guard = self.prekey_upload_lock.lock().await; + self.upload_pre_keys_with_retry(false).await + } + /// Validate server key bundle digest, re-uploading only when the server has no record. /// /// Matches WA Web's `WAWebDigestKeyJob.digestKey()`: @@ -792,7 +866,7 @@ impl Client { err(Debug) ) )] - pub(crate) async fn validate_digest_key(&self) -> Result<(), anyhow::Error> { + pub async fn validate_digest_key(&self) -> Result<(), anyhow::Error> { // Hold the lock across the whole pass so the 404 re-upload can't race with // `upload_pre_keys_at_login`, `handle_prekey_low`, or `refresh_pre_keys` on // `next_pre_key_id` allocation. @@ -1073,6 +1147,25 @@ mod window_tests { client.persistence_manager.backend() } + #[tokio::test] + async fn explicit_upload_count_does_not_change_background_configuration() { + let client = crate::test_utils::create_test_client_with_name("prekey_explicit_count").await; + client.set_wanted_pre_key_count(5); + + let _ = client.upload_pre_keys_inner_with_count(7).await; + + assert_eq!(client.wanted_pre_key_count(), 5); + assert_eq!(snapshot(&client), (8, 8)); + assert_eq!( + backend(&client) + .load_prekeys_batch(&[1, 2, 3, 4, 5, 6, 7]) + .await + .unwrap() + .len(), + 7 + ); + } + /// A failed upload IQ must leave the watermarks past the generated window /// (WA Web abandons on unknown server state) and the next attempt must /// mint FRESH ids, never regenerating over the stored ones: that diff --git a/src/request.rs b/src/request.rs index 08a435e1a..0fc9e96fe 100644 --- a/src/request.rs +++ b/src/request.rs @@ -2,6 +2,7 @@ use crate::client::Client; use crate::client::ClientError; use crate::socket::error::{EncryptSendError, SocketError}; use futures::FutureExt; +use std::num::NonZeroU64; use std::sync::Arc; use std::sync::atomic::Ordering; use std::time::Duration; @@ -11,6 +12,10 @@ use wacore_binary::Node; pub use wacore::request::{InfoQuery, InfoQueryType, RequestUtils}; +const DEFAULT_IQ_TIMEOUT: Duration = Duration::from_secs(75); +const IQ_ID_ATTR: &str = "id"; +const IQ_TAG: &str = "iq"; + /// Type-erased send future handed to [`Client::send_and_wait_iq`]. Boxing it /// keeps that function non-generic so it isn't re-monomorphized per `IqSpec`. /// `Send` on native (IQ awaits happen inside spawned handler tasks); dropped @@ -34,6 +39,7 @@ type IqSendFuture<'a> = struct ResponseWaiterGuard { waiters: Arc>, req_id: String, + cleanup_generation: NonZeroU64, } impl Drop for ResponseWaiterGuard { @@ -41,7 +47,7 @@ impl Drop for ResponseWaiterGuard { self.waiters .lock() .unwrap_or_else(|p| p.into_inner()) - .remove(&self.req_id); + .remove_guarded(&self.req_id, self.cleanup_generation); } } @@ -86,6 +92,8 @@ pub enum IqError { UnexpectedResponseType { got: Option }, #[error("internal channel closed unexpectedly")] InternalChannelClosed, + #[error("IQ request ID is already in flight: {0}")] + DuplicateRequestId(String), #[error("failed to encode IQ request")] EncodeError(#[source] anyhow::Error), #[error("failed to parse IQ response")] @@ -238,8 +246,7 @@ impl Client { #[cfg(feature = "tracing")] self.record_identity_on_span(&tracing::Span::current()); - let default_timeout = Duration::from_secs(75); - let iq_timeout = query.timeout.unwrap_or(default_timeout); + let iq_timeout = query.timeout.unwrap_or(DEFAULT_IQ_TIMEOUT); let req_id = query .id .clone() @@ -256,6 +263,46 @@ impl Client { .await } + /// Sends a fully constructed IQ stanza and waits for its matching response. + /// + /// The stanza ID is preserved when supplied and generated otherwise. The + /// same waiter, cancellation, timeout and response validation path used by + /// typed IQ specifications handles the request. + #[cfg_attr( + feature = "tracing", + tracing::instrument(name = "wa.iq.node", level = "debug", skip_all, err(Debug)) + )] + pub async fn send_iq_node( + &self, + mut node: Node, + timeout: Option, + ) -> Result, IqError> { + #[cfg(feature = "tracing")] + self.record_identity_on_span(&tracing::Span::current()); + + if node.tag.as_ref() != IQ_TAG { + return Err(IqError::ParseError(anyhow::anyhow!( + "expected an stanza, got <{}>", + node.tag + ))); + } + + let req_id = node + .attrs + .get(IQ_ID_ATTR) + .map(|value| value.as_str().into_owned()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| self.generate_request_id()); + node.attrs.insert(IQ_ID_ATTR, req_id.clone()); + + self.send_and_wait_iq( + req_id, + timeout.unwrap_or(DEFAULT_IQ_TIMEOUT), + Box::pin(async { self.send_node(node).await }), + ) + .await + } + /// Executes an IQ specification and returns the typed response. /// /// This is a convenience method that combines building the IQ request, @@ -303,7 +350,7 @@ impl Client { PreparedIq::Encoded(buf) => { self.send_and_wait_iq( req_id, - Duration::from_secs(75), + DEFAULT_IQ_TIMEOUT, Box::pin(async { self.send_raw_bytes(buf).await }), ) .await @@ -341,19 +388,17 @@ impl Client { } let (tx, rx) = futures::channel::oneshot::channel(); - { + let cleanup_generation = { let mut waiters = self.response_waiters_guard(); - // req_ids come from the monotonic generate_request_id(), so a given - // id is never in flight twice — the invariant that makes the guard's - // remove-by-id unambiguous (an overwrite could otherwise let an older - // guard evict a newer waiter). Assert it so a future caller passing a - // duplicate id is caught in tests instead of silently. - debug_assert!( - !waiters.contains_key(&req_id), - "duplicate in-flight IQ request id: {req_id}" - ); - waiters.insert(req_id.clone(), tx); - } + // Explicit IDs are accepted by both InfoQuery and send_iq_node. Never + // overwrite an older waiter. The per-registration generation also + // prevents an older guard from removing a later reuse of this ID. + let Some(cleanup_generation) = waiters.try_insert_guarded(req_id.clone(), tx) else { + wacore::telemetry::iq("error"); + return Err(IqError::DuplicateRequestId(req_id)); + }; + cleanup_generation + }; // RAII cleanup covers every exit below — including this future being // dropped mid-await (cancellation), which the explicit paths can't // catch. So the send-fail / timeout / shutdown arms no longer remove @@ -361,6 +406,7 @@ impl Client { let _waiter_guard = ResponseWaiterGuard { waiters: self.response_waiters.clone(), req_id, + cleanup_generation, }; // Per-connection: pending IQ requests are bound to the current socket; @@ -410,9 +456,47 @@ impl Client { #[cfg(test)] mod tests { - use super::{IqError, ResponseWaiterGuard}; - use std::collections::HashMap; + use super::{IQ_ID_ATTR, IQ_TAG, IqError, ResponseWaiterGuard}; + use crate::client::ResponseWaiterMap; + use std::sync::atomic::Ordering; use std::sync::{Arc, Mutex}; + use wacore_binary::builder::NodeBuilder; + + #[tokio::test] + async fn send_iq_node_rejects_non_iq_stanzas() { + let client = crate::test_utils::create_test_client_with_name("invalid_iq_node").await; + let error = client + .send_iq_node(NodeBuilder::new("message").build(), None) + .await + .expect_err("a non-IQ stanza must be rejected before transport"); + assert!(matches!(error, IqError::ParseError(_))); + } + + #[tokio::test] + async fn send_iq_node_rejects_duplicate_in_flight_id() { + let client = crate::test_utils::create_test_client_with_name("duplicate_iq_id").await; + client.is_running.store(true, Ordering::Release); + let request_id = "duplicate-request"; + let (tx, _rx) = futures::channel::oneshot::channel(); + client + .response_waiters_guard() + .insert(request_id.to_owned(), tx); + + let error = client + .send_iq_node( + NodeBuilder::new(IQ_TAG) + .attr(IQ_ID_ATTR, request_id) + .build(), + None, + ) + .await + .expect_err("a duplicate ID must not replace an existing waiter"); + assert!(matches!(error, IqError::DuplicateRequestId(id) if id == request_id)); + assert!(client.response_waiters_guard().contains_key(request_id)); + + client.response_waiters_guard().remove(request_id); + client.is_running.store(false, Ordering::Release); + } #[test] fn converts_unexpected_response_type() { @@ -432,15 +516,20 @@ mod tests { #[test] fn waiter_guard_removes_pending_entry_on_drop() { let waiters: Arc> = - Arc::new(Mutex::new(HashMap::new())); + Arc::new(Mutex::new(ResponseWaiterMap::default())); let (tx, _rx) = futures::channel::oneshot::channel(); - waiters.lock().unwrap().insert("req-1".to_string(), tx); + let cleanup_generation = waiters + .lock() + .unwrap() + .try_insert_guarded("req-1".to_string(), tx) + .expect("unique request ID"); assert!(waiters.lock().unwrap().contains_key("req-1")); { let _guard = ResponseWaiterGuard { waiters: waiters.clone(), req_id: "req-1".to_string(), + cleanup_generation, }; } assert!( @@ -454,14 +543,86 @@ mod tests { #[test] fn waiter_guard_drop_is_noop_when_already_resolved() { let waiters: Arc> = - Arc::new(Mutex::new(HashMap::new())); + Arc::new(Mutex::new(ResponseWaiterMap::default())); + let (tx, _rx) = futures::channel::oneshot::channel(); + let cleanup_generation = waiters + .lock() + .unwrap() + .try_insert_guarded("req-1".to_string(), tx) + .expect("unique request ID"); // Map empty = resolver already delivered + removed this request's waiter. + waiters.lock().unwrap().remove("req-1"); { let _guard = ResponseWaiterGuard { waiters: waiters.clone(), req_id: "req-1".to_string(), + cleanup_generation, }; } assert!(waiters.lock().unwrap().is_empty()); } + + #[test] + fn stale_waiter_guard_preserves_a_reused_request_id() { + let waiters = Arc::new(Mutex::new(ResponseWaiterMap::default())); + let (old_tx, _old_rx) = futures::channel::oneshot::channel(); + let old_generation = waiters + .lock() + .unwrap() + .try_insert_guarded("reused-id".to_string(), old_tx) + .expect("initial request ID"); + let old_guard = ResponseWaiterGuard { + waiters: waiters.clone(), + req_id: "reused-id".to_string(), + cleanup_generation: old_generation, + }; + + // Simulate response delivery removing the old sender, followed by a + // new explicit-ID request registering before the old future is dropped. + waiters.lock().unwrap().remove("reused-id"); + let (new_tx, _new_rx) = futures::channel::oneshot::channel(); + waiters + .lock() + .unwrap() + .try_insert_guarded("reused-id".to_string(), new_tx) + .expect("reused request ID"); + + drop(old_guard); + assert!( + waiters.lock().unwrap().contains_key("reused-id"), + "an old guard must not remove the newer registration" + ); + } + + #[test] + fn disconnected_waiter_guard_preserves_a_reused_request_id() { + let waiters = Arc::new(Mutex::new(ResponseWaiterMap::default())); + let (old_tx, _old_rx) = futures::channel::oneshot::channel(); + let old_generation = waiters + .lock() + .unwrap() + .try_insert_guarded("reused-id".to_string(), old_tx) + .expect("initial request ID"); + let old_guard = ResponseWaiterGuard { + waiters: waiters.clone(), + req_id: "reused-id".to_string(), + cleanup_generation: old_generation, + }; + + // Disconnect drains the old sender but its request future (and guard) + // may not be polled and dropped until after a reconnect reuses the ID. + waiters.lock().unwrap().clear(); + let (new_tx, _new_rx) = futures::channel::oneshot::channel(); + waiters + .lock() + .unwrap() + .try_insert_guarded("reused-id".to_string(), new_tx) + .expect("reused request ID"); + + drop(old_guard); + assert!( + waiters.lock().unwrap().contains_key("reused-id"), + "a pre-disconnect guard must not remove the post-reconnect waiter" + ); + } } diff --git a/src/retry.rs b/src/retry.rs index 5c56ced39..89a021260 100644 --- a/src/retry.rs +++ b/src/retry.rs @@ -7,7 +7,7 @@ use wacore::types::message::MessageCategory; use scopeguard; use std::sync::Arc; use wacore::iq::prekeys::{OneTimePreKeyNode, SignedPreKeyNode}; -use wacore::libsignal::protocol::{PreKeyBundle, PublicKey, UsePQRatchet, process_prekey_bundle}; +use wacore::libsignal::protocol::{PreKeyBundle, PublicKey}; use wacore::protocol::ProtocolNode; use wacore::types::jid::JidExt; use wacore_binary::JidExt as _; @@ -1049,33 +1049,13 @@ impl Client { identity_key.into(), )?; - // Acquire per-sender session lock to prevent race with concurrent message decryption. - // This matches the session_locks pattern used in process_session_enc_batch. - let session_mutex = self.session_lock_for(signal_address.as_str()).await; - let _session_guard = session_mutex.lock().await; - let mut adapter = self.signal_adapter().await; + let mut rng = rand::make_rng::(); + self.install_prekey_bundle_cached(requester_jid, &bundle, &mut adapter, &mut rng) + .await?; - let identity_change = process_prekey_bundle( - &signal_address, - &mut adapter.session_store, - &mut adapter.identity_store, - &bundle, - &mut rand::make_rng::(), - UsePQRatchet::No, - ) - .await?; - - // Flush after session establishment; release the session lock first - // (the batch-safe flush acquires the processing permit, whose holder - // may need this same lock). - drop(_session_guard); self.flush_signal_cache_batch_safe().await?; - if identity_change == wacore::libsignal::protocol::IdentityChange::ReplacedExisting { - self.react_to_local_identity_change(requester_jid); - } - info!( "Processed key bundle from retry receipt for {}", signal_address diff --git a/tests/e2e/tests/groups.rs b/tests/e2e/tests/groups.rs index 58a19bfe6..199b7f84d 100644 --- a/tests/e2e/tests/groups.rs +++ b/tests/e2e/tests/groups.rs @@ -398,7 +398,7 @@ async fn test_group_settings() -> anyhow::Result<()> { metadata .ephemeral .and_then(|settings| settings.expiration) - .unwrap_or(0), + .unwrap_or_default(), 0, "Ephemeral should be disabled initially" ); @@ -492,8 +492,11 @@ async fn test_group_settings() -> anyhow::Result<()> { .await?; let metadata = client_a.client.groups().get_metadata(&group_jid).await?; assert_eq!( - metadata.ephemeral.and_then(|settings| settings.expiration), - Some(0), + metadata + .ephemeral + .and_then(|settings| settings.expiration) + .unwrap_or_default(), + 0, "Ephemeral should be disabled after set_ephemeral(0)" ); info!("Ephemeral disabled - verified"); diff --git a/wacore/binary/src/attrs.rs b/wacore/binary/src/attrs.rs index a397c240a..9f346ece6 100644 --- a/wacore/binary/src/attrs.rs +++ b/wacore/binary/src/attrs.rs @@ -118,8 +118,13 @@ impl<'a> AttrParserRef<'a> { }) } + /// Parse an optional protocol boolean while preserving absence. + pub fn optional_bool_value(&mut self, key: &str) -> Option { + self.get_bool(key, false) + } + pub fn optional_bool(&mut self, key: &str) -> bool { - self.get_bool(key, false).unwrap_or(false) + self.optional_bool_value(key).unwrap_or(false) } pub fn bool(&mut self, key: &str) -> bool { @@ -266,8 +271,13 @@ impl<'a> AttrParser<'a> { }) } + /// Parse an optional protocol boolean while preserving absence. + pub fn optional_bool_value(&mut self, key: &str) -> Option { + self.get_bool(key, false) + } + pub fn optional_bool(&mut self, key: &str) -> bool { - self.get_bool(key, false).unwrap_or(false) + self.optional_bool_value(key).unwrap_or(false) } pub fn bool(&mut self, key: &str) -> bool { diff --git a/wacore/libsignal/src/protocol/local_field.rs b/wacore/libsignal/src/protocol/local_field.rs index 552ac8172..9d709da6e 100644 --- a/wacore/libsignal/src/protocol/local_field.rs +++ b/wacore/libsignal/src/protocol/local_field.rs @@ -6,6 +6,7 @@ use buffa::encoding::{Tag, WireType, decode_varint, encode_varint, skip_field}; const STORE_INCARNATION_FIELD: u32 = 101; const STORE_INCARNATION_LEN: usize = 16; pub(crate) const STORE_INCARNATION_ENCODED_LEN: usize = 19; +pub(crate) const COUNTER_RESERVATION_FIELD: u32 = 100; pub(crate) struct LocalRecordFields { pub(crate) reservation: u32, diff --git a/wacore/libsignal/src/protocol/sender_keys.rs b/wacore/libsignal/src/protocol/sender_keys.rs index 3149bdc1d..dd736dbb0 100644 --- a/wacore/libsignal/src/protocol/sender_keys.rs +++ b/wacore/libsignal/src/protocol/sender_keys.rs @@ -489,7 +489,7 @@ pub struct SenderKeyRecord { /// The vendored `SenderKeyRecordStructure` proto is untouched; the generated /// decoder skips this unknown top-level field and `deserialize` scans it out. /// Matches the field-number scheme `SessionRecord` uses for its DM counterpart. -const RESERVED_ITERATION_FIELD: u32 = 100; +const RESERVED_ITERATION_FIELD: u32 = super::local_field::COUNTER_RESERVATION_FIELD; impl SenderKeyRecord { /// Replaces the states wholesale, so the wire gate — which belongs to the diff --git a/wacore/libsignal/src/protocol/state/session.rs b/wacore/libsignal/src/protocol/state/session.rs index fe9d64725..e91cf30ff 100644 --- a/wacore/libsignal/src/protocol/state/session.rs +++ b/wacore/libsignal/src/protocol/state/session.rs @@ -673,7 +673,8 @@ impl From<&SessionState> for SessionStructure { /// prevent that — already-released readers skip unknown fields by definition — /// so it is a release-note constraint, not a code one. Never lower this /// number into a range an older reader might interpret. -const RESERVED_SENDER_CHAIN_INDEX_FIELD: u32 = 100; +const RESERVED_SENDER_CHAIN_INDEX_FIELD: u32 = + crate::protocol::local_field::COUNTER_RESERVATION_FIELD; #[derive(Clone)] pub struct SessionRecord { diff --git a/wacore/src/crypto.rs b/wacore/src/crypto.rs index 5f0b9649e..e35d08c10 100644 --- a/wacore/src/crypto.rs +++ b/wacore/src/crypto.rs @@ -16,12 +16,12 @@ pub enum CryptoError { InvalidHkdfLength, } -/// Computes an MD5 digest. +/// Legacy protocol fingerprint; this must not be used as a security hash. pub fn md5_digest(input: &[u8]) -> [u8; 16] { md5::compute(input).into() } -/// Derives `expanded_length` bytes using HKDF-SHA256. +/// Rejects output beyond HKDF's 255-block expansion limit before allocating. pub fn hkdf_sha256( input_key_material: &[u8], expanded_length: usize, @@ -36,7 +36,7 @@ pub fn hkdf_sha256( Ok(output) } -/// Derives HKDF-SHA256 output directly into a caller-provided buffer. +/// Caller-owned output avoids an allocation in fixed-size derivation paths. pub fn hkdf_sha256_into( input_key_material: &[u8], salt: Option<&[u8]>, @@ -48,12 +48,12 @@ pub fn hkdf_sha256_into( .map_err(|_| CryptoError::InvalidHkdfLength) } -/// Generates a Curve25519 key pair with the configured secure random source. +/// Uses the crate-wide secure random source so key generation follows one policy. pub fn generate_curve_key_pair() -> KeyPair { KeyPair::generate(&mut rand::make_rng::()) } -/// Signs `message` with the supplied Curve25519 private key. +/// Uses the same secure random policy as key generation for randomized signatures. pub fn calculate_curve_signature( private_key: &PrivateKey, message: &[u8], diff --git a/wacore/src/event.rs b/wacore/src/event.rs index d32b8318b..d74048e5d 100644 --- a/wacore/src/event.rs +++ b/wacore/src/event.rs @@ -6,10 +6,11 @@ use anyhow::{Result, ensure}; use waproto::whatsapp::message::EventResponseMessage; -use crate::secret_enc_addon::{AddonContext, ModificationType, decrypt_addon, encrypt_addon}; +use crate::secret_enc_addon::{ + AddonContext, MESSAGE_SECRET_SIZE, ModificationType, decrypt_addon, encrypt_addon, +}; const GCM_IV_SIZE: usize = 12; -const MESSAGE_SECRET_SIZE: usize = 32; fn event_response_addon_ctx<'a>( stanza_id: &'a str, diff --git a/wacore/src/iq/business.rs b/wacore/src/iq/business.rs index 840594ff3..ebb2106fc 100644 --- a/wacore/src/iq/business.rs +++ b/wacore/src/iq/business.rs @@ -78,8 +78,10 @@ pub struct BusinessHours { pub struct BusinessHoursConfig { pub day_of_week: DayOfWeek, pub mode: BusinessHourMode, - pub open_time: u32, - pub close_time: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub open_time: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub close_time: Option, } #[derive(Debug, Clone, serde::Serialize)] @@ -170,11 +172,9 @@ impl IqSpec for BusinessProfileSpec { day_of_week: DayOfWeek::from(day.as_ref()), mode: BusinessHourMode::from(mode_str.as_ref()), open_time: optional_attr(c, "open_time") - .and_then(|s| s.parse::().ok()) - .unwrap_or(0), + .and_then(|s| s.parse::().ok()), close_time: optional_attr(c, "close_time") - .and_then(|s| s.parse::().ok()) - .unwrap_or(0), + .and_then(|s| s.parse::().ok()), }) }) .collect(); @@ -202,3 +202,47 @@ impl IqSpec for BusinessProfileSpec { })) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preserves_absent_business_hour_times() { + let jid: Jid = "5511999999999@s.whatsapp.net".parse().unwrap(); + let spec = BusinessProfileSpec::new(&jid); + let response = NodeBuilder::new("iq") + .children([NodeBuilder::new("business_profile") + .children([NodeBuilder::new("profile") + .attr("jid", &jid) + .children([NodeBuilder::new("business_hours") + .attr("timezone", "America/Araguaina") + .children([ + NodeBuilder::new("business_hours_config") + .attr("day_of_week", "mon") + .attr("mode", "open_24h") + .build(), + NodeBuilder::new("business_hours_config") + .attr("day_of_week", "tue") + .attr("mode", "specific_hours") + .attr("open_time", "480") + .attr("close_time", "1080") + .build(), + ]) + .build()]) + .build()]) + .build()]) + .build(); + + let profile = spec + .parse_response(&response.as_node_ref()) + .unwrap() + .unwrap(); + let configs = profile.business_hours.business_config.unwrap(); + + assert_eq!(configs[0].open_time, None); + assert_eq!(configs[0].close_time, None); + assert_eq!(configs[1].open_time, Some(480)); + assert_eq!(configs[1].close_time, Some(1080)); + } +} diff --git a/wacore/src/iq/contacts.rs b/wacore/src/iq/contacts.rs index 3ab232ebd..17347011a 100644 --- a/wacore/src/iq/contacts.rs +++ b/wacore/src/iq/contacts.rs @@ -26,6 +26,7 @@ use crate::iq::spec::IqSpec; use crate::iq::tctoken::build_tc_token_node; use crate::request::InfoQuery; use anyhow::anyhow; +use std::time::Duration; use wacore_binary::builder::NodeBuilder; use wacore_binary::{Jid, Server}; use wacore_binary::{NodeContent, NodeRef}; @@ -59,6 +60,8 @@ pub struct ProfilePictureSpec { /// Current known picture ID. When set, the server can skip re-sending /// if the picture hasn't changed (cache optimization). pub existing_id: Option, + /// Optional request timeout override. + pub timeout: Option, } impl ProfilePictureSpec { @@ -68,6 +71,7 @@ impl ProfilePictureSpec { picture_type: ProfilePictureType::Preview, tc_token: None, existing_id: None, + timeout: None, } } @@ -77,6 +81,7 @@ impl ProfilePictureSpec { picture_type: ProfilePictureType::Full, tc_token: None, existing_id: None, + timeout: None, } } @@ -86,6 +91,7 @@ impl ProfilePictureSpec { picture_type, tc_token: None, existing_id: None, + timeout: None, } } @@ -101,6 +107,12 @@ impl ProfilePictureSpec { self.existing_id = Some(id); self } + + /// Override the default request timeout. + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = Some(timeout); + self + } } impl IqSpec for ProfilePictureSpec { @@ -120,12 +132,16 @@ impl IqSpec for ProfilePictureSpec { picture_builder = picture_builder.children([build_tc_token_node(token)]); } - InfoQuery::get( + let query = InfoQuery::get( "w:profile:picture", Jid::new("", Server::Pn), Some(NodeContent::Nodes(vec![picture_builder.build()])), ) - .with_target_ref(&self.jid) + .with_target_ref(&self.jid); + match self.timeout { + Some(timeout) => query.with_timeout(timeout), + None => query, + } } fn parse_response(&self, response: &NodeRef<'_>) -> Result { @@ -382,6 +398,16 @@ mod tests { } } + #[test] + fn test_profile_picture_spec_timeout_override() { + let jid: Jid = "1234567890@s.whatsapp.net".parse().unwrap(); + let timeout = Duration::from_millis(1_250); + let iq = ProfilePictureSpec::preview(&jid) + .with_timeout(timeout) + .build_iq(); + assert_eq!(iq.timeout, Some(timeout)); + } + #[test] fn test_profile_picture_spec_parse_success() { let jid: Jid = "1234567890@s.whatsapp.net".parse().unwrap(); diff --git a/wacore/src/iq/groups.rs b/wacore/src/iq/groups.rs index 6de192087..8a962a697 100644 --- a/wacore/src/iq/groups.rs +++ b/wacore/src/iq/groups.rs @@ -7,7 +7,7 @@ use anyhow::{Result, anyhow}; use std::num::NonZeroU32; use typed_builder::TypedBuilder; use wacore_binary::builder::NodeBuilder; -use wacore_binary::{Jid, Server}; +use wacore_binary::{CompactString, Jid, Server}; use wacore_binary::{Node, NodeContent, NodeRef}; // Re-export AddressingMode from types::message for convenience @@ -31,6 +31,19 @@ pub const BATCH_GROUP_INFO_LIMIT: usize = 10_000; /// Maximum number of pictures in a batch profile picture query. pub const BATCH_PROFILE_PICTURES_LIMIT: usize = 1_000; +/// Maximum participant count accepted in a group-info response. +pub const GROUP_INFO_PARTICIPANT_LIMIT: u32 = 19_999; + +/// Maximum disappearing-message expiration accepted in group metadata. +pub const GROUP_EPHEMERAL_EXPIRATION_MAX: u32 = i32::MAX as u32; +/// Maximum source trigger accepted by group settings. +pub const GROUP_SETTING_TRIGGER_MAX: u32 = 20; +/// Maximum schema evolution version accepted in group metadata. +pub const GROUP_EVOLUTION_VERSION_MAX: u32 = 100; + +/// Max for ``, retained as the setting-specific public name. +pub const EPHEMERAL_TRIGGER_MAX: u32 = GROUP_SETTING_TRIGGER_MAX; + /// Member link mode for group invite links. #[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)] pub enum MemberLinkMode { @@ -69,6 +82,19 @@ pub enum MemberShareHistoryMode { AllMemberShare, } +/// Review state for an appeal on a suspended group. +#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)] +pub enum GroupAppealStatus { + #[wire = "approved"] + Approved, + #[wire = "in_review"] + InReview, + #[wire = "none"] + NoAppeal, + #[wire = "rejected"] + Rejected, +} + /// Growth lock info (system-managed, read-only). #[derive(Debug, Clone, PartialEq, Eq)] pub struct GrowthLockInfo { @@ -494,6 +520,106 @@ pub struct GroupQueryRequest { pub request: GroupQueryRequestType, } +fn optional_u64_attr(node: &NodeRef<'_>, name: &str) -> Result> { + let Some(value) = node.attrs().optional_string(name) else { + return Ok(None); + }; + value + .parse() + .map(Some) + .map_err(|error| anyhow!("invalid '{name}' attribute '{value}': {error}")) +} + +fn optional_bounded_u32_attr(node: &NodeRef<'_>, name: &str, maximum: u32) -> Result> { + let Some(value) = optional_u64_attr(node, name)? else { + return Ok(None); + }; + let value = u32::try_from(value) + .map_err(|_| anyhow!("'{name}' attribute exceeds {maximum}: {value}"))?; + if value > maximum { + return Err(anyhow!("'{name}' attribute exceeds {maximum}: {value}")); + } + Ok(Some(value)) +} + +/// Less-common participant metadata, allocated only when at least one field is present. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct GroupParticipantDetails { + /// Server-defined label associated with this participant. + pub participant_label: Option, + /// Modification timestamp for `participant_label`. + pub participant_label_mtime: Option, + /// Timestamp when the participant joined the group. + pub join_time: Option, + /// Whether initial group history was delivered to the participant. + pub group_history_sent: Option, + /// Server-rendered participant display name. + pub display_name: Option, + /// Whether the participant may be addressed directly. + pub is_addressable: bool, +} + +impl Default for GroupParticipantDetails { + fn default() -> Self { + Self { + participant_label: None, + participant_label_mtime: None, + join_time: None, + group_history_sent: None, + display_name: None, + is_addressable: true, + } + } +} + +impl GroupParticipantDetails { + fn from_node(node: &NodeRef<'_>) -> Result>> { + let mut attrs = node.attrs(); + let participant_label = attrs + .optional_string("participant_label") + .map(|value| CompactString::from(value.as_ref())); + let group_history_sent = attrs.optional_bool_value("group_history_sent"); + let display_name = attrs + .optional_string("display_name") + .map(|value| CompactString::from(value.as_ref())); + let is_addressable = attrs.optional_bool_value("addressable").unwrap_or(true); + attrs.finish()?; + + let details = Self { + participant_label, + participant_label_mtime: optional_u64_attr(node, "participant_label_mtime")?, + join_time: optional_u64_attr(node, "join_time")?, + group_history_sent, + display_name, + is_addressable, + }; + Ok((details != Self::default()).then(|| Box::new(details))) + } + + fn apply_to_builder(self, mut builder: NodeBuilder) -> NodeBuilder { + if let Some(value) = self.participant_label { + builder = builder.attr("participant_label", value); + } + if let Some(value) = self.participant_label_mtime { + builder = builder.attr("participant_label_mtime", value); + } + if let Some(value) = self.join_time { + builder = builder.attr("join_time", value); + } + if let Some(value) = self.group_history_sent { + builder = builder.attr("group_history_sent", if value { "true" } else { "false" }); + } + if let Some(value) = self.display_name { + builder = builder.attr("display_name", value); + } + if !self.is_addressable { + builder = builder.attr("addressable", "false"); + } + builder + } +} + /// A participant in a group response. #[derive(Debug, Clone)] #[non_exhaustive] @@ -501,8 +627,9 @@ pub struct GroupParticipantResponse { pub jid: Jid, pub phone_number: Option, pub lid: Option, - pub username: Option, + pub username: Option, pub participant_type: ParticipantType, + pub details: Option>, } impl ProtocolNode for GroupParticipantResponse { @@ -519,11 +646,14 @@ impl ProtocolNode for GroupParticipantResponse { builder = builder.attr("lid", lid); } if let Some(username) = self.username { - builder = builder.attr("participant_username", username); + builder = builder.attr("username", username); } if self.participant_type != ParticipantType::Member { builder = builder.attr("type", self.participant_type.as_str()); } + if let Some(details) = self.details { + builder = details.apply_to_builder(builder); + } builder.build() } @@ -538,9 +668,9 @@ impl ProtocolNode for GroupParticipantResponse { let phone_number = attrs.optional_jid("phone_number"); let lid = attrs.optional_jid("lid"); let username = attrs - .optional_string("participant_username") - .or_else(|| attrs.optional_string("username")) - .map(|value| value.into_owned()); + .optional_string("username") + .or_else(|| attrs.optional_string("participant_username")) + .map(|value| CompactString::from(value.as_ref())); let participant_type = attrs .optional_string("type") .and_then(|s| ParticipantType::try_from(s.as_ref()).ok()) @@ -552,6 +682,7 @@ impl ProtocolNode for GroupParticipantResponse { lid, username, participant_type, + details: GroupParticipantDetails::from_node(node)?, }) } } @@ -584,14 +715,13 @@ impl ProtocolNode for GroupEphemeralSettings { return Err(anyhow!("expected , got <{}>", node.tag)); } - let mut attrs = node.attrs(); Ok(Self { - expiration: attrs - .optional_string("expiration") - .and_then(|value| value.parse().ok()), - trigger: attrs - .optional_string("trigger") - .and_then(|value| value.parse().ok()), + expiration: optional_bounded_u32_attr( + node, + "expiration", + GROUP_EPHEMERAL_EXPIRATION_MAX, + )?, + trigger: optional_bounded_u32_attr(node, "trigger", GROUP_SETTING_TRIGGER_MAX)?, }) } } @@ -616,6 +746,14 @@ pub struct GroupInfoResponse { pub creator_country_code: Option, /// Group creation timestamp (from `creation` attribute). pub creation_time: Option, + /// Participant-list version identifier (from `p_v_id`). + pub participant_version_id: Option, + /// Admin-list version identifier (from `a_v_id`). + pub admin_version_id: Option, + /// Open thread identifier associated with the group. + pub open_thread_id: Option, + /// Whether participant identity information was incomplete in this response. + pub has_missing_participant_identification: bool, /// Subject modification timestamp (from `s_t` attribute). pub subject_time: Option, /// Subject owner JID (from `s_o` attribute). @@ -652,6 +790,8 @@ pub struct GroupInfoResponse { pub size: Option, /// Whether this group is a community parent group (has `` child). pub is_parent_group: bool, + /// Whether joins to this parent group require approval by default. + pub parent_membership_approval_required: bool, /// JID of the parent community (for subgroups, from ``). pub parent_group_jid: Option, /// Whether this is the default announcement subgroup of a community. @@ -668,6 +808,14 @@ pub struct GroupInfoResponse { pub growth_locked: Option, /// Whether the group is suspended. pub is_suspended: bool, + /// Whether a suspension appeal may be filed automatically. + pub suspension_can_auto_file: bool, + /// Current suspension-appeal state. + pub appeal_status: Option, + /// Last suspension-appeal update timestamp. + pub appeal_update_time: Option, + /// Whether the group is marked as a support group. + pub is_support_group: bool, /// Whether admin reports are allowed. pub allow_admin_reports: bool, /// Whether the group is hidden. @@ -676,8 +824,20 @@ pub struct GroupInfoResponse { pub is_incognito: bool, /// Whether group history is enabled. pub has_group_history: bool, + /// Whether automatic participant addition is disabled. + pub is_auto_add_disabled: bool, + /// Whether the group carries the CAPI capability marker. + pub has_capi: bool, + /// Group schema evolution version. + pub evolution_version: Option, + /// Whether the group safety-check feature is enabled. + pub has_group_safety_check: bool, + /// Whether participant labels are enabled. + pub participant_label_enabled: bool, /// Whether limit sharing is enabled. pub is_limit_sharing_enabled: bool, + /// Source trigger for limit-sharing enablement. + pub limit_sharing_trigger: Option, } impl ProtocolNode for GroupInfoResponse { @@ -692,6 +852,10 @@ impl ProtocolNode for GroupInfoResponse { .map(|p| p.into_node()) .collect(); + if self.has_missing_participant_identification { + children.push(NodeBuilder::new("missing_participant_identification").build()); + } + if self.is_locked { children.push(NodeBuilder::new("locked").build()); } @@ -757,7 +921,11 @@ impl ProtocolNode for GroupInfoResponse { // Community fields if self.is_parent_group { - children.push(NodeBuilder::new("parent").build()); + let mut parent = NodeBuilder::new("parent"); + if self.parent_membership_approval_required { + parent = parent.attr("default_membership_approval_mode", "request_required"); + } + children.push(parent.build()); } if let Some(ref parent_jid) = self.parent_group_jid { children.push( @@ -793,8 +961,29 @@ impl ProtocolNode for GroupInfoResponse { .build(), ); } + if self.is_support_group { + children.push(NodeBuilder::new("support").build()); + } if self.is_suspended { - children.push(NodeBuilder::new("suspended").build()); + let mut suspended = NodeBuilder::new("suspended"); + if self.suspension_can_auto_file { + suspended = suspended.attr("can_auto_file", "true"); + } + children.push(suspended.build()); + } + if let Some(status) = self.appeal_status { + children.push( + NodeBuilder::new("appeal_status") + .attr("type", status.as_str()) + .build(), + ); + } + if let Some(value) = self.appeal_update_time { + children.push( + NodeBuilder::new("appeal_update_time") + .attr("value", value) + .build(), + ); } if self.allow_admin_reports { children.push(NodeBuilder::new("allow_admin_reports").build()); @@ -808,8 +997,31 @@ impl ProtocolNode for GroupInfoResponse { if self.has_group_history { children.push(NodeBuilder::new("group_history").build()); } + if self.is_auto_add_disabled { + children.push(NodeBuilder::new("auto_add_disabled").build()); + } + if self.has_capi { + children.push(NodeBuilder::new("capi").build()); + } + if let Some(value) = self.evolution_version { + children.push( + NodeBuilder::new("evolution_version") + .attr("value", value) + .build(), + ); + } + if self.has_group_safety_check { + children.push(NodeBuilder::new("group_safety_check").build()); + } + if self.participant_label_enabled { + children.push(NodeBuilder::new("participant_label_enabled").build()); + } if self.is_limit_sharing_enabled { - children.push(NodeBuilder::new("limit_sharing_enabled").build()); + let mut limit_sharing = NodeBuilder::new("limit_sharing_enabled"); + if let Some(trigger) = self.limit_sharing_trigger { + limit_sharing = limit_sharing.attr("trigger", trigger); + } + children.push(limit_sharing.build()); } let mut builder = NodeBuilder::new("group") @@ -835,6 +1047,15 @@ impl ProtocolNode for GroupInfoResponse { if let Some(creation_time) = self.creation_time { builder = builder.attr("creation", creation_time); } + if let Some(participant_version_id) = self.participant_version_id { + builder = builder.attr("p_v_id", participant_version_id); + } + if let Some(admin_version_id) = self.admin_version_id { + builder = builder.attr("a_v_id", admin_version_id); + } + if let Some(open_thread_id) = self.open_thread_id { + builder = builder.attr("open_thread_id", open_thread_id); + } if let Some(subject_time) = self.subject_time { builder = builder.attr("s_t", subject_time); } @@ -856,8 +1077,11 @@ impl ProtocolNode for GroupInfoResponse { fn try_from_node_ref(node: &NodeRef<'_>) -> Result { use wacore_binary::NodeContentRef; - if node.tag != "group" { - return Err(anyhow!("expected , got <{}>", node.tag)); + if node.tag != "group" && node.tag != "community" { + return Err(anyhow!( + "expected or , got <{}>", + node.tag + )); } let mut attrs = node.attrs(); @@ -897,17 +1121,33 @@ impl ProtocolNode for GroupInfoResponse { .optional_string("creator_country_code") .map(|value| value.into_owned()); let creation_time = attrs.optional_u64("creation"); + let participant_version_id = attrs + .optional_string("p_v_id") + .map(|value| value.into_owned()); + let admin_version_id = attrs + .optional_string("a_v_id") + .map(|value| value.into_owned()); + let open_thread_id = attrs + .optional_string("open_thread_id") + .map(|value| value.into_owned()); + let has_missing_participant_identification = node + .get_optional_child_by_tag(&["missing_participant_identification"]) + .is_some(); let subject_time = attrs.optional_u64("s_t"); let subject_owner = attrs.optional_jid("s_o"); let subject_owner_pn = attrs.optional_jid("s_o_pn"); let subject_owner_username = attrs .optional_string("s_o_username") .map(|value| value.into_owned()); - let size = attrs - .optional_string("size") - .and_then(|s| s.parse::().ok()); + let size = optional_bounded_u32_attr(node, "size", GROUP_INFO_PARTICIPANT_LIMIT)?; let participants = collect_children::(node, "participant")?; + if participants.len() > GROUP_INFO_PARTICIPANT_LIMIT as usize { + return Err(anyhow!( + "group-info participant count exceeds {GROUP_INFO_PARTICIPANT_LIMIT}: {}", + participants.len() + )); + } let is_locked = node.get_optional_child_by_tag(&["locked"]).is_some(); let is_announcement = node.get_optional_child_by_tag(&["announcement"]).is_some(); @@ -955,7 +1195,15 @@ impl ProtocolNode for GroupInfoResponse { .and_then(|n| n.attrs().optional_string("t")) .and_then(|s| s.parse::().ok()); - let is_parent_group = node.get_optional_child_by_tag(&["parent"]).is_some(); + let parent_node = node.get_optional_child_by_tag(&["parent"]); + let is_parent_group = parent_node.is_some(); + let parent_membership_approval_required = parent_node + .and_then(|parent| { + parent + .attrs() + .optional_string("default_membership_approval_mode") + }) + .is_some_and(|value| value == "request_required"); let parent_group_jid = node .get_optional_child_by_tag(&["linked_parent"]) .and_then(|n| n.attrs().optional_jid("jid")); @@ -994,16 +1242,64 @@ impl ProtocolNode for GroupInfoResponse { } }); - let is_suspended = node.get_optional_child_by_tag(&["suspended"]).is_some(); + let is_support_group = node.get_optional_child_by_tag(&["support"]).is_some(); + let suspended_node = node.get_optional_child_by_tag(&["suspended"]); + let is_suspended = suspended_node.is_some(); + let suspension_can_auto_file = match suspended_node { + None => false, + Some(suspended) => { + let mut attrs = suspended.attrs(); + let can_auto_file = attrs.optional_bool("can_auto_file"); + attrs.finish()?; + can_auto_file + } + }; + let appeal_status = + node.get_optional_child_by_tag(&["appeal_status"]) + .map(|appeal| { + let value = appeal.attrs().optional_string("type").ok_or_else(|| { + anyhow!("appeal_status missing required 'type' attribute") + })?; + GroupAppealStatus::try_from(value.as_ref()) + .map_err(|_| anyhow!("invalid appeal status '{value}'")) + }) + .transpose()?; + let appeal_update_time = node + .get_optional_child_by_tag(&["appeal_update_time"]) + .map(|appeal| { + optional_u64_attr(appeal, "value")? + .ok_or_else(|| anyhow!("appeal_update_time missing required 'value' attribute")) + }) + .transpose()?; let allow_admin_reports = node .get_optional_child_by_tag(&["allow_admin_reports"]) .is_some(); let is_hidden_group = node.get_optional_child_by_tag(&["hidden_group"]).is_some(); let is_incognito = node.get_optional_child_by_tag(&["incognito"]).is_some(); let has_group_history = node.get_optional_child_by_tag(&["group_history"]).is_some(); - let is_limit_sharing_enabled = node - .get_optional_child_by_tag(&["limit_sharing_enabled"]) + let is_auto_add_disabled = node + .get_optional_child_by_tag(&["auto_add_disabled"]) .is_some(); + let has_capi = node.get_optional_child_by_tag(&["capi"]).is_some(); + let evolution_version = node + .get_optional_child_by_tag(&["evolution_version"]) + .map(|evolution| { + optional_bounded_u32_attr(evolution, "value", GROUP_EVOLUTION_VERSION_MAX)? + .ok_or_else(|| anyhow!("evolution_version missing required 'value' attribute")) + }) + .transpose()?; + let has_group_safety_check = node + .get_optional_child_by_tag(&["group_safety_check"]) + .is_some(); + let participant_label_enabled = node + .get_optional_child_by_tag(&["participant_label_enabled"]) + .is_some(); + let limit_sharing_node = node.get_optional_child_by_tag(&["limit_sharing_enabled"]); + let is_limit_sharing_enabled = limit_sharing_node.is_some(); + let limit_sharing_trigger = limit_sharing_node + .map(|limit| optional_bounded_u32_attr(limit, "trigger", GROUP_SETTING_TRIGGER_MAX)) + .transpose()? + .flatten(); Ok(Self { id, @@ -1016,6 +1312,10 @@ impl ProtocolNode for GroupInfoResponse { creator_username, creator_country_code, creation_time, + participant_version_id, + admin_version_id, + open_thread_id, + has_missing_participant_identification, subject_time, subject_owner, subject_owner_pn, @@ -1034,6 +1334,7 @@ impl ProtocolNode for GroupInfoResponse { member_link_mode, size, is_parent_group, + parent_membership_approval_required, parent_group_jid, is_default_sub_group, is_general_chat, @@ -1042,11 +1343,21 @@ impl ProtocolNode for GroupInfoResponse { member_share_history_mode, growth_locked, is_suspended, + suspension_can_auto_file, + appeal_status, + appeal_update_time, + is_support_group, allow_admin_reports, is_hidden_group, is_incognito, has_group_history, + is_auto_add_disabled, + has_capi, + evolution_version, + has_group_safety_check, + participant_label_enabled, is_limit_sharing_enabled, + limit_sharing_trigger, }) } } @@ -1117,11 +1428,17 @@ impl ProtocolNode for GroupParticipatingResponse { } fn try_from_node_ref(node: &NodeRef<'_>) -> Result { - if node.tag != "groups" { - return Err(anyhow!("expected , got <{}>", node.tag)); - } - - let groups = collect_children::(node, "group")?; + let child_tag = match node.tag.as_ref() { + "groups" => "group", + "communities" => "community", + _ => { + return Err(anyhow!( + "expected or , got <{}>", + node.tag + )); + } + }; + let groups = collect_children::(node, child_tag)?; Ok(Self { groups }) } @@ -1180,7 +1497,10 @@ impl IqSpec for GroupQueryIq { } fn parse_response(&self, response: &NodeRef<'_>) -> Result { - match response.get_optional_child("group") { + match response + .get_optional_child("group") + .or_else(|| response.get_optional_child("community")) + { Some(group_node) => Ok(GroupInfoOutcome::Full(Box::new( GroupInfoResponse::try_from_node_ref(group_node)?, ))), @@ -1203,21 +1523,98 @@ impl IqSpec for GroupParticipatingIq { type Response = GroupParticipatingResponse; fn build_iq(&self) -> InfoQuery<'static> { - InfoQuery::get( - GROUP_IQ_NAMESPACE, - Jid::new("", Server::Group), - Some(NodeContent::Nodes(vec![ - GroupParticipatingRequest::new().into_node(), - ])), - ) + build_participating_iq() } fn parse_response(&self, response: &NodeRef<'_>) -> Result { - let groups_node = required_child(response, "groups")?; - GroupParticipatingResponse::try_from_node_ref(groups_node) + if has_participating_shape(response, "groups", "group") { + parse_participating_response(response, "groups", "group") + } else { + parse_community_participating_response(response) + } + } +} + +/// IQ specification for getting all parent groups the user participates in. +#[derive(Debug, Clone, Default)] +pub struct CommunityParticipatingIq; + +impl CommunityParticipatingIq { + pub fn new() -> Self { + Self } } +impl IqSpec for CommunityParticipatingIq { + type Response = GroupParticipatingResponse; + + fn build_iq(&self) -> InfoQuery<'static> { + build_participating_iq() + } + + fn parse_response(&self, response: &NodeRef<'_>) -> Result { + if has_participating_shape(response, "communities", "community") { + return parse_community_participating_response(response); + } + + let mut result = parse_participating_response(response, "groups", "group")?; + result.groups.retain(|group| group.is_parent_group); + Ok(result) + } +} + +fn parse_community_participating_response( + response: &NodeRef<'_>, +) -> Result { + let mut result = parse_participating_response(response, "communities", "community")?; + for community in &mut result.groups { + community.is_parent_group = true; + } + Ok(result) +} + +fn build_participating_iq() -> InfoQuery<'static> { + InfoQuery::get( + GROUP_IQ_NAMESPACE, + Jid::new("", Server::Group), + Some(NodeContent::Nodes(vec![ + GroupParticipatingRequest::new().into_node(), + ])), + ) +} + +fn has_participating_shape( + response: &NodeRef<'_>, + container_tag: &'static str, + child_tag: &'static str, +) -> bool { + response.tag == container_tag + || response.get_optional_child(container_tag).is_some() + || response.get_optional_child(child_tag).is_some() +} + +fn parse_participating_response( + response: &NodeRef<'_>, + container_tag: &'static str, + child_tag: &'static str, +) -> Result { + if response.tag == container_tag { + return GroupParticipatingResponse::try_from_node_ref(response); + } + + if let Some(container) = response.get_optional_child(container_tag) { + return GroupParticipatingResponse::try_from_node_ref(container); + } + + let groups = collect_children::(response, child_tag)?; + if groups.is_empty() { + return Err(anyhow!( + "missing <{container_tag}> or direct <{child_tag}> participating result" + )); + } + Ok(GroupParticipatingResponse { groups }) +} + /// IQ specification for creating a new group. #[derive(Debug, Clone)] pub struct GroupCreateIq { @@ -1246,7 +1643,10 @@ impl IqSpec for GroupCreateIq { } fn parse_response(&self, response: &NodeRef<'_>) -> Result { - let group_node = required_child(response, "group")?; + let group_node = response + .get_optional_child("group") + .or_else(|| response.get_optional_child("community")) + .ok_or_else(|| anyhow!("missing group or community create result"))?; let mut info = GroupInfoResponse::try_from_node_ref(group_node)?; // Server may omit `` from a community-create reply; overlay @@ -1257,6 +1657,7 @@ impl IqSpec for GroupCreateIq { info.parent_group_jid.is_some() || self.options.linked_parent.is_some(); if self.options.is_parent && !is_linked_subgroup { info.is_parent_group = true; + info.parent_membership_approval_required |= self.options.closed; info.allow_non_admin_sub_group_creation |= self.options.allow_non_admin_sub_group_creation; } @@ -1541,13 +1942,18 @@ fn build_participant_action_iq( group_jid: &Jid, action: &'static str, participants: &[Jid], + include_linked_groups: bool, ) -> InfoQuery<'static> { let children: Vec = participants .iter() .map(|jid| NodeBuilder::new("participant").attr("jid", jid).build()) .collect(); - let action_node = NodeBuilder::new(action).children(children).build(); + let mut action_node = NodeBuilder::new(action); + if include_linked_groups { + action_node = action_node.attr("linked_groups", "true"); + } + let action_node = action_node.children(children).build(); InfoQuery::set_ref( GROUP_IQ_NAMESPACE, @@ -1583,7 +1989,7 @@ macro_rules! define_group_participant_iq { type Response = Vec; fn build_iq(&self) -> InfoQuery<'static> { - build_participant_action_iq(&self.group_jid, $action, &self.participants) + build_participant_action_iq(&self.group_jid, $action, &self.participants, false) } fn parse_response(&self, response: &NodeRef<'_>) -> Result { @@ -1616,7 +2022,7 @@ macro_rules! define_group_participant_iq { type Response = (); fn build_iq(&self) -> InfoQuery<'static> { - build_participant_action_iq(&self.group_jid, $action, &self.participants) + build_participant_action_iq(&self.group_jid, $action, &self.participants, false) } fn parse_response(&self, _response: &NodeRef<'_>) -> Result { @@ -1710,6 +2116,36 @@ define_group_participant_iq!( RemoveParticipantsIq, action = "remove", response = Vec ); +/// IQ specification for removing participants from a parent group and all of +/// its linked groups in the same server operation. +#[derive(Debug, Clone)] +pub struct RemoveParticipantsIncludingLinkedGroupsIq { + pub group_jid: Jid, + pub participants: Vec, +} + +impl RemoveParticipantsIncludingLinkedGroupsIq { + pub fn new(group_jid: &Jid, participants: &[Jid]) -> Self { + Self { + group_jid: group_jid.clone(), + participants: participants.to_vec(), + } + } +} + +impl IqSpec for RemoveParticipantsIncludingLinkedGroupsIq { + type Response = Vec; + + fn build_iq(&self) -> InfoQuery<'static> { + build_participant_action_iq(&self.group_jid, "remove", &self.participants, true) + } + + fn parse_response(&self, response: &NodeRef<'_>) -> Result { + let action_node = required_child(response, "remove")?; + collect_children::(action_node, "participant") + } +} + define_group_participant_iq!( /// IQ specification for promoting participants to admin. /// @@ -1719,7 +2155,7 @@ define_group_participant_iq!( /// /// /// ``` - PromoteParticipantsIq, action = "promote", response = () + PromoteParticipantsIq, action = "promote", response = Vec ); define_group_participant_iq!( @@ -1731,7 +2167,7 @@ define_group_participant_iq!( /// /// /// ``` - DemoteParticipantsIq, action = "demote", response = () + DemoteParticipantsIq, action = "demote", response = Vec ); /// IQ specification for getting (or resetting) a group's invite link. @@ -1881,9 +2317,6 @@ impl IqSpec for SetGroupAnnouncementIq { } } -/// Max for ``, per `WASmaxInGroupsGroupInfoMixin`. -pub const EPHEMERAL_TRIGGER_MAX: u32 = 20; - /// IQ specification for setting ephemeral (disappearing) messages on a group. /// /// Wire format: @@ -2473,7 +2906,10 @@ fn parse_group_id(id_str: &str) -> Result { /// Shared response parser for group join IQs (both code-based and V4 invite). fn parse_join_group_response(response: &NodeRef<'_>) -> Result { - if let Some(group_node) = response.get_optional_child("group") { + if let Some(group_node) = response + .get_optional_child("group") + .or_else(|| response.get_optional_child("community")) + { let jid_str = required_attr(group_node, "jid")?; let jid: Jid = jid_str .parse() @@ -2488,7 +2924,7 @@ fn parse_join_group_response(response: &NodeRef<'_>) -> Result return Ok(JoinGroupResult::PendingApproval(jid)); } Err(anyhow!( - "expected or in join response" + "expected , , or in join response" )) } @@ -2610,7 +3046,10 @@ impl IqSpec for GetGroupInviteInfoIq { } fn parse_response(&self, response: &NodeRef<'_>) -> Result { - let group_node = required_child(response, "group")?; + let group_node = response + .get_optional_child("group") + .or_else(|| response.get_optional_child("community")) + .ok_or_else(|| anyhow!("missing group or community invite result"))?; GroupInfoResponse::try_from_node_ref(group_node) } } @@ -3124,6 +3563,132 @@ mod tests { )); } + #[test] + fn participating_iqs_select_their_own_container() { + let response = NodeBuilder::new("iq") + .children([ + NodeBuilder::new("groups") + .children([NodeBuilder::new("group") + .attr("id", "120363000000000001@g.us") + .attr("subject", "Regular group") + .build()]) + .build(), + NodeBuilder::new("communities") + .children([NodeBuilder::new("community") + .attr("id", "120363000000000002@g.us") + .attr("subject", "Parent group") + .build()]) + .build(), + ]) + .build(); + + let groups = GroupParticipatingIq::new() + .parse_response(&response.as_node_ref()) + .unwrap(); + let communities = CommunityParticipatingIq::new() + .parse_response(&response.as_node_ref()) + .unwrap(); + + assert_eq!(groups.groups.len(), 1); + assert_eq!(groups.groups[0].subject.as_str(), "Regular group"); + assert_eq!(communities.groups.len(), 1); + assert_eq!(communities.groups[0].subject.as_str(), "Parent group"); + assert!(communities.groups[0].is_parent_group); + } + + #[test] + fn community_container_marks_entries_as_parent_groups() { + let response = NodeBuilder::new("iq") + .children([NodeBuilder::new("communities") + .children([NodeBuilder::new("community") + .attr("id", "120363000000000003@g.us") + .attr("subject", "Parent without redundant marker") + .build()]) + .build()]) + .build(); + + let groups = GroupParticipatingIq::new() + .parse_response(&response.as_node_ref()) + .unwrap(); + let communities = CommunityParticipatingIq::new() + .parse_response(&response.as_node_ref()) + .unwrap(); + + assert!(groups.groups[0].is_parent_group); + assert!(communities.groups[0].is_parent_group); + } + + #[test] + fn participating_iqs_accept_direct_group_children() { + let response = NodeBuilder::new("iq") + .children([ + NodeBuilder::new("group") + .attr("id", "120363000000000001@g.us") + .attr("subject", "Regular group") + .build(), + NodeBuilder::new("group") + .attr("id", "120363000000000002@g.us") + .attr("subject", "Parent group") + .children([NodeBuilder::new("parent").build()]) + .build(), + ]) + .build(); + + let groups = GroupParticipatingIq::new() + .parse_response(&response.as_node_ref()) + .unwrap(); + let communities = CommunityParticipatingIq::new() + .parse_response(&response.as_node_ref()) + .unwrap(); + + assert_eq!(groups.groups.len(), 2); + assert_eq!(communities.groups.len(), 1); + assert_eq!(communities.groups[0].subject.as_str(), "Parent group"); + } + + #[test] + fn participating_iqs_preserve_errors_from_the_selected_shape() { + let malformed_groups = NodeBuilder::new("iq") + .children([ + NodeBuilder::new("groups") + .children([NodeBuilder::new("group") + .attr("id", "120363000000000001@g.us") + .attr("size", GROUP_INFO_PARTICIPANT_LIMIT + 1) + .build()]) + .build(), + NodeBuilder::new("communities") + .children([NodeBuilder::new("community") + .attr("id", "120363000000000002@g.us") + .build()]) + .build(), + ]) + .build(); + let error = GroupParticipatingIq::new() + .parse_response(&malformed_groups.as_node_ref()) + .unwrap_err(); + assert!(error.to_string().contains("'size' attribute exceeds")); + + let malformed_direct_community = NodeBuilder::new("iq") + .children([ + NodeBuilder::new("groups") + .children([NodeBuilder::new("group") + .attr("id", "120363000000000003@g.us") + .build()]) + .build(), + NodeBuilder::new("community") + .attr("id", "120363000000000004@g.us") + .children([NodeBuilder::new("evolution_version") + .attr("value", GROUP_EVOLUTION_VERSION_MAX + 1) + .build()]) + .build(), + ]) + .build(); + let error = CommunityParticipatingIq::new() + .parse_response(&malformed_direct_community.as_node_ref()) + .unwrap_err(); + assert!(error.to_string().contains("'value' attribute exceeds")); + } + #[test] fn test_group_subject_validation() { let subject = GroupSubject::new("Test Group").unwrap(); @@ -3394,6 +3959,25 @@ mod tests { } } + #[test] + fn test_remove_participants_including_linked_groups_iq() { + let parent: Jid = "120363000000000001@g.us".parse().unwrap(); + let participant: Jid = "1234567890@s.whatsapp.net".parse().unwrap(); + let spec = RemoveParticipantsIncludingLinkedGroupsIq::new(&parent, &[participant]); + let iq = spec.build_iq(); + + if let Some(NodeContent::Nodes(nodes)) = &iq.content { + assert_eq!(nodes[0].tag, "remove"); + assert_eq!( + nodes[0].attrs().optional_string("linked_groups").as_deref(), + Some("true") + ); + assert_eq!(nodes[0].get_children_by_tag("participant").count(), 1); + } else { + panic!("expected nodes content"); + } + } + #[test] fn test_promote_demote_iq() { let group: Jid = "120363000000000001@g.us".parse().unwrap(); @@ -3407,13 +3991,39 @@ mod tests { panic!("expected nodes content"); } - let demote = DemoteParticipantsIq::new(&group, &[p1]); + let promote_response = NodeBuilder::new("iq") + .children([NodeBuilder::new("promote") + .children([NodeBuilder::new("participant") + .attr("jid", &p1) + .attr("type", "admin") + .build()]) + .build()]) + .build(); + let promoted = promote + .parse_response(&promote_response.as_node_ref()) + .unwrap(); + assert_eq!(promoted.len(), 1); + assert_eq!(promoted[0].jid, p1); + assert_eq!(promoted[0].status.as_deref(), Some("admin")); + + let demote = DemoteParticipantsIq::new(&group, std::slice::from_ref(&p1)); let iq = demote.build_iq(); if let Some(NodeContent::Nodes(nodes)) = &iq.content { assert_eq!(nodes[0].tag, "demote"); } else { panic!("expected nodes content"); } + + let demote_response = NodeBuilder::new("iq") + .children([NodeBuilder::new("demote") + .children([NodeBuilder::new("participant").attr("jid", &p1).build()]) + .build()]) + .build(); + let demoted = demote + .parse_response(&demote_response.as_node_ref()) + .unwrap(); + assert_eq!(demoted.len(), 1); + assert!(demoted[0].is_ok()); } #[test] @@ -3723,6 +4333,26 @@ mod tests { // Community IQ spec tests // ----------------------------------------------------------------------- + #[test] + fn participating_groups_accepts_current_and_legacy_envelopes() { + let spec = GroupParticipatingIq::new(); + for (container_tag, item_tag) in [("groups", "group"), ("communities", "community")] { + let response = NodeBuilder::new("iq") + .children([NodeBuilder::new(container_tag) + .children([NodeBuilder::new(item_tag) + .attr("id", "120363000000000041@g.us") + .attr("subject", "Fictitious parent") + .children([NodeBuilder::new("parent").build()]) + .build()]) + .build()]) + .build(); + + let groups = spec.parse_response(&response.as_node_ref()).unwrap().groups; + assert_eq!(groups.len(), 1); + assert!(groups[0].is_parent_group); + } + } + #[test] fn test_build_create_community_node() { let options = GroupCreateOptions { @@ -4014,10 +4644,38 @@ mod tests { .attr("creator_pn", "15550000010@s.whatsapp.net") .attr("creator_username", "fixture.creator") .attr("creator_country_code", "US") + .attr("p_v_id", "participants-v1") + .attr("a_v_id", "admins-v1") + .attr("open_thread_id", "thread-v1") + .attr("size", 2u32) .attr("s_o", "100000000000011@lid") .attr("s_o_pn", "15550000011@s.whatsapp.net") .attr("s_o_username", "fixture.subject") .children([ + NodeBuilder::new("missing_participant_identification").build(), + NodeBuilder::new("parent") + .attr("default_membership_approval_mode", "request_required") + .build(), + NodeBuilder::new("support").build(), + NodeBuilder::new("suspended") + .attr("can_auto_file", "true") + .build(), + NodeBuilder::new("appeal_status") + .attr("type", "in_review") + .build(), + NodeBuilder::new("appeal_update_time") + .attr("value", 1_700_000_099u64) + .build(), + NodeBuilder::new("auto_add_disabled").build(), + NodeBuilder::new("capi").build(), + NodeBuilder::new("evolution_version") + .attr("value", GROUP_EVOLUTION_VERSION_MAX) + .build(), + NodeBuilder::new("group_safety_check").build(), + NodeBuilder::new("participant_label_enabled").build(), + NodeBuilder::new("limit_sharing_enabled") + .attr("trigger", GROUP_SETTING_TRIGGER_MAX) + .build(), NodeBuilder::new("description") .attr("id", "fixture-description") .attr("participant", "100000000000012@lid") @@ -4035,8 +4693,14 @@ mod tests { NodeBuilder::new("participant") .attr("jid", "100000000000013@lid") .attr("phone_number", "15550000013@s.whatsapp.net") - .attr("participant_username", "fixture.member") + .attr("username", "fixture.member") .attr("type", "superadmin") + .attr("participant_label", "organizer") + .attr("participant_label_mtime", 1_700_000_013u64) + .attr("join_time", 1_700_000_014u64) + .attr("group_history_sent", "0") + .attr("display_name", "Fixture Member") + .attr("addressable", "0") .build(), NodeBuilder::new("participant") .attr("jid", "15550000014@s.whatsapp.net") @@ -4058,6 +4722,32 @@ mod tests { Some("fixture.creator") ); assert_eq!(response.creator_country_code.as_deref(), Some("US")); + assert_eq!( + response.participant_version_id.as_deref(), + Some("participants-v1") + ); + assert_eq!(response.admin_version_id.as_deref(), Some("admins-v1")); + assert_eq!(response.open_thread_id.as_deref(), Some("thread-v1")); + assert!(response.has_missing_participant_identification); + assert!(response.parent_membership_approval_required); + assert!(response.is_support_group); + assert!(response.is_suspended); + assert!(response.suspension_can_auto_file); + assert_eq!(response.appeal_status, Some(GroupAppealStatus::InReview)); + assert_eq!(response.appeal_update_time, Some(1_700_000_099)); + assert!(response.is_auto_add_disabled); + assert!(response.has_capi); + assert_eq!( + response.evolution_version, + Some(GROUP_EVOLUTION_VERSION_MAX) + ); + assert!(response.has_group_safety_check); + assert!(response.participant_label_enabled); + assert!(response.is_limit_sharing_enabled); + assert_eq!( + response.limit_sharing_trigger, + Some(GROUP_SETTING_TRIGGER_MAX) + ); assert_eq!( response.subject_owner_pn, Some("15550000011@s.whatsapp.net".parse().unwrap()) @@ -4093,6 +4783,16 @@ mod tests { response.participants[0].username.as_deref(), Some("fixture.member") ); + let details = response.participants[0] + .details + .as_deref() + .expect("participant details"); + assert_eq!(details.participant_label.as_deref(), Some("organizer")); + assert_eq!(details.participant_label_mtime, Some(1_700_000_013)); + assert_eq!(details.join_time, Some(1_700_000_014)); + assert_eq!(details.group_history_sent, Some(false)); + assert_eq!(details.display_name.as_deref(), Some("Fixture Member")); + assert!(!details.is_addressable); assert_eq!( response.participants[1].lid, Some("100000000000014@lid".parse().unwrap()) @@ -4112,6 +4812,65 @@ mod tests { round_trip.participants[0].participant_type, ParticipantType::SuperAdmin ); + assert_eq!( + round_trip.participants[0].username.as_deref(), + Some("fixture.member") + ); + assert_eq!( + round_trip.limit_sharing_trigger, + Some(GROUP_SETTING_TRIGGER_MAX) + ); + assert!(round_trip.has_missing_participant_identification); + } + + #[test] + fn group_info_treats_non_request_parent_mode_as_open() { + let node = NodeBuilder::new("community") + .attr("id", "120363000000000013@g.us") + .children([NodeBuilder::new("parent") + .attr("default_membership_approval_mode", "auto_approve") + .build()]) + .build(); + + let response = GroupInfoResponse::try_from_node(&node).unwrap(); + assert!(response.is_parent_group); + assert!(!response.parent_membership_approval_required); + } + + #[test] + fn participant_details_accept_protocol_boolean_forms() { + for (wire, expected) in [("0", false), ("1", true)] { + let node = NodeBuilder::new("participant") + .attr("jid", "100000000000015@lid") + .attr("group_history_sent", wire) + .attr("addressable", wire) + .build(); + let participant = GroupParticipantResponse::try_from_node(&node).unwrap(); + let details = participant.details.expect("boolean details"); + assert_eq!(details.group_history_sent, Some(expected)); + assert_eq!(details.is_addressable, expected); + } + + let invalid = NodeBuilder::new("participant") + .attr("jid", "100000000000015@lid") + .attr("addressable", "sometimes") + .build(); + assert!(GroupParticipantResponse::try_from_node(&invalid).is_err()); + } + + #[test] + fn test_group_info_response_accepts_explicit_false_suspension_flag() { + let node = NodeBuilder::new("group") + .attr("id", "120363000000000015@g.us") + .attr("subject", "Suspended Group") + .children([NodeBuilder::new("suspended") + .attr("can_auto_file", "false") + .build()]) + .build(); + + let response = GroupInfoResponse::try_from_node(&node).unwrap(); + assert!(response.is_suspended); + assert!(!response.suspension_can_auto_file); } #[test] @@ -4134,6 +4893,47 @@ mod tests { assert!(empty.into_node().get_optional_child("ephemeral").is_some()); } + #[test] + fn test_group_info_response_rejects_out_of_range_metadata() { + let fixtures = [ + NodeBuilder::new("group") + .attr("id", "120363000000000031@g.us") + .attr("size", GROUP_INFO_PARTICIPANT_LIMIT + 1) + .build(), + NodeBuilder::new("group") + .attr("id", "120363000000000032@g.us") + .children([NodeBuilder::new("ephemeral") + .attr("expiration", u64::from(GROUP_EPHEMERAL_EXPIRATION_MAX) + 1) + .build()]) + .build(), + NodeBuilder::new("group") + .attr("id", "120363000000000033@g.us") + .children([NodeBuilder::new("ephemeral") + .attr("trigger", GROUP_SETTING_TRIGGER_MAX + 1) + .build()]) + .build(), + NodeBuilder::new("group") + .attr("id", "120363000000000034@g.us") + .children([NodeBuilder::new("evolution_version") + .attr("value", GROUP_EVOLUTION_VERSION_MAX + 1) + .build()]) + .build(), + NodeBuilder::new("group") + .attr("id", "120363000000000035@g.us") + .children([NodeBuilder::new("limit_sharing_enabled") + .attr("trigger", GROUP_SETTING_TRIGGER_MAX + 1) + .build()]) + .build(), + ]; + + for fixture in fixtures { + assert!( + GroupInfoResponse::try_from_node(&fixture).is_err(), + "out-of-range group metadata must be rejected: {fixture:?}" + ); + } + } + #[test] fn test_group_info_response_serializes_description_identity_without_body() { let node = NodeBuilder::new("group") @@ -4164,15 +4964,15 @@ mod tests { ); } - /// `parse_response` should overlay `is_parent_group` and - /// `allow_non_admin_sub_group_creation` from the request when the server - /// omits `` from a community-create reply (WA Web's CreateJob - /// never reads parent markers from the response either). + /// `parse_response` should overlay parent settings from the request when + /// the server omits `` from a community-create reply (WA Web's + /// CreateJob never reads parent markers from the response either). #[test] fn test_group_create_iq_overlays_parent_flags() { let options = GroupCreateOptions { subject: "My Community".into(), is_parent: true, + closed: true, allow_non_admin_sub_group_creation: true, ..Default::default() }; @@ -4188,6 +4988,7 @@ mod tests { let response = spec.parse_response(&iq.as_node_ref()).unwrap(); assert!(response.is_parent_group); + assert!(response.parent_membership_approval_required); assert!(response.allow_non_admin_sub_group_creation); } diff --git a/wacore/src/poll.rs b/wacore/src/poll.rs index 1fd3fe2e8..757930705 100644 --- a/wacore/src/poll.rs +++ b/wacore/src/poll.rs @@ -3,11 +3,11 @@ //! Thin wrapper over [`secret_enc_addon`] specialised for the //! `PollVoteMessage` proto and the `"Poll Vote"` use-case. -use anyhow::{Result, anyhow}; +use anyhow::{Result, anyhow, ensure}; use sha2::{Digest, Sha256}; use crate::secret_enc_addon::{ - AddonContext, ModificationType, build_aad, decrypt_addon, encrypt_addon, + AddonContext, MESSAGE_SECRET_SIZE, ModificationType, build_aad, decrypt_addon, encrypt_addon, }; const GCM_IV_SIZE: usize = 12; @@ -272,6 +272,11 @@ pub fn decrypt_poll_vote_payload_with_secret( poll_creator_jid: &str, voter_jid: &str, ) -> Result> { + ensure!( + message_secret.len() == MESSAGE_SECRET_SIZE, + "message_secret must be {MESSAGE_SECRET_SIZE} bytes, got {}", + message_secret.len() + ); decrypt_addon( ciphertext.enc_payload, ciphertext.enc_iv, @@ -408,6 +413,27 @@ mod tests { assert_eq!(plaintext, vote_message.encode_to_vec()); } + #[test] + fn payload_decrypt_rejects_invalid_message_secret_before_ciphertext() { + let error = decrypt_poll_vote_payload_with_secret( + PollVoteCiphertext { + enc_payload: &[], + enc_iv: &[], + }, + &[0u8; MESSAGE_SECRET_SIZE - 1], + "id", + "creator@s.whatsapp.net", + "voter@s.whatsapp.net", + ) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("message_secret must be 32 bytes") + ); + } + #[test] fn selected_option_encoding_matches_message_encoding() { use buffa::Message; diff --git a/wacore/src/secret_enc_addon.rs b/wacore/src/secret_enc_addon.rs index 58d2087d8..c3b1f4815 100644 --- a/wacore/src/secret_enc_addon.rs +++ b/wacore/src/secret_enc_addon.rs @@ -27,7 +27,7 @@ use crate::libsignal::crypto::{aes_256_gcm_decrypt, aes_256_gcm_encrypt}; const GCM_IV_SIZE: usize = 12; const GCM_TAG_SIZE: usize = 16; -const KEY_SIZE: usize = 32; +pub(crate) const MESSAGE_SECRET_SIZE: usize = 32; /// Use-case literal that goes into the HKDF `info` buffer. /// @@ -96,13 +96,12 @@ pub struct AddonContext<'a> { pub fn derive_use_case_secret( message_secret: &[u8], ctx: &AddonContext<'_>, -) -> Result<[u8; KEY_SIZE]> { - if message_secret.len() != KEY_SIZE { - return Err(anyhow!( - "Invalid messageSecret size: expected {KEY_SIZE}, got {}", - message_secret.len() - )); - } +) -> Result<[u8; MESSAGE_SECRET_SIZE]> { + anyhow::ensure!( + message_secret.len() == MESSAGE_SECRET_SIZE, + "Invalid messageSecret size: expected {MESSAGE_SECRET_SIZE}, got {}", + message_secret.len() + ); let mut info = Vec::with_capacity( ctx.stanza_id.len() @@ -115,7 +114,7 @@ pub fn derive_use_case_secret( info.extend_from_slice(ctx.modification_sender.as_bytes()); info.extend_from_slice(ctx.modification_type.as_str().as_bytes()); - let mut key = [0u8; KEY_SIZE]; + let mut key = [0u8; MESSAGE_SECRET_SIZE]; crate::crypto::hkdf_sha256_into(message_secret, None, &info, &mut key) .map_err(|e| anyhow!("HKDF expand failed: {e}"))?; Ok(key) diff --git a/wacore/src/stanza/groups.rs b/wacore/src/stanza/groups.rs index e4fd13548..f88adb610 100644 --- a/wacore/src/stanza/groups.rs +++ b/wacore/src/stanza/groups.rs @@ -15,6 +15,8 @@ use serde::Serialize; use wacore_binary::Jid; use wacore_binary::{Node, NodeRef}; +const MISSING_PARTICIPANT_IDENTIFICATION_TAG: &str = "missing_participant_identification"; + /// How a membership request was initiated. /// /// Maps to `WAWebRequestMethodType` in WhatsApp Web JS. @@ -36,6 +38,10 @@ pub struct GroupNotification { pub group_jid: Jid, /// Notification stanza identifier (from `id`). pub notification_id: Option, + /// Display name supplied with the notification. + pub notify: Option, + /// Raw offline-delivery marker supplied with the notification. + pub offline: Option, /// Admin/user who triggered the notification (from `participant` attribute) pub participant: Option, /// Phone number JID of the participant (from `participant_pn` attribute, for LID groups) @@ -49,6 +55,8 @@ pub struct GroupNotification { pub timestamp: u64, /// Whether the group uses LID addressing mode (from `addressing_mode="lid"`) pub is_lid_addressing_mode: bool, + /// Whether at least one participant identity was omitted by the server. + pub has_incomplete_participant_information: bool, /// One or more actions in this notification pub actions: Vec, } @@ -66,6 +74,17 @@ pub enum GroupParticipantType { SuperAdmin, } +/// Delivery state for history shared with a newly joined participant. +#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)] +pub enum GroupHistorySentState { + #[wire = "HISTORY_NOT_SENT"] + HistoryNotSent, + #[wire = "HISTORY_SENT"] + HistorySent, + #[wire = "NOTICE_SENT"] + NoticeSent, +} + /// Participant info extracted from `` child elements. /// /// Wire format: @@ -105,6 +124,9 @@ pub struct GroupParticipantInfo { /// admin UI for tenure display. #[serde(skip_serializing_if = "Option::is_none")] pub join_time: Option, + /// Delivery state for post-join group history. + #[serde(skip_serializing_if = "Option::is_none")] + pub group_history_sent_state: Option, } /// All possible group notification action types. @@ -348,6 +370,12 @@ impl GroupNotification { let mut attrs = node.attrs(); let group_jid = attrs.optional_jid("from")?; let notification_id = attrs.optional_string("id").map(|value| value.into_owned()); + let notify = attrs + .optional_string("notify") + .map(|value| value.into_owned()); + let offline = attrs + .optional_string("offline") + .map(|value| value.into_owned()); let participant = attrs.optional_jid("participant"); let participant_pn = attrs.optional_jid("participant_pn"); let participant_username = attrs @@ -362,20 +390,34 @@ impl GroupNotification { .map(|v| v.as_str()) .is_some_and(|s| s == "lid"); + let mut has_incomplete_participant_information = false; let actions = node .children() - .map(|children| children.iter().filter_map(parse_action).collect()) + .map(|children| { + let mut actions = Vec::with_capacity(children.len()); + for child in children { + if child.tag.as_ref() == MISSING_PARTICIPANT_IDENTIFICATION_TAG { + has_incomplete_participant_information = true; + } else if let Some(action) = parse_action(child) { + actions.push(action); + } + } + actions + }) .unwrap_or_default(); Some(Self { group_jid, notification_id, + notify, + offline, participant, participant_pn, participant_username, participant_country_code, timestamp, is_lid_addressing_mode, + has_incomplete_participant_information, actions, }) } @@ -396,7 +438,7 @@ fn parse_action(node: &NodeRef<'_>) -> Option { use wacore_binary::NodeContentRef; // WA Web drops this child entirely; mirror that behavior. - if node.tag.as_ref() == "missing_participant_identification" { + if node.tag.as_ref() == MISSING_PARTICIPANT_IDENTIFICATION_TAG { return None; } @@ -635,10 +677,13 @@ fn parse_participants(node: &NodeRef<'_>) -> Vec { ); let lid = attrs.optional_jid("lid"); let username = attrs - .optional_string("participant_username") - .or_else(|| attrs.optional_string("username")) + .optional_string("username") + .or_else(|| attrs.optional_string("participant_username")) .map(|s| s.into_owned()); let join_time = attrs.optional_u64("join_time"); + let group_history_sent_state = attrs + .optional_string("group_history_sent_state") + .and_then(|value| GroupHistorySentState::try_from(value.as_ref()).ok()); Some(GroupParticipantInfo { jid, phone_number, @@ -647,6 +692,7 @@ fn parse_participants(node: &NodeRef<'_>) -> Vec { lid, username, join_time, + group_history_sent_state, }) }) .collect() @@ -676,6 +722,7 @@ fn parse_requested_users(node: &NodeRef<'_>) -> Vec { lid: None, username, join_time: None, + group_history_sent_state: None, }) }) .collect() @@ -760,18 +807,27 @@ mod tests { .attr("participant_pn", participant_pn.clone()) .attr("participant_username", "group-admin") .attr("participant_country_code", "BR") + .attr("notify", "Group Admin") + .attr("offline", "1") .attr("t", "1704067200") - .children(vec![NodeBuilder::new("announcement").build()]) + .children(vec![ + NodeBuilder::new(MISSING_PARTICIPANT_IDENTIFICATION_TAG).build(), + NodeBuilder::new("announcement").build(), + ]) .build(); let notification = GroupNotification::try_from_node_ref(&node.as_node_ref()).unwrap(); assert_eq!(notification.notification_id.as_deref(), Some("GP-ROOT-1")); + assert_eq!(notification.notify.as_deref(), Some("Group Admin")); + assert_eq!(notification.offline.as_deref(), Some("1")); assert_eq!(notification.participant_pn, Some(participant_pn)); assert_eq!( notification.participant_username.as_deref(), Some("group-admin") ); assert_eq!(notification.participant_country_code.as_deref(), Some("BR")); + assert!(notification.has_incomplete_participant_information); + assert_eq!(notification.actions.len(), 1); } #[test] @@ -871,9 +927,10 @@ mod tests { .attr("jid", "55510000001@s.whatsapp.net") .attr("type", "admin") .attr("lid", "99900000000001@lid") - .attr("participant_username", "alice") - .attr("username", "fallback-alice") + .attr("participant_username", "legacy-alice") + .attr("username", "alice") .attr("join_time", "1700000000") + .attr("group_history_sent_state", "NOTICE_SENT") .build(), ]) .build(), @@ -890,6 +947,10 @@ mod tests { ); assert_eq!(p.username.as_deref(), Some("alice")); assert_eq!(p.join_time, Some(1700000000)); + assert_eq!( + p.group_history_sent_state, + Some(GroupHistorySentState::NoticeSent) + ); } other => panic!("expected Add, got {:?}", other), } diff --git a/wacore/src/store/signal_cache.rs b/wacore/src/store/signal_cache.rs index 979ef7211..b648f2804 100644 --- a/wacore/src/store/signal_cache.rs +++ b/wacore/src/store/signal_cache.rs @@ -78,6 +78,12 @@ fn high_watermark(max_entries: usize) -> usize { max_entries.saturating_add((max_entries / EVICTION_SLACK_DIVISOR).max(EVICTION_SLACK_FLOOR)) } +fn protocol_address_matches_user(address: &str, user: &str) -> bool { + address + .strip_prefix(user) + .is_some_and(|suffix| suffix.starts_with('@') || suffix.starts_with(':')) +} + /// In-memory write-back cache for Signal protocol state. /// Keys use `Arc` for O(1) clone. Sessions cached as objects (serialized on flush). /// Capacity-bounded: every path that grows a store (writes and read-populate @@ -90,6 +96,9 @@ pub struct SignalStoreCache { pending_session_restores: SyncMutex>, identities: Mutex, sender_keys: Mutex, + /// Fast-path guard for the normally-empty pending distribution map. Warm + /// group encrypts avoid a second sender-key mutex acquisition. + has_pending_sender_key_distributions: AtomicBool, /// Consumed one-time prekeys buffered for durable deletion, keyed by the /// address of the session whose pkmsg promotion consumed each one. The flush /// deletes a prekey only after that session is persisted, so a crash can never @@ -303,6 +312,11 @@ struct SenderKeyStoreState { /// re-derives forward), /// so unrelated group receives never force a sync flush onto a DM send. wire_gate_pending: HashSet>, + /// Distributions created for a new outbound chain but not yet returned by + /// a successful encryption call. A failed durability gate leaves the + /// distribution here so a retry cannot emit ciphertext for an + /// undistributed key. + pending_distributions: HashMap, Arc<[u8]>>, } impl SenderKeyStoreState { @@ -312,6 +326,7 @@ impl SenderKeyStoreState { cache: HashMap::new(), dirty: HashSet::new(), wire_gate_pending: HashSet::new(), + pending_distributions: HashMap::new(), } } @@ -336,12 +351,14 @@ impl SenderKeyStoreState { let addr = self.key_for(address); self.cache.insert(addr.clone(), None); self.dirty.insert(addr.clone()); + self.pending_distributions.remove(address); } fn clear(&mut self) { self.cache.clear(); self.dirty.clear(); self.wire_gate_pending.clear(); + self.pending_distributions.clear(); } fn discard(&mut self, incarnation: StoreIncarnation) { @@ -447,6 +464,7 @@ impl SignalStoreCache { pending_session_restores: SyncMutex::new(Vec::new()), identities: Mutex::new(ByteStoreState::new()), sender_keys: Mutex::new(SenderKeyStoreState::new(incarnation)), + has_pending_sender_key_distributions: AtomicBool::new(false), removed_prekeys: Mutex::new(HashMap::new()), sender_key_locks: Mutex::new(HashMap::new()), max_entries, @@ -626,25 +644,52 @@ impl SignalStoreCache { /// (even a stale/checked-out marker), so it never reports "none" when state /// might exist. pub async fn has_state_for_user(&self, user: &str, backend: &dyn SignalStore) -> Result { - fn matches(addr: &str, user: &str) -> bool { - addr.strip_prefix(user) - .is_some_and(|rest| rest.starts_with('@') || rest.starts_with(':')) - } { let state = self.lock_sessions().await; - if state.cache.keys().any(|k| matches(k, user)) { + if state + .cache + .keys() + .any(|address| protocol_address_matches_user(address, user)) + { return Ok(true); } } { let state = self.identities.lock().await; - if state.cache.keys().any(|k| matches(k, user)) { + if state + .cache + .keys() + .any(|address| protocol_address_matches_user(address, user)) + { return Ok(true); } } Ok(backend.has_signal_state_for_user(user).await?) } + /// Whether this user's pairwise session or identity writes still need a + /// durability retry. Migration uses this after a failed flush, when the + /// cache already reflects the move and a second pass makes no new changes. + pub async fn has_pending_pairwise_writes_for_user(&self, user: &str) -> bool { + { + let state = self.lock_sessions().await; + if state + .dirty + .iter() + .chain(&state.deleted) + .any(|address| protocol_address_matches_user(address, user)) + { + return true; + } + } + let state = self.identities.lock().await; + state + .dirty + .iter() + .chain(&state.deleted) + .any(|address| protocol_address_matches_user(address, user)) + } + // === Sessions (object cache — serialize only during flush) === /// Takes ownership of the cached session, leaving a `CheckedOut` marker. @@ -947,6 +992,59 @@ impl SignalStoreCache { state.evict_if_needed(self.max_entries); } + /// Retain a newly created sender-key distribution until the encryption + /// operation that owns it passes its durability gate. + pub async fn cache_pending_sender_key_distribution( + &self, + name: &SenderKeyName, + distribution: Arc<[u8]>, + ) { + let mut state = self.sender_keys.lock().await; + let key = state.key_for(name.cache_key()); + state.pending_distributions.insert(key, distribution); + self.has_pending_sender_key_distributions + .store(true, Ordering::Release); + } + + /// Return a retained distribution whose prior encryption attempt did not + /// complete its durability gate. + pub async fn pending_sender_key_distribution(&self, name: &SenderKeyName) -> Option> { + if !self + .has_pending_sender_key_distributions + .load(Ordering::Acquire) + { + return None; + } + self.sender_keys + .lock() + .await + .pending_distributions + .get(name.cache_key()) + .cloned() + } + + /// Clear a retained distribution after a successful encryption, but only + /// if it is still the distribution observed by that call. This prevents a + /// concurrent chain replacement from losing its newer distribution. + pub async fn clear_pending_sender_key_distribution( + &self, + name: &SenderKeyName, + expected: &[u8], + ) { + let mut state = self.sender_keys.lock().await; + if state + .pending_distributions + .get(name.cache_key()) + .is_some_and(|distribution| distribution.as_ref() == expected) + { + state.pending_distributions.remove(name.cache_key()); + if state.pending_distributions.is_empty() { + self.has_pending_sender_key_distributions + .store(false, Ordering::Release); + } + } + } + /// Shared lock for the `name` chain. Same name returns the same lock so a /// concurrent encrypt can't read a chain iteration another is advancing. pub async fn sender_key_lock(&self, name: &SenderKeyName) -> Arc> { @@ -984,6 +1082,49 @@ impl SignalStoreCache { let _guard = lock.lock().await; let mut state = self.sender_keys.lock().await; state.delete(cache_key); + if state.pending_distributions.is_empty() { + self.has_pending_sender_key_distributions + .store(false, Ordering::Release); + } + } + + /// Delete one sender-key chain from the cache and backend while holding its + /// chain lock. Only this record is persisted, avoiding a global cache flush + /// while preventing an in-flight mutation from resurrecting the old chain. + pub async fn delete_sender_key_durable( + &self, + name: &SenderKeyName, + backend: &dyn SignalStore, + ) -> Result<()> { + let lock = self.sender_key_lock(name).await; + let _guard = lock.lock().await; + let cache_key = name.cache_key(); + { + let mut state = self.sender_keys.lock().await; + state.delete(cache_key); + if state.pending_distributions.is_empty() { + self.has_pending_sender_key_distributions + .store(false, Ordering::Release); + } + } + + // The per-chain guard above keeps this record stable; unrelated chains + // must not queue behind backend latency on the global cache mutex. + backend.delete_sender_key(cache_key).await?; + + let mut state = self.sender_keys.lock().await; + if matches!(state.cache.get(cache_key), Some(None)) { + state.dirty.remove(cache_key); + state.wire_gate_pending.remove(cache_key); + } else if state.cache.contains_key(cache_key) { + // Defensive against a direct cache writer that did not honor the + // chain lock: the backend delete may have raced its write, so keep + // the replacement dirty for the next flush. + let key = state.key_for(cache_key); + state.dirty.insert(key); + } + state.evict_if_needed(self.max_entries); + Ok(()) } // === Consumed pre-keys === @@ -1247,7 +1388,7 @@ impl SignalStoreCache { CollectionStats::new(i.cache.len() as u64, bytes as u64) }; - let (sk_count, sk_keys_len, sk_recs): (u64, usize, Vec<_>) = { + let (sk_count, sk_keys_len, sk_pending_bytes, sk_recs): (u64, usize, usize, Vec<_>) = { let sk = self.sender_keys.lock().await; let mut keys_len = 0usize; let recs = sk @@ -1258,10 +1399,29 @@ impl SignalStoreCache { v.clone() }) .collect(); - (sk.cache.len() as u64, keys_len, recs) + let pending_bytes = sk + .pending_distributions + .values() + .map(|distribution| distribution.len()) + .sum(); + let (pending_only_count, pending_only_key_bytes) = sk + .pending_distributions + .keys() + .filter(|key| !sk.cache.contains_key(key.as_ref())) + .fold((0usize, 0usize), |(count, bytes), key| { + (count + 1, bytes + key.len()) + }); + keys_len += pending_only_key_bytes; + ( + (sk.cache.len() + pending_only_count) as u64, + keys_len, + pending_bytes, + recs, + ) }; - let sk_bytes: usize = - sk_keys_len + sk_recs.iter().map(|r| r.estimated_size()).sum::(); + let sk_bytes: usize = sk_keys_len + + sk_pending_bytes + + sk_recs.iter().map(|r| r.estimated_size()).sum::(); let sender_keys = CollectionStats::new(sk_count, sk_bytes as u64); (sessions, identities, sender_keys) @@ -1286,6 +1446,8 @@ impl SignalStoreCache { } self.identities.lock().await.clear(); self.sender_keys.lock().await.discard(incarnation); + self.has_pending_sender_key_distributions + .store(false, Ordering::Release); // Drop buffered prekey removals together with the volatile sessions they // belong to: the promoted session is gone, so the still-durable prekey // must stay so a redelivered pkmsg can rebuild the session. @@ -1314,8 +1476,13 @@ impl SignalStoreCache { drop(identities); let mut sender_keys = self.sender_keys.lock().await; - if sender_keys.dirty.is_empty() && sender_keys.wire_gate_pending.is_empty() { + if sender_keys.dirty.is_empty() + && sender_keys.wire_gate_pending.is_empty() + && sender_keys.pending_distributions.is_empty() + { sender_keys.clear(); + self.has_pending_sender_key_distributions + .store(false, Ordering::Release); } } } @@ -3493,6 +3660,55 @@ mod pre_wire_gate_tests { ); } + #[tokio::test] + async fn durable_sender_key_delete_does_not_block_unrelated_chains() { + let cache = Arc::new(SignalStoreCache::new()); + let backend = Arc::new(DeleteBarrierBackend::new(DeleteTarget::SenderKey)); + backend.fail_delete.store(false, Ordering::Release); + let target = SenderKeyName::from_parts("g1@g.us", "u@s.whatsapp.net:0"); + let unrelated = SenderKeyName::from_parts("g2@g.us", "u@s.whatsapp.net:0"); + cache + .put_sender_key(&target, SenderKeyRecord::new_empty()) + .await; + let target_lock = cache.sender_key_lock(&target).await; + + let deletion = tokio::spawn({ + let cache = cache.clone(); + let backend = backend.clone(); + async move { + cache + .delete_sender_key_durable(&target, backend.as_ref()) + .await + } + }); + backend.entered.wait().await; + + assert!( + target_lock.try_lock().is_none(), + "the target chain must remain serialized during backend deletion" + ); + tokio::time::timeout( + std::time::Duration::from_secs(1), + cache.put_sender_key(&unrelated, SenderKeyRecord::new_empty()), + ) + .await + .expect("backend latency for one chain must not hold the global cache lock"); + + backend.release.wait().await; + deletion + .await + .expect("delete task") + .expect("durable delete"); + assert!( + cache + .get_sender_key(&unrelated, backend.as_ref()) + .await + .unwrap() + .is_some(), + "unrelated state must remain available" + ); + } + /// Cleanup racing a post-flush write must not release its durability gate. #[tokio::test] async fn clear_after_flush_retains_every_post_flush_write_and_wire_gate() { diff --git a/wacore/src/types/events.rs b/wacore/src/types/events.rs index 7165179e5..fae040c67 100755 --- a/wacore/src/types/events.rs +++ b/wacore/src/types/events.rs @@ -1389,7 +1389,14 @@ pub struct GroupUpdate { /// Identifier of the source notification stanza. #[serde(skip_serializing_if = "Option::is_none")] pub notification_id: Option, + /// Display name supplied with the source notification. + #[serde(skip_serializing_if = "Option::is_none")] + pub notify: Option, + /// Raw offline-delivery marker supplied with the source notification. + #[serde(skip_serializing_if = "Option::is_none")] + pub offline: Option, /// Zero-based emitted-action index within the source notification. + #[builder(default)] pub action_index: u32, /// The admin/user who triggered the change (`participant` attribute) #[serde(skip_serializing_if = "Option::is_none")] @@ -1407,6 +1414,9 @@ pub struct GroupUpdate { pub timestamp: DateTime, /// Whether the group uses LID addressing mode pub is_lid_addressing_mode: bool, + /// Whether participant identity information was incomplete in the source stanza. + #[builder(default)] + pub has_incomplete_participant_information: bool, /// The specific action pub action: crate::stanza::groups::GroupNotificationAction, } @@ -1572,6 +1582,19 @@ mod tests { use buffa::Message; use waproto::whatsapp as wa; + #[test] + fn group_update_builder_defaults_additive_scalar_fields() { + let update = GroupUpdate::builder() + .group_jid("120363000000000001@g.us".parse().unwrap()) + .timestamp(DateTime::::UNIX_EPOCH) + .is_lid_addressing_mode(false) + .action(crate::stanza::groups::GroupNotificationAction::Unlocked) + .build(); + + assert_eq!(update.action_index, 0); + assert!(!update.has_incomplete_participant_information); + } + #[test] fn unavailable_fanout_flags_follow_wa_web_precedence() { use UnavailableType::*; diff --git a/wacore/src/types/message.rs b/wacore/src/types/message.rs index ceefc3796..37a6c5ab5 100644 --- a/wacore/src/types/message.rs +++ b/wacore/src/types/message.rs @@ -316,7 +316,7 @@ pub struct MessageInfo { pub server_id: MessageServerId, pub r#type: String, pub push_name: String, - #[serde(with = "chrono::serde::ts_seconds")] + #[serde(serialize_with = "chrono::serde::ts_seconds::serialize")] pub timestamp: DateTime, pub category: MessageCategory, pub multicast: bool,