Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
100 changes: 100 additions & 0 deletions wacore/src/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,22 @@ pub fn decode_plaintext(padded_plaintext: &[u8], padding_version: u8) -> Result<
.map_err(|e| anyhow::anyhow!("Failed to decode decrypted plaintext: {e}"))
}

/// Wrap a message into a DeviceSentMessage for own-device sync, hoisting
/// `message_context_info` onto the outer message (matching WA Web). Inverse of
/// [`unwrap_device_sent`].
pub fn wrap_device_sent(mut message: wa::Message, destination_jid: String) -> wa::Message {
let context = message.message_context_info.take();
wa::Message {
message_context_info: context,
Comment on lines +104 to +106

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve non-secret DSM context fields

When an own-device DM already carries MessageContextInfo fields other than the WA-Web-hoisted subset, moving the entire context to the outer DSM drops those fields after receive: unwrap_device_sent delegates to merge_dsm_context, whose outer-only path copies only message_secret, association, limit sharing, thread IDs, and bot metadata. For example, src/send.rs builds pin/unpin messages with message_add_on_duration_in_secs; after this wrapper change, our other devices will unwrap the DSM without that duration, whereas the previous inner-context shape preserved it. Either keep unsupported fields on the inner copy or extend the DSM merge to round-trip the fields this sender can place in MessageContextInfo.

Useful? React with 👍 / 👎.

device_sent_message: Some(Box::new(wa::message::DeviceSentMessage {
destination_jid: Some(destination_jid),
message: Some(Box::new(message)),
phash: None,
})),
..Default::default()
}
}

/// Unwrap a DeviceSentMessage wrapper, returning the inner message.
///
/// When a message is sent from our own device, the actual content is nested
Expand Down Expand Up @@ -741,3 +757,87 @@ mod parse_message_info_tests {
);
}
}

#[cfg(test)]
mod device_sent_tests {
use super::*;

fn msg_with_secret(secret: &[u8]) -> wa::Message {
wa::Message {
conversation: Some("hi".into()),
message_context_info: Some(wa::MessageContextInfo {
message_secret: Some(secret.to_vec()),
..Default::default()
}),
..Default::default()
}
}

#[test]
fn wrap_hoists_context_to_outer_on_wire() {
let secret = [7u8; 32];
let wrapped = wrap_device_sent(msg_with_secret(&secret), "1@s.whatsapp.net".into());

let bytes = wrapped.encode_to_vec();
let decoded = wa::Message::decode(bytes.as_slice()).unwrap();

assert_eq!(
decoded
.message_context_info
.and_then(|c| c.message_secret)
.as_deref(),
Some(secret.as_slice())
);
let inner = decoded.device_sent_message.unwrap().message.unwrap();
assert!(inner.message_context_info.is_none());
assert_eq!(inner.conversation.as_deref(), Some("hi"));
}

#[test]
fn wrap_without_context_leaves_outer_empty() {
let inner = wa::Message {
conversation: Some("hi".into()),
..Default::default()
};
let wrapped = wrap_device_sent(inner, "1@s.whatsapp.net".into());

assert!(wrapped.message_context_info.is_none());
let dsm = wrapped.device_sent_message.unwrap();
assert_eq!(dsm.destination_jid.as_deref(), Some("1@s.whatsapp.net"));
assert!(dsm.message.unwrap().message_context_info.is_none());
}

#[test]
fn wrap_then_unwrap_preserves_non_secret_context_fields() {
let inner = wa::Message {
message_context_info: Some(wa::MessageContextInfo {
message_add_on_duration_in_secs: Some(604800),
..Default::default()
}),
..Default::default()
};
let unwrapped = unwrap_device_sent(wrap_device_sent(inner, "1@s.whatsapp.net".into()));
assert_eq!(
unwrapped
.message_context_info
.and_then(|c| c.message_add_on_duration_in_secs),
Some(604800)
);
}

#[test]
fn wrap_then_unwrap_round_trips_secret() {
let secret = [9u8; 32];
let wrapped = wrap_device_sent(msg_with_secret(&secret), "1@s.whatsapp.net".into());
let unwrapped = unwrap_device_sent(wrapped);

assert_eq!(unwrapped.conversation.as_deref(), Some("hi"));
assert_eq!(
unwrapped
.message_context_info
.and_then(|c| c.message_secret)
.as_deref(),
Some(secret.as_slice())
);
}
}
25 changes: 17 additions & 8 deletions wacore/src/proto_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -511,14 +511,9 @@ pub fn merge_dsm_context(
inner.limit_sharing_v2 = None;
Some(inner)
}
(None, Some(outer)) => Some(wa::MessageContextInfo {
message_secret: outer.message_secret.clone(),
message_association: outer.message_association.clone(),
limit_sharing_v2: outer.limit_sharing_v2,
thread_id: outer.thread_id.clone(),
bot_metadata: outer.bot_metadata.clone(),
..Default::default()
}),
// Inner was cleared by a WA-Web-style hoist; restore the full context the
// sender moved to the outer message, not just the merge subset.
(None, Some(outer)) => Some(outer.clone()),
(Some(mut inner), Some(outer)) => {
if inner.message_secret.is_none() {
inner.message_secret = outer.message_secret.clone();
Expand Down Expand Up @@ -1550,6 +1545,20 @@ mod tests {
);
}

#[test]
fn test_merge_dsm_context_outer_only_preserves_non_subset_fields() {
let outer = wa::MessageContextInfo {
message_add_on_duration_in_secs: Some(86400),
..Default::default()
};
let result = merge_dsm_context(None, Some(&outer)).unwrap();
assert_eq!(
result.message_add_on_duration_in_secs,
Some(86400),
"hoisted fields outside the merge subset must survive unwrap"
);
}

#[test]
fn test_merge_dsm_context_inner_preferred_for_secret() {
let inner = wa::MessageContextInfo {
Expand Down
10 changes: 1 addition & 9 deletions wacore/src/send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ use wacore_binary::builder::NodeBuilder;
use wacore_binary::{Jid, JidExt as _};
use wacore_libsignal::crypto::aes_256_cbc_encrypt_into;
use waproto::whatsapp as wa;
use waproto::whatsapp::message::DeviceSentMessage;

/// Wire-format constants (MsgCreateDeviceStanza.js).
pub(crate) mod stanza {
Expand Down Expand Up @@ -1004,14 +1003,7 @@ pub async fn prepare_dm_stanza<
MessageUtils::participant_list_hash(&sent).ok()
};

let dsm = wa::Message {
device_sent_message: Some(Box::new(DeviceSentMessage {
destination_jid: Some(to_jid.to_string()),
message: Some(Box::new(message_for_encryption)),
phash: None, // WA Web only sets DSM phash for groups
})),
..Default::default()
};
let dsm = crate::messages::wrap_device_sent(message_for_encryption, to_jid.to_string());

let own_devices_plaintext = MessageUtils::encode_and_pad(&dsm);

Expand Down
Loading