diff --git a/src/retry.rs b/src/retry.rs index 270ac122a..896900a5e 100644 --- a/src/retry.rs +++ b/src/retry.rs @@ -434,6 +434,8 @@ impl Client { let _session_guard = session_mutex.lock().await; let mut store_adapter = self.signal_adapter().await; + let edit_attr = + wacore::types::message::EditAttribute::infer_from_message(&original_msg); let stanza = wacore::send::prepare_group_retry_stanza( &mut store_adapter.session_store, &mut store_adapter.identity_store, @@ -445,6 +447,7 @@ impl Client { retry_count, device_snapshot.account.as_ref(), addressing_mode, + edit_attr, ) .await?; @@ -464,6 +467,8 @@ impl Client { let _session_guard = session_mutex.lock().await; let mut store_adapter = self.signal_adapter().await; + let edit_attr = + wacore::types::message::EditAttribute::infer_from_message(&original_msg); let stanza = wacore::send::prepare_dm_retry_stanza( &mut store_adapter.session_store, &mut store_adapter.identity_store, @@ -474,6 +479,7 @@ impl Client { message_id, retry_count, device_snapshot.account.as_ref(), + edit_attr, ) .await?; diff --git a/wacore/src/send.rs b/wacore/src/send.rs index defc30a26..2ed1a86fc 100644 --- a/wacore/src/send.rs +++ b/wacore/src/send.rs @@ -50,8 +50,9 @@ pub fn extract_ciphertext(msg: CiphertextMessage) -> Option<(&'static str, bool, } /// Unwrap wrapper message types to reach the inner message. -/// Matches WA Web's getUnwrappedProtobufMessage (EProtoUtils.js:19-35). -fn unwrap_message(msg: &wa::Message) -> &wa::Message { +/// Matches WA Web's getUnwrappedProtobufMessage. Does not unwrap +/// `edited_message`; that field is itself a signal callers may need. +pub(crate) fn unwrap_message(msg: &wa::Message) -> &wa::Message { macro_rules! try_unwrap { ($($field:ident),+ $(,)?) => { $( @@ -206,6 +207,20 @@ pub fn media_type_from_message(msg: &wa::Message) -> Option<&'static str> { None } +/// Canonical rule for `decrypt-fail="hide"` on outgoing `` nodes. +/// Shared by DM fanout, group SKDM and group SKMSG so the three paths can't drift. +/// `AdminRevoke` is excluded because the server drops revoke stanzas carrying the +/// hide attribute. +pub fn should_hide_decrypt_fail_for_send( + edit: Option<&crate::types::message::EditAttribute>, + msg: &wa::Message, +) -> bool { + edit.is_some_and(|e| { + *e != crate::types::message::EditAttribute::Empty + && *e != crate::types::message::EditAttribute::AdminRevoke + }) || should_hide_decrypt_fail(msg) +} + /// Infrastructure messages get decrypt-fail="hide" so recipients don't see /// "waiting for this message" placeholders for things like reactions or pin changes. pub fn should_hide_decrypt_fail(msg: &wa::Message) -> bool { @@ -859,10 +874,7 @@ pub async fn prepare_dm_stanza< let mut participant_nodes = Vec::with_capacity(total_devices); let mut includes_prekey_message = false; - let hide_decrypt_fail = edit - .as_ref() - .is_some_and(|e| *e != crate::types::message::EditAttribute::Empty) - || should_hide_decrypt_fail(message); + let hide_decrypt_fail = should_hide_decrypt_fail_for_send(edit.as_ref(), message); let mediatype = media_type_from_message(message); @@ -995,6 +1007,7 @@ pub async fn prepare_dm_retry_stanza( message_id: String, retry_count: u8, account: Option<&wa::AdvSignedDeviceIdentity>, + edit: Option, ) -> Result where S: crate::libsignal::protocol::SessionStore, @@ -1037,12 +1050,20 @@ where ); } - Ok(NodeBuilder::new("message") + let mut stanza_builder = NodeBuilder::new("message") .attr("to", to_jid) .attr("id", message_id) - .attr("type", stanza_type_from_message(message)) - .children(children) - .build()) + .attr("type", stanza_type_from_message(message)); + + // Without `edit`, the resend looks like a normal message and the client never + // applies the revoke/edit. + if let Some(e) = edit + && e != crate::types::message::EditAttribute::Empty + { + stanza_builder = stanza_builder.attr("edit", e.to_string_val()); + } + + Ok(stanza_builder.children(children).build()) } /// Pairwise-encrypted retry stanza for a single group participant. @@ -1060,6 +1081,7 @@ pub async fn prepare_group_retry_stanza( retry_count: u8, account: Option<&wa::AdvSignedDeviceIdentity>, addressing_mode: crate::types::message::AddressingMode, + edit: Option, ) -> Result where S: crate::libsignal::protocol::SessionStore, @@ -1104,6 +1126,14 @@ where // WA Web always sets addressing_mode for groups (MsgCreateDeviceStanza.js:131-135) stanza_builder = stanza_builder.attr("addressing_mode", addressing_mode.as_str()); + // Without `edit`, the resend looks like a normal message and the client never + // applies the revoke/edit. + if let Some(e) = edit + && e != crate::types::message::EditAttribute::Empty + { + stanza_builder = stanza_builder.attr("edit", e.to_string_val()); + } + Ok(stanza_builder.children(children).build()) } @@ -1323,13 +1353,17 @@ pub async fn prepare_group_stanza< // WA Web's GroupSkmsgJob wraps ensureE2ESessions in try/catch — logs error // but does NOT rethrow. SKDM distribution failure must not prevent the group // message from being sent. Only successfully encrypted devices are tracked. + // Must match the rule applied to the main skmsg payload below: if SKDM carries + // `decrypt-fail="hide"` but the payload does not (e.g. AdminRevoke), recipients + // without a sender key never decrypt the skmsg and the revoke is silently dropped. + let skdm_hide_decrypt_fail = should_hide_decrypt_fail_for_send(edit.as_ref(), message); match encrypt_for_devices( runtime, stores, resolver, distribution_list, &skdm_plaintext_to_encrypt, - true, + skdm_hide_decrypt_fail, None, ) .await @@ -1381,10 +1415,7 @@ pub async fn prepare_group_stanza< let skmsg_ciphertext = skmsg.into_serialized(); let mediatype = media_type_from_message(message); - let hide_decrypt_fail = (edit.as_ref().is_some_and(|e| { - *e != crate::types::message::EditAttribute::Empty - && *e != crate::types::message::EditAttribute::AdminRevoke - })) || should_hide_decrypt_fail(message); + let hide_decrypt_fail = should_hide_decrypt_fail_for_send(edit.as_ref(), message); let mut enc_builder = NodeBuilder::new("enc") .attr("v", stanza::ENC_VERSION) @@ -2842,6 +2873,7 @@ mod tests { 1, None, AddressingMode::Pn, + None, ) .await .unwrap(); @@ -2892,6 +2924,7 @@ mod tests { "dm-retry-1".into(), 1, None, + None, ) .await .unwrap(); @@ -2948,6 +2981,7 @@ mod tests { "dm-retry-2".into(), 2, Some(&acc), + None, ) .await .unwrap(); @@ -2984,6 +3018,7 @@ mod tests { 2, Some(&acc), AddressingMode::Pn, + None, ) .await .unwrap(); @@ -3023,6 +3058,7 @@ mod tests { 3, Some(&wa::AdvSignedDeviceIdentity::default()), AddressingMode::Lid, + None, ) .await .unwrap(); @@ -3036,6 +3072,74 @@ mod tests { "lid" ); } + + #[tokio::test] + async fn group_retry_preserves_edit_attribute() { + let (mut ss, mut is, jid) = setup_session().await; + let group: Jid = "120363098765432100@g.us".parse().unwrap(); + let p: Jid = jid.to_string().parse().unwrap(); + let n = prepare_group_retry_stanza( + &mut ss, + &mut is, + group, + p.clone(), + p, + &wa::Message::default(), + "revoke-1".into(), + 1, + None, + AddressingMode::Lid, + Some(crate::types::message::EditAttribute::AdminRevoke), + ) + .await + .unwrap(); + assert_eq!(n.attrs().optional_string("edit").unwrap().as_ref(), "8"); + } + + #[tokio::test] + async fn dm_retry_preserves_edit_attribute() { + let (mut ss, mut is, jid) = setup_session().await; + let to: Jid = "559922223333@s.whatsapp.net".parse().unwrap(); + let requester: Jid = jid.to_string().parse().unwrap(); + let n = prepare_dm_retry_stanza( + &mut ss, + &mut is, + to, + requester.clone(), + requester, + &wa::Message::default(), + "edit-1".into(), + 1, + None, + Some(crate::types::message::EditAttribute::MessageEdit), + ) + .await + .unwrap(); + assert_eq!(n.attrs().optional_string("edit").unwrap().as_ref(), "1"); + } + + #[tokio::test] + async fn retry_without_edit_omits_attribute() { + let (mut ss, mut is, jid) = setup_session().await; + let group: Jid = "120363098765432100@g.us".parse().unwrap(); + let p: Jid = jid.to_string().parse().unwrap(); + let n = prepare_group_retry_stanza( + &mut ss, + &mut is, + group, + p.clone(), + p, + &wa::Message::default(), + "plain-1".into(), + 1, + None, + AddressingMode::Lid, + None, + ) + .await + .unwrap(); + assert!(n.attrs().optional_string("edit").is_none()); + } } mod decrypt_fail { diff --git a/wacore/src/types/message.rs b/wacore/src/types/message.rs index d74ed5aaa..75a53bc0f 100644 --- a/wacore/src/types/message.rs +++ b/wacore/src/types/message.rs @@ -95,6 +95,39 @@ impl EditAttribute { pub fn to_string_val(&self) -> &str { self.as_str() } + + /// Recover the wire `edit` value from a cached protobuf so the retry path can + /// re-emit `edit="N"` on resends. The original `subtype` (used by WA Web's + /// `editAttribute`) isn't persisted, so `key.from_me` is used as a proxy to + /// distinguish admin-vs-sender revoke: WA Web sets `from_me=false` on the + /// revoke proto's `MessageKey` when an admin revokes someone else's message. + pub fn infer_from_message(msg: &waproto::whatsapp::Message) -> Option { + use waproto::whatsapp::message::protocol_message::Type as ProtocolType; + + // The operation signal can be nested under any neutral wrapper. + let msg = crate::send::unwrap_message(msg); + + if msg.pin_in_chat_message.is_some() { + return Some(Self::PinInChat); + } + if msg.edited_message.is_some() { + return Some(Self::MessageEdit); + } + if let Some(pm) = msg.protocol_message.as_deref() { + if pm.r#type == Some(ProtocolType::Revoke as i32) { + let from_me = pm.key.as_ref().and_then(|k| k.from_me).unwrap_or(false); + return Some(if from_me { + Self::SenderRevoke + } else { + Self::AdminRevoke + }); + } + if pm.r#type == Some(ProtocolType::MessageEdit as i32) || pm.edited_message.is_some() { + return Some(Self::MessageEdit); + } + } + None + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -208,4 +241,152 @@ mod tests { assert!(!should_add_decrypt_fail_hide(&EditAttribute::Empty)); assert!(!should_add_decrypt_fail_hide(&EditAttribute::AdminRevoke)); } + + #[test] + fn infer_from_message_admin_revoke() { + let msg = waproto::whatsapp::Message { + protocol_message: Some(Box::new(waproto::whatsapp::message::ProtocolMessage { + key: Some(waproto::whatsapp::MessageKey { + from_me: Some(false), + ..Default::default() + }), + r#type: Some(waproto::whatsapp::message::protocol_message::Type::Revoke as i32), + ..Default::default() + })), + ..Default::default() + }; + assert_eq!( + EditAttribute::infer_from_message(&msg), + Some(EditAttribute::AdminRevoke) + ); + } + + #[test] + fn infer_from_message_sender_revoke() { + let msg = waproto::whatsapp::Message { + protocol_message: Some(Box::new(waproto::whatsapp::message::ProtocolMessage { + key: Some(waproto::whatsapp::MessageKey { + from_me: Some(true), + ..Default::default() + }), + r#type: Some(waproto::whatsapp::message::protocol_message::Type::Revoke as i32), + ..Default::default() + })), + ..Default::default() + }; + assert_eq!( + EditAttribute::infer_from_message(&msg), + Some(EditAttribute::SenderRevoke) + ); + } + + #[test] + fn infer_from_message_top_level_edit() { + let msg = waproto::whatsapp::Message { + edited_message: Some(Box::new(waproto::whatsapp::message::FutureProofMessage { + message: Some(Box::new(waproto::whatsapp::Message::default())), + })), + ..Default::default() + }; + assert_eq!( + EditAttribute::infer_from_message(&msg), + Some(EditAttribute::MessageEdit) + ); + } + + #[test] + fn infer_from_message_legacy_edit() { + let msg = waproto::whatsapp::Message { + protocol_message: Some(Box::new(waproto::whatsapp::message::ProtocolMessage { + edited_message: Some(Box::new(waproto::whatsapp::Message::default())), + ..Default::default() + })), + ..Default::default() + }; + assert_eq!( + EditAttribute::infer_from_message(&msg), + Some(EditAttribute::MessageEdit) + ); + } + + #[test] + fn infer_from_message_message_edit_sender() { + let msg = waproto::whatsapp::Message { + protocol_message: Some(Box::new(waproto::whatsapp::message::ProtocolMessage { + key: Some(waproto::whatsapp::MessageKey { + from_me: Some(true), + ..Default::default() + }), + r#type: Some( + waproto::whatsapp::message::protocol_message::Type::MessageEdit as i32, + ), + edited_message: Some(Box::new(waproto::whatsapp::Message::default())), + ..Default::default() + })), + ..Default::default() + }; + assert_eq!( + EditAttribute::infer_from_message(&msg), + Some(EditAttribute::MessageEdit) + ); + } + + #[test] + fn infer_from_message_plain_returns_none() { + let msg = waproto::whatsapp::Message { + conversation: Some("plain".into()), + ..Default::default() + }; + assert_eq!(EditAttribute::infer_from_message(&msg), None); + } + + #[test] + fn infer_from_message_unwraps_neutral_wrappers() { + let inner_revoke = waproto::whatsapp::Message { + protocol_message: Some(Box::new(waproto::whatsapp::message::ProtocolMessage { + key: Some(waproto::whatsapp::MessageKey { + from_me: Some(false), + ..Default::default() + }), + r#type: Some(waproto::whatsapp::message::protocol_message::Type::Revoke as i32), + ..Default::default() + })), + ..Default::default() + }; + let wrapped = waproto::whatsapp::Message { + ephemeral_message: Some(Box::new(waproto::whatsapp::message::FutureProofMessage { + message: Some(Box::new(inner_revoke)), + })), + ..Default::default() + }; + assert_eq!( + EditAttribute::infer_from_message(&wrapped), + Some(EditAttribute::AdminRevoke) + ); + + // Same for pin wrapped in view_once and device_sent (double nesting). + let inner_pin = waproto::whatsapp::Message { + pin_in_chat_message: Some(waproto::whatsapp::message::PinInChatMessage::default()), + ..Default::default() + }; + let wrapped_pin = waproto::whatsapp::Message { + device_sent_message: Some(Box::new(waproto::whatsapp::message::DeviceSentMessage { + destination_jid: Some(String::new()), + message: Some(Box::new(waproto::whatsapp::Message { + view_once_message: Some(Box::new( + waproto::whatsapp::message::FutureProofMessage { + message: Some(Box::new(inner_pin)), + }, + )), + ..Default::default() + })), + ..Default::default() + })), + ..Default::default() + }; + assert_eq!( + EditAttribute::infer_from_message(&wrapped_pin), + Some(EditAttribute::PinInChat) + ); + } }