Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/retry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -445,6 +447,7 @@ impl Client {
retry_count,
device_snapshot.account.as_ref(),
addressing_mode,
edit_attr,
)
.await?;

Expand All @@ -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,
Expand All @@ -474,6 +479,7 @@ impl Client {
message_id,
retry_count,
device_snapshot.account.as_ref(),
edit_attr,
)
.await?;

Expand Down
151 changes: 137 additions & 14 deletions wacore/src/send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,12 @@ 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 {
///
/// Public so the retry-edit-inference path can descend through neutral
/// wrappers (ephemeral / view_once / device_sent) before checking for
/// pin/edit/revoke fields. Does **not** unwrap `edited_message` — that field
/// is itself the signal we're trying to detect.
pub fn unwrap_message(msg: &wa::Message) -> &wa::Message {
macro_rules! try_unwrap {
($($field:ident),+ $(,)?) => {
$(
Expand Down Expand Up @@ -206,6 +211,28 @@ pub fn media_type_from_message(msg: &wa::Message) -> Option<&'static str> {
None
}

/// Canonical rule for the `decrypt-fail="hide"` attribute on outgoing `<enc>`
/// nodes. Centralizes the predicate used across DM peer fanout, group SKDM
/// distribution and group SKMSG to avoid drift — that drift was the root
/// cause of admin revokes silently failing for the SKDM path.
///
/// `true` means: emit `decrypt-fail="hide"`. Returns:
/// * `true` for any non-`Empty` edit *except* `AdminRevoke` — the server
/// rejects revoke stanzas carrying the hide attribute (per the
/// `test_decrypt_fail_hide_logic_for_edits` test in `types::message`).
/// * `true` for infrastructure messages that match `should_hide_decrypt_fail`
/// (reactions, pin changes, poll updates, etc.).
/// * `false` otherwise.
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 {
Expand Down Expand Up @@ -859,10 +886,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);

Expand Down Expand Up @@ -995,6 +1019,7 @@ pub async fn prepare_dm_retry_stanza<S, I>(
message_id: String,
retry_count: u8,
account: Option<&wa::AdvSignedDeviceIdentity>,
edit: Option<crate::types::message::EditAttribute>,
) -> Result<Node>
where
S: crate::libsignal::protocol::SessionStore,
Expand Down Expand Up @@ -1037,12 +1062,19 @@ 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));

// Preserve edit attribute on retry — same reason as the group path.
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.
Expand All @@ -1060,6 +1092,7 @@ pub async fn prepare_group_retry_stanza<S, I>(
retry_count: u8,
account: Option<&wa::AdvSignedDeviceIdentity>,
addressing_mode: crate::types::message::AddressingMode,
edit: Option<crate::types::message::EditAttribute>,
) -> Result<Node>
where
S: crate::libsignal::protocol::SessionStore,
Expand Down Expand Up @@ -1104,6 +1137,17 @@ where
// WA Web always sets addressing_mode for groups (MsgCreateDeviceStanza.js:131-135)
stanza_builder = stanza_builder.attr("addressing_mode", addressing_mode.as_str());

// Preserve the edit attribute on retry — without it the client receives the
// resend as a normal message and never applies the revoke/edit. Symptom:
// admin revoke worked for devices that decrypted on first try, but devices
// that sent a retry receipt got the resend without `edit="8"` and the
// image/message stayed visible for them.
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())
}

Expand Down Expand Up @@ -1323,13 +1367,19 @@ 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.
//
// hide_decrypt_fail: matches the main skmsg payload — `false` for AdminRevoke
// (server rejects revoke when SKDM has `decrypt-fail="hide"`; some clients
// never decrypt the skmsg principal and never apply the revoke), `true` for
// other edits and infrastructure messages.
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
Expand Down Expand Up @@ -1381,10 +1431,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)
Expand Down Expand Up @@ -2842,6 +2889,7 @@ mod tests {
1,
None,
AddressingMode::Pn,
None,
)
.await
.unwrap();
Expand Down Expand Up @@ -2892,6 +2940,7 @@ mod tests {
"dm-retry-1".into(),
1,
None,
None,
)
.await
.unwrap();
Expand Down Expand Up @@ -2948,6 +2997,7 @@ mod tests {
"dm-retry-2".into(),
2,
Some(&acc),
None,
)
.await
.unwrap();
Expand Down Expand Up @@ -2984,6 +3034,7 @@ mod tests {
2,
Some(&acc),
AddressingMode::Pn,
None,
)
.await
.unwrap();
Expand Down Expand Up @@ -3023,6 +3074,7 @@ mod tests {
3,
Some(&wa::AdvSignedDeviceIdentity::default()),
AddressingMode::Lid,
None,
)
.await
.unwrap();
Expand All @@ -3036,6 +3088,77 @@ mod tests {
"lid"
);
}

// Regression: retry resends must preserve the original `edit` wire
// attribute. Without it, admin revokes were re-sent as plain messages
// on retry and the client never applied the revoke for that device.
#[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 {
Expand Down
Loading
Loading