use crate::libsignal::crypto::CryptographicHash;
use anyhow::{Result, anyhow};
use base64::Engine as _;
use buffa::MessageView;
#[cfg(test)]
use buffa::Message as _;
use waproto::whatsapp as wa;
pub struct MessageUtils;
pub trait DsmDestination {
fn encoded_len(&self) -> usize;
fn write_into(&self, out: &mut Vec<u8>);
}
macro_rules! dsm_destination_via_str {
($($ty:ty),+ $(,)?) => {$(
impl DsmDestination for $ty {
#[inline]
fn encoded_len(&self) -> usize {
str::len(self)
}
#[inline]
fn write_into(&self, out: &mut Vec<u8>) {
out.extend_from_slice(str::as_bytes(self));
}
}
)+};
}
dsm_destination_via_str!(
str,
String,
Box<str>,
std::rc::Rc<str>,
std::sync::Arc<str>,
std::borrow::Cow<'_, str>,
);
impl<T: DsmDestination + ?Sized> DsmDestination for &T {
#[inline]
fn encoded_len(&self) -> usize {
(**self).encoded_len()
}
#[inline]
fn write_into(&self, out: &mut Vec<u8>) {
(**self).write_into(out);
}
}
impl<T: DsmDestination + ?Sized> DsmDestination for &mut T {
#[inline]
fn encoded_len(&self) -> usize {
(**self).encoded_len()
}
#[inline]
fn write_into(&self, out: &mut Vec<u8>) {
(**self).write_into(out);
}
}
impl DsmDestination for wacore_binary::jid::Jid {
#[inline]
fn encoded_len(&self) -> usize {
let mut counter = DisplayLen(0);
let _ = self.write_display_to(&mut counter);
counter.0
}
#[inline]
fn write_into(&self, out: &mut Vec<u8>) {
let _ = self.write_display_to(&mut Utf8Sink(out));
}
}
struct DisplayLen(usize);
impl core::fmt::Write for DisplayLen {
fn write_str(&mut self, s: &str) -> core::fmt::Result {
self.0 += s.len();
Ok(())
}
}
struct Utf8Sink<'a>(&'a mut Vec<u8>);
impl core::fmt::Write for Utf8Sink<'_> {
fn write_str(&mut self, s: &str) -> core::fmt::Result {
self.0.extend_from_slice(s.as_bytes());
Ok(())
}
}
impl MessageUtils {
fn random_pad_len() -> u8 {
use rand::RngExt;
let mut rng = rand::rng();
(rng.random::<u8>() & 0x0F) + 1
}
pub fn pad_message_v2(mut plaintext: Vec<u8>) -> Vec<u8> {
let pad = Self::random_pad_len();
plaintext.resize(plaintext.len() + pad as usize, pad);
plaintext
}
pub fn encode_and_pad(msg: &wa::Message) -> Vec<u8> {
let pad = Self::random_pad_len();
let mut cache = buffa::SizeCache::new();
let size = waproto::codec::message_compute_size(msg, &mut cache);
let mut buf = Vec::with_capacity(size + pad as usize);
waproto::codec::message_write_to(msg, &mut cache, &mut buf);
buf.resize(buf.len() + pad as usize, pad);
buf
}
pub fn encode_and_pad_with_context(
msg: &wa::Message,
extra_context: Option<&wa::MessageContextInfo>,
) -> Vec<u8> {
let pad = Self::random_pad_len();
let mut c_cache = buffa::SizeCache::new();
let extra_inner = extra_context
.map(|c| waproto::codec::message_context_info_compute_size(c, &mut c_cache));
let extra_len = extra_inner.map_or(0, |sz| len_delimited_len(TAG_MESSAGE_CONTEXT_INFO, sz));
let mut msg_cache = buffa::SizeCache::new();
let msg_size = waproto::codec::message_compute_size(msg, &mut msg_cache);
let mut buf = Vec::with_capacity(msg_size + extra_len + pad as usize);
waproto::codec::message_write_to(msg, &mut msg_cache, &mut buf);
if let (Some(c), Some(sz)) = (extra_context, extra_inner) {
push_message_field_sized(TAG_MESSAGE_CONTEXT_INFO, c, sz, &mut c_cache, &mut buf);
}
buf.resize(buf.len() + pad as usize, pad);
buf
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(name = "wa.send.dm_plaintext", level = "debug", skip_all)
)]
pub fn pad_with_context_from_encoded(
content: &[u8],
extra_context: Option<&wa::MessageContextInfo>,
) -> Vec<u8> {
let pad = Self::random_pad_len();
let mut c_cache = buffa::SizeCache::new();
let extra_inner = extra_context
.map(|c| waproto::codec::message_context_info_compute_size(c, &mut c_cache));
let extra_len = extra_inner.map_or(0, |sz| len_delimited_len(TAG_MESSAGE_CONTEXT_INFO, sz));
let mut buf = Vec::with_capacity(content.len() + extra_len + pad as usize);
buf.extend_from_slice(content);
if let (Some(c), Some(sz)) = (extra_context, extra_inner) {
push_message_field_sized(TAG_MESSAGE_CONTEXT_INFO, c, sz, &mut c_cache, &mut buf);
}
buf.resize(buf.len() + pad as usize, pad);
buf
}
pub fn encode_dm_plaintexts(
message: &wa::Message,
extra_context: Option<&wa::MessageContextInfo>,
destination_jid: impl DsmDestination,
) -> DmPlaintexts {
if message.message_context_info.is_set() {
let mut owned = message.clone();
if let Some(extra) = extra_context {
let ctx = owned
.message_context_info
.as_option_mut()
.expect("mci is set");
waproto::codec::message_context_info_merge(
ctx,
&waproto::codec::message_context_info_to_vec(extra),
)
.expect("merge MessageContextInfo");
}
return Self::encode_dm_plaintexts_owned(owned, destination_jid);
}
const MAX_PAD: usize = 16;
let mci_field_len = extra_context.map_or(0, |m| {
let mut c = buffa::SizeCache::new();
len_delimited_len(
TAG_MESSAGE_CONTEXT_INFO,
waproto::codec::message_context_info_compute_size(m, &mut c),
)
});
let mut msg_cache = buffa::SizeCache::new();
let content_len = waproto::codec::message_compute_size(message, &mut msg_cache);
let dest_len = destination_jid.encoded_len();
let mut recipient = Vec::with_capacity(content_len + mci_field_len + MAX_PAD);
waproto::codec::message_write_to(message, &mut msg_cache, &mut recipient);
let dsm_len = len_delimited_len(TAG_DSM_DESTINATION_JID, dest_len)
+ len_delimited_len(TAG_DSM_MESSAGE, content_len);
let own_cap = len_delimited_len(TAG_DEVICE_SENT_MESSAGE, dsm_len) + mci_field_len + MAX_PAD;
let mut own_devices = Vec::with_capacity(own_cap);
push_wire_tag(
TAG_DEVICE_SENT_MESSAGE,
buffa::encoding::WireType::LengthDelimited,
&mut own_devices,
);
push_varint(dsm_len as u64, &mut own_devices); push_wire_tag(
TAG_DSM_DESTINATION_JID,
buffa::encoding::WireType::LengthDelimited,
&mut own_devices,
);
push_varint(dest_len as u64, &mut own_devices);
destination_jid.write_into(&mut own_devices);
push_len_delimited(TAG_DSM_MESSAGE, &recipient[..content_len], &mut own_devices);
if let Some(extra) = extra_context {
push_message_field(TAG_MESSAGE_CONTEXT_INFO, extra, &mut own_devices);
}
if let Some(extra) = extra_context {
push_message_field(TAG_MESSAGE_CONTEXT_INFO, extra, &mut recipient);
}
DmPlaintexts {
recipient: Self::pad_message_v2(recipient),
own_devices: Self::pad_message_v2(own_devices),
}
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(name = "wa.send.dm_plaintexts", level = "debug", skip_all)
)]
pub fn dm_plaintexts_from_encoded(
content: &[u8],
extra_context: Option<&wa::MessageContextInfo>,
destination_jid: impl DsmDestination,
) -> DmPlaintexts {
const MAX_PAD: usize = 16;
let mci_field_len = extra_context.map_or(0, |m| {
let mut c = buffa::SizeCache::new();
len_delimited_len(
TAG_MESSAGE_CONTEXT_INFO,
waproto::codec::message_context_info_compute_size(m, &mut c),
)
});
let content_len = content.len();
let dest_len = destination_jid.encoded_len();
let mut recipient = Vec::with_capacity(content_len + mci_field_len + MAX_PAD);
recipient.extend_from_slice(content);
let dsm_len = len_delimited_len(TAG_DSM_DESTINATION_JID, dest_len)
+ len_delimited_len(TAG_DSM_MESSAGE, content_len);
let own_cap = len_delimited_len(TAG_DEVICE_SENT_MESSAGE, dsm_len) + mci_field_len + MAX_PAD;
let mut own_devices = Vec::with_capacity(own_cap);
push_wire_tag(
TAG_DEVICE_SENT_MESSAGE,
buffa::encoding::WireType::LengthDelimited,
&mut own_devices,
);
push_varint(dsm_len as u64, &mut own_devices); push_wire_tag(
TAG_DSM_DESTINATION_JID,
buffa::encoding::WireType::LengthDelimited,
&mut own_devices,
);
push_varint(dest_len as u64, &mut own_devices);
destination_jid.write_into(&mut own_devices);
push_len_delimited(TAG_DSM_MESSAGE, content, &mut own_devices);
if let Some(extra) = extra_context {
push_message_field(TAG_MESSAGE_CONTEXT_INFO, extra, &mut own_devices);
}
if let Some(extra) = extra_context {
push_message_field(TAG_MESSAGE_CONTEXT_INFO, extra, &mut recipient);
}
DmPlaintexts {
recipient: Self::pad_message_v2(recipient),
own_devices: Self::pad_message_v2(own_devices),
}
}
fn encode_dm_plaintexts_owned(
mut message: wa::Message,
destination_jid: impl DsmDestination,
) -> DmPlaintexts {
const MAX_PAD: usize = 16;
let mci = message.message_context_info.take();
let mci_field_len = mci.as_ref().map_or(0, |m| {
let mut c = buffa::SizeCache::new();
len_delimited_len(
TAG_MESSAGE_CONTEXT_INFO,
waproto::codec::message_context_info_compute_size(m, &mut c),
)
});
let mut msg_cache = buffa::SizeCache::new();
let content_len = waproto::codec::message_compute_size(&message, &mut msg_cache);
let dest_len = destination_jid.encoded_len();
let mut recipient = Vec::with_capacity(content_len + mci_field_len + MAX_PAD);
waproto::codec::message_write_to(&message, &mut msg_cache, &mut recipient);
let dsm_len = len_delimited_len(TAG_DSM_DESTINATION_JID, dest_len)
+ len_delimited_len(TAG_DSM_MESSAGE, content_len);
let own_cap = len_delimited_len(TAG_DEVICE_SENT_MESSAGE, dsm_len) + mci_field_len + MAX_PAD;
let mut own_devices = Vec::with_capacity(own_cap);
push_wire_tag(
TAG_DEVICE_SENT_MESSAGE,
buffa::encoding::WireType::LengthDelimited,
&mut own_devices,
);
push_varint(dsm_len as u64, &mut own_devices); push_wire_tag(
TAG_DSM_DESTINATION_JID,
buffa::encoding::WireType::LengthDelimited,
&mut own_devices,
);
push_varint(dest_len as u64, &mut own_devices);
destination_jid.write_into(&mut own_devices);
push_len_delimited(TAG_DSM_MESSAGE, &recipient[..content_len], &mut own_devices);
if let Some(mci) = &mci {
push_message_field(TAG_MESSAGE_CONTEXT_INFO, mci, &mut own_devices);
}
if let Some(mci) = &mci {
push_message_field(TAG_MESSAGE_CONTEXT_INFO, mci, &mut recipient);
}
DmPlaintexts {
recipient: Self::pad_message_v2(recipient),
own_devices: Self::pad_message_v2(own_devices),
}
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(name = "wa.send.participant_hash", level = "debug", skip_all)
)]
pub fn participant_list_hash<'a>(
devices: impl IntoIterator<Item = &'a wacore_binary::Jid>,
) -> Result<String> {
let devices = devices.into_iter();
let mut ranges: Vec<(usize, usize)> = Vec::with_capacity(devices.size_hint().0);
let mut arena = String::with_capacity(ranges.capacity() * 36);
for jid in devices {
let start = arena.len();
jid.push_phash_form_to(&mut arena);
ranges.push((start, arena.len()));
}
ranges.sort_unstable_by(|a, b| arena[a.0..a.1].cmp(&arena[b.0..b.1]));
let mut h = CryptographicHash::new("SHA-256")
.map_err(|e| anyhow!("failed to initialize SHA-256 hasher: {:?}", e))?;
for &(start, end) in &ranges {
h.update(&arena.as_bytes()[start..end]);
}
let full_hash = h
.finalize_sha256_array()
.map_err(|e| anyhow!("failed to finalize hash: {:?}", e))?;
let mut out = String::with_capacity(10);
out.push_str("2:");
base64::prelude::BASE64_STANDARD_NO_PAD.encode_string(&full_hash[..6], &mut out);
Ok(out)
}
pub fn validate_bcl_hash(participants: &[wacore_binary::Jid], expected: &str) -> bool {
Self::participant_list_hash(participants).is_ok_and(|computed| computed == expected)
}
pub fn unpadded_message_len(plaintext: &[u8], version: u8) -> Result<usize> {
if version == 3 {
return Ok(plaintext.len());
}
if plaintext.is_empty() {
return Err(anyhow::anyhow!("plaintext is empty, cannot unpad"));
}
let pad_len = plaintext[plaintext.len() - 1] as usize;
if pad_len == 0 || pad_len > plaintext.len() {
return Err(anyhow::anyhow!("invalid padding length: {}", pad_len));
}
let (data, padding) = plaintext.split_at(plaintext.len() - pad_len);
for &byte in padding {
if byte != pad_len as u8 {
return Err(anyhow::anyhow!("invalid padding bytes"));
}
}
Ok(data.len())
}
pub fn unpad_message_ref(plaintext: &[u8], version: u8) -> Result<&[u8]> {
let unpadded_len = Self::unpadded_message_len(plaintext, version)?;
Ok(&plaintext[..unpadded_len])
}
}
pub fn decode_plaintext(padded_plaintext: &[u8], padding_version: u8) -> Result<wa::Message> {
let plaintext_slice = MessageUtils::unpad_message_ref(padded_plaintext, padding_version)?;
waproto::codec::message_decode(plaintext_slice)
.map_err(|e| anyhow::anyhow!("Failed to decode decrypted plaintext: {e}"))
}
#[derive(Debug)]
pub struct DetachedHistorySyncNotification {
pub notification: wa::message::HistorySyncNotification,
pub inline_payload: Option<bytes::Bytes>,
}
impl From<wa::message::HistorySyncNotification> for DetachedHistorySyncNotification {
fn from(mut notification: wa::message::HistorySyncNotification) -> Self {
let inline_payload = notification
.initial_hist_bootstrap_inline_payload
.take()
.map(bytes::Bytes::from);
Self {
notification,
inline_payload,
}
}
}
pub fn decode_plaintext_detached_history_sync(
padded_plaintext: Vec<u8>,
padding_version: u8,
) -> Result<(wa::Message, Option<DetachedHistorySyncNotification>)> {
let unpadded_len = MessageUtils::unpadded_message_len(&padded_plaintext, padding_version)?;
let source = bytes::Bytes::from(padded_plaintext).slice(0..unpadded_len);
let history_path = if contains_nested_message_field(&source, DEVICE_SENT_INNER_MESSAGE_PATH)? {
DEVICE_SENT_HISTORY_PAYLOAD_PATH
} else {
DIRECT_HISTORY_PAYLOAD_PATH
};
#[cfg(feature = "tracing")]
let decode_span = tracing::trace_span!(
"wa.message.detach_history_sync_payload",
plaintext_bytes = source.len() as u64
);
#[cfg(feature = "tracing")]
let decode_guard = decode_span.enter();
let redaction = redact_nested_bytes_field(&source, 0, history_path)?;
#[cfg(feature = "tracing")]
drop(decode_guard);
#[cfg(feature = "tracing")]
let materialize_span = tracing::trace_span!(
"wa.message.decode_plaintext",
plaintext_bytes = source.len() as u64,
payload_detached = redaction.detached_value.is_some()
);
#[cfg(feature = "tracing")]
let _materialize_guard = materialize_span.enter();
let encoded = redaction.rewritten.as_deref().unwrap_or(&source);
let mut message = waproto::codec::message_decode(encoded)
.map_err(|e| anyhow::anyhow!("Failed to decode decrypted plaintext: {e}"))?;
let mut history_sync =
take_history_sync_notification(&mut message).map(DetachedHistorySyncNotification::from);
if let Some(payload_range) = redaction.detached_value {
let detached = history_sync
.as_mut()
.ok_or_else(|| anyhow!("inline history-sync payload had no decoded notification"))?;
detached.inline_payload = Some(source.slice(payload_range));
}
Ok((message, history_sync))
}
fn take_history_sync_notification(
message: &mut wa::Message,
) -> Option<wa::message::HistorySyncNotification> {
let message = match message.device_sent_message.as_option_mut() {
Some(device_sent) if device_sent.message.is_set() => device_sent.message.as_option_mut()?,
_ => message,
};
message
.protocol_message
.as_option_mut()?
.history_sync_notification
.take()
}
#[derive(Default)]
struct WireRedaction {
rewritten: Option<Vec<u8>>,
detached_value: Option<std::ops::Range<usize>>,
}
fn contains_nested_message_field(message: &[u8], path: &[u32]) -> Result<bool, buffa::DecodeError> {
let Some((&field_number, remaining_path)) = path.split_first() else {
return Ok(false);
};
let mut remaining = message;
while !remaining.is_empty() {
let mut after_tag = remaining;
let tag = buffa::encoding::Tag::decode(&mut after_tag)?;
if tag.field_number() == field_number
&& tag.wire_type() == buffa::encoding::WireType::LengthDelimited
{
let value_range = length_delimited_value_range(message.len(), after_tag)?;
if remaining_path.is_empty()
|| contains_nested_message_field(&message[value_range.clone()], remaining_path)?
{
return Ok(true);
}
remaining = &message[value_range.end..];
} else {
buffa::encoding::skip_field_depth(tag, &mut after_tag, buffa::RECURSION_LIMIT)?;
remaining = after_tag;
}
}
Ok(false)
}
fn redact_nested_bytes_field(
message: &[u8],
absolute_offset: usize,
path: &[u32],
) -> Result<WireRedaction, buffa::DecodeError> {
let Some((&field_number, remaining_path)) = path.split_first() else {
return Ok(WireRedaction::default());
};
let mut result = WireRedaction::default();
let mut copied_until = 0;
let mut remaining = message;
while !remaining.is_empty() {
let field_start = message.len() - remaining.len();
let mut after_tag = remaining;
let tag = buffa::encoding::Tag::decode(&mut after_tag)?;
if tag.field_number() == field_number
&& tag.wire_type() == buffa::encoding::WireType::LengthDelimited
{
let tag_end = message.len() - after_tag.len();
let value_range = length_delimited_value_range(message.len(), after_tag)?;
if remaining_path.is_empty() {
let rewritten = result.rewritten.get_or_insert_with(Vec::new);
rewritten.extend_from_slice(&message[copied_until..field_start]);
copied_until = value_range.end;
result.detached_value =
Some(absolute_offset + value_range.start..absolute_offset + value_range.end);
} else {
let child = redact_nested_bytes_field(
&message[value_range.clone()],
absolute_offset + value_range.start,
remaining_path,
)?;
if let Some(child_range) = child.detached_value {
result.detached_value = Some(child_range);
}
if let Some(child_bytes) = child.rewritten {
let rewritten = result.rewritten.get_or_insert_with(Vec::new);
rewritten.extend_from_slice(&message[copied_until..field_start]);
rewritten.extend_from_slice(&message[field_start..tag_end]);
push_varint(child_bytes.len() as u64, rewritten);
rewritten.extend_from_slice(&child_bytes);
copied_until = value_range.end;
}
}
remaining = &message[value_range.end..];
} else {
buffa::encoding::skip_field_depth(tag, &mut after_tag, buffa::RECURSION_LIMIT)?;
remaining = after_tag;
}
}
if let Some(rewritten) = result.rewritten.as_mut() {
rewritten.extend_from_slice(&message[copied_until..]);
}
Ok(result)
}
fn length_delimited_value_range(
message_len: usize,
mut after_tag: &[u8],
) -> Result<std::ops::Range<usize>, buffa::DecodeError> {
let value_len = buffa::encoding::decode_varint(&mut after_tag)?;
let value_len = usize::try_from(value_len).map_err(|_| buffa::DecodeError::MessageTooLarge)?;
let value_start = message_len - after_tag.len();
let value_end = value_start
.checked_add(value_len)
.ok_or(buffa::DecodeError::MessageTooLarge)?;
if value_end > message_len {
return Err(buffa::DecodeError::UnexpectedEof);
}
Ok(value_start..value_end)
}
pub fn decode_plaintext_view(
padded_plaintext: &[u8],
padding_version: u8,
) -> Result<wa::MessageView<'_>> {
let plaintext_slice = MessageUtils::unpad_message_ref(padded_plaintext, padding_version)?;
wa::MessageView::decode_view(plaintext_slice)
.map_err(|e| anyhow::anyhow!("Failed to decode decrypted plaintext: {e}"))
}
pub fn decode_plaintext_owned_view(
padded_plaintext: Vec<u8>,
padding_version: u8,
) -> Result<wa::MessageOwnedView> {
let unpadded_len = MessageUtils::unpadded_message_len(&padded_plaintext, padding_version)?;
let plaintext = bytes::Bytes::from(padded_plaintext).slice(0..unpadded_len);
wa::MessageOwnedView::decode(plaintext)
.map_err(|e| anyhow::anyhow!("Failed to decode decrypted plaintext: {e}"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SenderKeyDistributionOnlyPlaintext<'a> {
pub axolotl_sender_key_distribution_message: Option<&'a [u8]>,
}
pub fn sender_key_distribution_only_plaintext(
padded_plaintext: &[u8],
padding_version: u8,
) -> Result<Option<SenderKeyDistributionOnlyPlaintext<'_>>> {
let plaintext_slice = MessageUtils::unpad_message_ref(padded_plaintext, padding_version)?;
if !has_only_sender_key_distribution_top_level_fields(plaintext_slice)? {
return Ok(None);
}
let view = wa::MessageView::decode_view(plaintext_slice)
.map_err(|e| anyhow::anyhow!("Failed to decode decrypted plaintext: {e}"))?;
let axolotl_sender_key_distribution_message = view
.sender_key_distribution_message
.as_option()
.and_then(|skdm| skdm.axolotl_sender_key_distribution_message);
Ok(Some(SenderKeyDistributionOnlyPlaintext {
axolotl_sender_key_distribution_message,
}))
}
pub fn has_only_sender_key_distribution_top_level_fields(
encoded: &[u8],
) -> Result<bool, buffa::DecodeError> {
use waproto::tags::message as m;
let mut cur = encoded;
let mut has_sender_key_distribution = false;
while !cur.is_empty() {
let tag = buffa::encoding::Tag::decode(&mut cur)?;
match tag.field_number() {
m::SENDER_KEY_DISTRIBUTION_MESSAGE
| m::FAST_RATCHET_KEY_SENDER_KEY_DISTRIBUTION_MESSAGE => {
has_sender_key_distribution = true
}
m::MESSAGE_CONTEXT_INFO => {}
_ => return Ok(false),
}
buffa::encoding::skip_field_depth(tag, &mut cur, buffa::RECURSION_LIMIT)?;
}
Ok(has_sender_key_distribution)
}
pub struct DmPlaintexts {
pub recipient: Vec<u8>,
pub own_devices: Vec<u8>,
}
const TAG_DEVICE_SENT_MESSAGE: u32 = waproto::tags::message::DEVICE_SENT_MESSAGE;
const TAG_MESSAGE_CONTEXT_INFO: u32 = waproto::tags::message::MESSAGE_CONTEXT_INFO;
const TAG_DSM_DESTINATION_JID: u32 = waproto::tags::message::device_sent_message::DESTINATION_JID;
const TAG_DSM_MESSAGE: u32 = waproto::tags::message::device_sent_message::MESSAGE;
const DEVICE_SENT_INNER_MESSAGE_PATH: &[u32] = &[TAG_DEVICE_SENT_MESSAGE, TAG_DSM_MESSAGE];
const DIRECT_HISTORY_PAYLOAD_PATH: &[u32] = &[
waproto::tags::message::PROTOCOL_MESSAGE,
waproto::tags::message::protocol_message::HISTORY_SYNC_NOTIFICATION,
waproto::tags::message::history_sync_notification::INITIAL_HIST_BOOTSTRAP_INLINE_PAYLOAD,
];
const DEVICE_SENT_HISTORY_PAYLOAD_PATH: &[u32] = &[
TAG_DEVICE_SENT_MESSAGE,
TAG_DSM_MESSAGE,
waproto::tags::message::PROTOCOL_MESSAGE,
waproto::tags::message::protocol_message::HISTORY_SYNC_NOTIFICATION,
waproto::tags::message::history_sync_notification::INITIAL_HIST_BOOTSTRAP_INLINE_PAYLOAD,
];
const PROTOBUF_WIRE_TYPE_BITS: u32 = 3;
#[inline]
fn push_varint(mut v: u64, out: &mut Vec<u8>) {
while v >= 0x80 {
out.push((v as u8) | 0x80);
v >>= 7;
}
out.push(v as u8);
}
#[inline]
fn wire_tag_value(field: u32, wire_type: buffa::encoding::WireType) -> u64 {
(u64::from(field) << PROTOBUF_WIRE_TYPE_BITS) | wire_type as u64
}
#[inline]
fn push_wire_tag(field: u32, wire_type: buffa::encoding::WireType, out: &mut Vec<u8>) {
push_varint(wire_tag_value(field, wire_type), out);
}
#[inline]
fn push_len_delimited(field: u32, bytes: &[u8], out: &mut Vec<u8>) {
push_wire_tag(field, buffa::encoding::WireType::LengthDelimited, out);
push_varint(bytes.len() as u64, out);
out.extend_from_slice(bytes);
}
#[inline]
fn varint_len(mut v: u64) -> usize {
let mut n = 1;
while v >= 0x80 {
v >>= 7;
n += 1;
}
n
}
#[inline]
fn len_delimited_len(field: u32, payload_len: usize) -> usize {
varint_len(wire_tag_value(
field,
buffa::encoding::WireType::LengthDelimited,
)) + varint_len(payload_len as u64)
+ payload_len
}
#[inline]
fn push_message_field(field: u32, msg: &wa::MessageContextInfo, out: &mut Vec<u8>) {
let mut cache = buffa::SizeCache::new();
let size = waproto::codec::message_context_info_compute_size(msg, &mut cache);
push_message_field_sized(field, msg, size, &mut cache, out);
}
#[inline]
fn push_message_field_sized(
field: u32,
msg: &wa::MessageContextInfo,
size: usize,
cache: &mut buffa::SizeCache,
out: &mut Vec<u8>,
) {
push_wire_tag(field, buffa::encoding::WireType::LengthDelimited, out);
push_varint(size as u64, out);
waproto::codec::message_context_info_write_to(msg, cache, out);
}
pub fn wrap_device_sent(mut message: wa::Message, destination_jid: String) -> wa::Message {
let context = std::mem::take(&mut message.message_context_info);
wa::Message {
message_context_info: context,
device_sent_message: wa::message::DeviceSentMessage {
destination_jid: Some(destination_jid),
message: message.into(),
..Default::default()
}
.into(),
..Default::default()
}
}
pub fn unwrap_device_sent(mut msg: wa::Message) -> wa::Message {
if let Some(mut dsm) = msg.device_sent_message.take() {
if let Some(mut inner) = dsm.message.take() {
inner.message_context_info = crate::proto_helpers::merge_dsm_context(
inner.message_context_info.take(),
msg.message_context_info.as_option(),
)
.map(buffa::MessageField::some)
.unwrap_or_default();
return inner;
}
msg.device_sent_message = buffa::MessageField::some(dsm);
}
msg
}
pub fn is_sender_key_distribution_only(msg: &mut wa::Message) -> bool {
if msg.sender_key_distribution_message.is_unset()
&& msg
.fast_ratchet_key_sender_key_distribution_message
.is_unset()
{
return false;
}
if msg.conversation.is_some()
|| msg.extended_text_message.is_set()
|| msg.image_message.is_set()
|| msg.video_message.is_set()
|| msg.audio_message.is_set()
|| msg.document_message.is_set()
|| msg.reaction_message.is_set()
|| msg.protocol_message.is_set()
|| msg.sticker_message.is_set()
|| msg.contact_message.is_set()
|| msg.location_message.is_set()
|| msg.live_location_message.is_set()
{
return false;
}
let skdm = msg.sender_key_distribution_message.take();
let fast = msg.fast_ratchet_key_sender_key_distribution_message.take();
let ctx = msg.message_context_info.take();
let mut cache = buffa::SizeCache::new();
let only = waproto::codec::message_compute_size(msg, &mut cache) == 0;
msg.sender_key_distribution_message = skdm.map(buffa::MessageField::some).unwrap_or_default();
msg.fast_ratchet_key_sender_key_distribution_message =
fast.map(buffa::MessageField::some).unwrap_or_default();
msg.message_context_info = ctx.map(buffa::MessageField::some).unwrap_or_default();
only
}
pub fn parse_message_info(
node: &wacore_binary::NodeRef<'_>,
own_jid: &wacore_binary::Jid,
own_lid: Option<&wacore_binary::Jid>,
) -> Result<crate::types::message::MessageInfo> {
use crate::types::message::{
AddressingMode, EditAttribute, MessageCategory, MessageInfo, MessageSource,
};
use wacore_binary::{JidExt as _, STATUS_BROADCAST_USER, Server};
let mut attrs = node.attrs();
let id = attrs.required_string("id")?;
anyhow::ensure!(
!id.is_empty(),
"message stanza has an empty required 'id' attribute"
);
let id = id.into_owned();
let from = attrs.required_jid("from")?;
let addressing_mode = attrs
.optional_string("addressing_mode")
.and_then(|s| AddressingMode::try_from(s.as_ref()).ok());
let mut source = if from.server == Server::Broadcast {
let participant = attrs.required_jid("participant")?;
let is_from_me = participant.matches_user_or_lid(own_jid, own_lid);
let sender_alt = if participant.server.is_pn_family() {
attrs.optional_jid("participant_lid")
} else if participant.server.is_lid_family() {
attrs.optional_jid("participant_pn")
} else {
None
};
MessageSource {
chat: from.clone(),
sender: participant.clone(),
is_from_me,
is_group: true,
broadcast_list_owner: if from.user != STATUS_BROADCAST_USER {
Some(participant.clone())
} else {
None
},
sender_alt,
..Default::default()
}
} else if from.is_group() {
let sender = attrs.required_jid("participant")?;
let sender_alt = match addressing_mode {
Some(AddressingMode::Lid) => attrs.optional_jid("participant_pn"),
Some(AddressingMode::Pn) => attrs.optional_jid("participant_lid"),
None => None,
};
let is_from_me = sender.matches_user_or_lid(own_jid, own_lid);
MessageSource {
chat: from.clone(),
sender: sender.clone(),
is_from_me,
is_group: true,
sender_alt,
..Default::default()
}
} else if from.matches_user_or_lid(own_jid, own_lid) {
let recipient = attrs.optional_jid_result("recipient")?;
let chat = recipient
.as_ref()
.map(|r| r.to_non_ad())
.unwrap_or_else(|| from.to_non_ad());
let sender_alt = if from.server == Server::Lid {
Some(own_jid.clone())
} else if from.server == Server::Pn && own_lid.is_some() {
own_lid.cloned()
} else {
None
};
MessageSource {
chat,
sender: from.clone(),
is_from_me: true,
recipient,
sender_alt,
..Default::default()
}
} else {
let sender_alt = if from.server == Server::Lid {
attrs.optional_jid("sender_pn")
} else {
attrs.optional_jid("sender_lid")
};
MessageSource {
chat: from.to_non_ad(),
sender: from.clone(),
is_from_me: false,
sender_alt,
..Default::default()
}
};
source.addressing_mode = addressing_mode;
let bcl_participants: Vec<wacore_binary::Jid> = if from.server == Server::Broadcast {
node.get_optional_child("participants")
.map(|p| {
p.get_children_by_tag("to")
.filter_map(|to| to.attrs().optional_jid("jid"))
.collect()
})
.unwrap_or_default()
} else {
Vec::new()
};
let category = attrs
.optional_string("category")
.map(|s| MessageCategory::from(s.as_ref()))
.unwrap_or_default();
let server_id = attrs
.optional_u64("server_id")
.filter(|&v| (99..=2_147_476_647).contains(&v))
.unwrap_or(0) as i32;
if source.chat.is_newsletter() {
source.chat.device = 0;
source.chat.agent = 0;
}
let is_offline = attrs.optional_string("offline").is_some();
let server_timestamp_us = attrs
.optional_u64("sts")
.and_then(|v| i64::try_from(v).ok());
let verified_level = attrs
.optional_string("verified_level")
.map(|s| s.into_owned());
let verified_name_serial = attrs
.optional_u64("verified_name")
.and_then(|v| i64::try_from(v).ok());
let verified_name = node
.get_optional_child("verified_name")
.and_then(|vn| crate::stanza::business::VerifiedName::try_from_node(vn).ok())
.map(Box::new);
let peer_recipient_pn = attrs.optional_jid("peer_recipient_pn");
let mut meta_info = crate::types::message::MsgMetaInfo::default();
if let Some(meta) = node.get_optional_child("meta") {
let mut ma = meta.attrs();
meta_info.content_type = ma.optional_string("content_type").map(|s| s.into_owned());
meta_info.appdata = ma.optional_string("appdata").map(|s| s.into_owned());
meta_info.target_id = ma.optional_string("target_id").map(|s| s.into_owned());
meta_info.target_sender = ma.optional_jid("target_sender_jid");
meta_info.target_chat = ma.optional_jid("target_chat_jid");
}
if let Some(reporting) = node.get_optional_child("reporting")
&& let Some(tag) = reporting.get_optional_child("reporting_tag")
{
meta_info.reporting_tag = tag.content_bytes().map(|b| b.to_vec());
}
if let Some(reporting) = node.get_optional_child("reporting")
&& let Some(token) = reporting.get_optional_child("reporting_token")
{
meta_info.reporting_token = token.content_bytes().map(|b| b.to_vec());
meta_info.reporting_token_version = Some(
token
.attrs()
.optional_u64("v")
.and_then(|v| i64::try_from(v).ok())
.unwrap_or(1),
);
}
let bot_info = node.get_optional_child("bot").map(|bot_node| {
let mut ba = bot_node.attrs();
crate::types::message::MsgBotInfo {
edit_type: ba
.optional_string("edit")
.and_then(|s| crate::types::message::BotEditType::from_wire(s.as_ref())),
edit_target_id: ba.optional_string("edit_target_id").map(|s| s.into_owned()),
edit_sender_timestamp_ms: ba
.optional_u64("sender_timestamp_ms")
.and_then(|ms| i64::try_from(ms).ok())
.and_then(crate::time::from_millis),
}
});
Ok(MessageInfo {
source,
id,
server_id,
push_name: attrs
.optional_string("notify")
.map(|s| s.to_string())
.unwrap_or_default(),
timestamp: crate::time::from_secs_or_now(attrs.unix_time("t")),
category,
edit: attrs
.optional_string("edit")
.map(|s| EditAttribute::from(s.as_ref()))
.unwrap_or_default(),
is_offline,
server_timestamp_us,
verified_level,
verified_name,
verified_name_serial,
peer_recipient_pn,
meta_info,
bot_info,
bcl_participants,
..Default::default()
})
}
#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod plaintext_view_tests {
use super::*;
fn padded(msg: &wa::Message) -> Vec<u8> {
MessageUtils::pad_message_v2(msg.encode_to_vec())
}
fn skdm(bytes: &[u8]) -> wa::message::SenderKeyDistributionMessage {
wa::message::SenderKeyDistributionMessage {
group_id: Some("120000000000000000@g.us".to_string()),
axolotl_sender_key_distribution_message: Some(bytes.to_vec()),
}
}
fn history_notification(payload: Vec<u8>) -> wa::message::HistorySyncNotification {
wa::message::HistorySyncNotification {
file_length: Some(payload.len() as u64),
sync_type: Some(wa::message::HistorySyncType::INITIAL_BOOTSTRAP),
initial_hist_bootstrap_inline_payload: Some(payload),
progress: Some(73),
..Default::default()
}
}
fn message_with_history(payload: Vec<u8>, text: &str) -> wa::Message {
wa::Message {
conversation: Some(text.to_owned()),
protocol_message: buffa::MessageField::some(wa::message::ProtocolMessage {
history_sync_notification: buffa::MessageField::some(history_notification(payload)),
..Default::default()
}),
..Default::default()
}
}
#[test]
fn decode_plaintext_view_borrows_message_fields() {
let msg = wa::Message {
conversation: Some("hello".to_string()),
..Default::default()
};
let padded = padded(&msg);
let view = decode_plaintext_view(&padded, 2).expect("view decode should succeed");
assert_eq!(view.conversation, Some("hello"));
}
#[test]
fn decode_plaintext_owned_view_keeps_unpadded_bytes() {
let msg = wa::Message {
conversation: Some("hello".to_string()),
..Default::default()
};
let padded = padded(&msg);
let padded_len = padded.len();
let view =
decode_plaintext_owned_view(padded, 2).expect("owned view decode should succeed");
assert_eq!(view.conversation(), Some("hello"));
assert!(view.bytes().len() < padded_len);
}
#[test]
fn detached_history_payload_shares_plaintext_and_preserves_message() {
let inline_payload = vec![0xA5; 1024];
let padded = padded(&message_with_history(inline_payload.clone(), "preserved"));
let plaintext_start = padded.as_ptr() as usize;
let plaintext_end = plaintext_start + padded.len();
let (decoded, detached) = decode_plaintext_detached_history_sync(padded, 2)
.expect("owned view decode should succeed");
let detached = detached.expect("history notification should be detached");
let payload = detached
.inline_payload
.expect("inline history payload should be detached");
assert_eq!(decoded.conversation.as_deref(), Some("preserved"));
assert!(
decoded
.protocol_message
.as_option()
.is_some_and(|protocol| !protocol.history_sync_notification.is_set()),
"only the detached history field should be cleared"
);
assert_eq!(detached.notification.file_length, Some(1024));
assert_eq!(detached.notification.progress, Some(73));
assert_eq!(payload.as_ref(), inline_payload);
assert!(
(plaintext_start..plaintext_end).contains(&(payload.as_ptr() as usize)),
"the detached payload must remain a slice of the original decrypt buffer"
);
}
#[test]
fn detached_history_follows_device_sent_unwrap_semantics() {
let inner_payload = vec![0x11; 64];
let inner = message_with_history(inner_payload.clone(), "inner");
let mut wrapped = wrap_device_sent(inner, "1@s.whatsapp.net".into());
wrapped.protocol_message = buffa::MessageField::some(wa::message::ProtocolMessage {
history_sync_notification: buffa::MessageField::some(history_notification(vec![
0x22;
32
])),
..Default::default()
});
let (decoded, detached) = decode_plaintext_detached_history_sync(padded(&wrapped), 2)
.expect("device-sent message should decode");
let decoded = unwrap_device_sent(decoded);
let detached = detached.expect("inner history notification should be detached");
assert_eq!(decoded.conversation.as_deref(), Some("inner"));
assert!(
decoded
.protocol_message
.as_option()
.is_some_and(|protocol| !protocol.history_sync_notification.is_set())
);
assert_eq!(detached.inline_payload.as_deref(), Some(&inner_payload[..]));
}
#[test]
fn sender_key_distribution_only_plaintext_returns_borrowed_axolotl() {
let msg = wa::Message {
sender_key_distribution_message: buffa::MessageField::some(skdm(&[1, 2, 3])),
..Default::default()
};
let padded = padded(&msg);
let found = sender_key_distribution_only_plaintext(&padded, 2)
.expect("view decode should succeed")
.expect("SKDM-only plaintext should be detected");
assert_eq!(
found.axolotl_sender_key_distribution_message,
Some(&[1, 2, 3][..])
);
}
#[test]
fn sender_key_distribution_only_plaintext_rejects_user_content() {
let msg = wa::Message {
conversation: Some("hello".to_string()),
sender_key_distribution_message: buffa::MessageField::some(skdm(&[1, 2, 3])),
..Default::default()
};
let padded = padded(&msg);
let found =
sender_key_distribution_only_plaintext(&padded, 2).expect("view scan should succeed");
assert!(found.is_none());
}
#[test]
fn sender_key_distribution_only_plaintext_allows_fast_ratchet_only() {
let msg = wa::Message {
fast_ratchet_key_sender_key_distribution_message: buffa::MessageField::some(skdm(&[
4, 5, 6,
])),
..Default::default()
};
let padded = padded(&msg);
let found = sender_key_distribution_only_plaintext(&padded, 2)
.expect("view decode should succeed")
.expect("fast-ratchet SKDM-only plaintext should be detected");
assert_eq!(found.axolotl_sender_key_distribution_message, None);
}
}
#[cfg(test)]
mod parse_message_info_tests {
use super::*;
use std::str::FromStr;
use wacore_binary::Jid;
use wacore_binary::builder::NodeBuilder;
#[test]
fn invalid_routing_and_identity_attributes_are_rejected() {
let own_pn = Jid::from_str("559900000000@s.whatsapp.net").unwrap();
let cases = [
NodeBuilder::new("message")
.attr("from", "559980000001@s.whatsapp.net")
.attr("id", "")
.build(),
NodeBuilder::new("message")
.attr("from", "not-a-jid")
.attr("id", "INVALID-FROM")
.build(),
NodeBuilder::new("message")
.attr("from", "120363021033254949@g.us")
.attr("id", "MISSING-PARTICIPANT")
.build(),
NodeBuilder::new("message")
.attr("from", "120363021033254949@g.us")
.attr("participant", "not-a-jid")
.attr("id", "INVALID-PARTICIPANT")
.build(),
NodeBuilder::new("message")
.attr("from", "559900000000:4@s.whatsapp.net")
.attr("recipient", "not-a-jid")
.attr("id", "INVALID-SELF-RECIPIENT")
.build(),
];
for node in &cases {
assert!(
parse_message_info(&node.as_node_ref(), &own_pn, None).is_err(),
"invalid identity or routing attributes must be rejected: {node:?}"
);
}
}
#[test]
fn status_broadcast_with_participant_lid_populates_sender_alt() {
let own_pn = Jid::from_str("559900000000@s.whatsapp.net").unwrap();
let own_lid = Jid::from_str("100000000000000@lid").unwrap();
let pn_user = "559980000001";
let lid_user = "100000012345678";
let node = NodeBuilder::new("message")
.attr("from", "status@broadcast")
.attr("type", "media")
.attr("id", "TEST_MSG_ID")
.attr("t", "1777415965")
.attr("participant", format!("{pn_user}@s.whatsapp.net").as_str())
.attr("participant_lid", format!("{lid_user}@lid").as_str())
.build();
let info = parse_message_info(&node.as_node_ref(), &own_pn, Some(&own_lid))
.expect("parse_message_info should succeed for status broadcast");
assert_eq!(info.source.sender.user, pn_user);
assert_eq!(info.source.sender.server, wacore_binary::Server::Pn);
let alt = info
.source
.sender_alt
.as_ref()
.expect("status broadcast must expose participant_lid as sender_alt");
assert_eq!(alt.user, lid_user);
assert_eq!(alt.server, wacore_binary::Server::Lid);
}
#[test]
fn envelope_enrichment_fields_are_captured() {
let own_pn = Jid::from_str("559900000000@s.whatsapp.net").unwrap();
let node = NodeBuilder::new("message")
.attr("from", "99000000000001@s.whatsapp.net")
.attr("type", "text")
.attr("id", "MSG-ENV-1")
.attr("t", "1777415965")
.attr("sts", "1777415965123456")
.attr("verified_level", "unknown")
.attr("verified_name", "12345")
.attr("peer_recipient_pn", "559980000099@s.whatsapp.net")
.build();
let info = parse_message_info(&node.as_node_ref(), &own_pn, None).unwrap();
assert_eq!(info.server_timestamp_us, Some(1777415965123456));
assert_eq!(info.verified_level.as_deref(), Some("unknown"));
assert_eq!(info.verified_name_serial, Some(12345));
assert_eq!(
info.peer_recipient_pn.as_ref().map(|j| j.user.as_str()),
Some("559980000099")
);
}
#[test]
#[allow(clippy::disallowed_methods)]
fn envelope_verified_name_cert_is_decoded() {
use buffa::Message;
let details = wa::verified_name_certificate::Details {
verified_name: Some("Fictitious Biz Ltd".into()),
issuer: Some("smb:wa".into()),
serial: Some(12345),
..Default::default()
};
let cert = wa::VerifiedNameCertificate {
details: Some(details.encode_to_vec()),
..Default::default()
};
let own_pn = Jid::from_str("559900000000@s.whatsapp.net").unwrap();
let node = NodeBuilder::new("message")
.attr("from", "99000000000001@s.whatsapp.net")
.attr("type", "text")
.attr("id", "MSG-VN-1")
.attr("t", "1777415965")
.attr("verified_name", "12345")
.children([NodeBuilder::new("verified_name")
.attr("v", "2")
.bytes(cert.encode_to_vec())
.build()])
.build();
let info = parse_message_info(&node.as_node_ref(), &own_pn, None).unwrap();
let vn = info.verified_name.expect("cert must reach MessageInfo");
assert_eq!(vn.name.as_deref(), Some("Fictitious Biz Ltd"));
assert_eq!(vn.serial.as_deref(), Some("12345"));
assert_eq!(vn.issuer.as_deref(), Some("smb:wa"));
assert_eq!(info.verified_name_serial, Some(12345));
}
#[test]
fn envelope_verified_name_bad_cert_does_not_fail_parse() {
let own_pn = Jid::from_str("559900000000@s.whatsapp.net").unwrap();
let node = NodeBuilder::new("message")
.attr("from", "99000000000001@s.whatsapp.net")
.attr("type", "text")
.attr("id", "MSG-VN-2")
.attr("t", "1777415965")
.children([NodeBuilder::new("verified_name")
.bytes(vec![0xff, 0x00, 0x13, 0x37])
.build()])
.build();
let info = parse_message_info(&node.as_node_ref(), &own_pn, None).unwrap();
let vn = info.verified_name.expect("node presence is surfaced");
assert!(vn.name.is_none());
}
#[test]
fn envelope_enrichment_is_optional() {
let own_pn = Jid::from_str("559900000000@s.whatsapp.net").unwrap();
let node = NodeBuilder::new("message")
.attr("from", "99000000000001@s.whatsapp.net")
.attr("type", "text")
.attr("id", "MSG-ENV-NONE")
.attr("t", "1777415965")
.build();
let info = parse_message_info(&node.as_node_ref(), &own_pn, None).unwrap();
assert!(info.server_timestamp_us.is_none());
assert!(info.verified_level.is_none());
assert!(info.verified_name.is_none());
assert!(info.verified_name_serial.is_none());
assert!(info.peer_recipient_pn.is_none());
}
#[test]
fn meta_child_attrs_are_captured() {
let own_pn = Jid::from_str("559900000000@s.whatsapp.net").unwrap();
let node = NodeBuilder::new("message")
.attr("from", "99000000000001@s.whatsapp.net")
.attr("type", "reaction")
.attr("id", "MSG-REACT-1")
.attr("t", "1777415965")
.children([NodeBuilder::new("meta")
.attr("content_type", "add_on")
.build()])
.build();
let info = parse_message_info(&node.as_node_ref(), &own_pn, None).unwrap();
assert_eq!(info.meta_info.content_type.as_deref(), Some("add_on"));
assert!(info.meta_info.appdata.is_none());
}
#[test]
fn reporting_token_and_tag_are_captured() {
let own_pn = Jid::from_str("559900000000@s.whatsapp.net").unwrap();
let tag_bytes: Vec<u8> = (0..16).collect();
let token_bytes: Vec<u8> = (16..32).collect();
let node = NodeBuilder::new("message")
.attr("from", "99000000000001@s.whatsapp.net")
.attr("type", "text")
.attr("id", "MSG-REP-1")
.attr("t", "1777415965")
.children([NodeBuilder::new("reporting")
.children([
NodeBuilder::new("reporting_tag")
.bytes(tag_bytes.clone())
.build(),
NodeBuilder::new("reporting_token")
.attr("v", "2")
.bytes(token_bytes.clone())
.build(),
])
.build()])
.build();
let info = parse_message_info(&node.as_node_ref(), &own_pn, None).unwrap();
assert_eq!(
info.meta_info.reporting_tag.as_deref(),
Some(tag_bytes.as_slice())
);
assert_eq!(
info.meta_info.reporting_token.as_deref(),
Some(token_bytes.as_slice())
);
assert_eq!(info.meta_info.reporting_token_version, Some(2));
}
#[test]
fn reporting_token_missing_version_defaults_to_one() {
let own_pn = Jid::from_str("559900000000@s.whatsapp.net").unwrap();
let node = NodeBuilder::new("message")
.attr("from", "99000000000001@s.whatsapp.net")
.attr("type", "text")
.attr("id", "MSG-REP-V")
.attr("t", "1777415965")
.children([NodeBuilder::new("reporting")
.children([NodeBuilder::new("reporting_token")
.bytes(vec![0xAA; 16])
.build()])
.build()])
.build();
let info = parse_message_info(&node.as_node_ref(), &own_pn, None).unwrap();
assert_eq!(info.meta_info.reporting_token_version, Some(1));
}
#[test]
fn reporting_tag_only_leaves_token_none() {
let own_pn = Jid::from_str("559900000000@s.whatsapp.net").unwrap();
let node = NodeBuilder::new("message")
.attr("from", "99000000000001@s.whatsapp.net")
.attr("type", "text")
.attr("id", "MSG-REP-2")
.attr("t", "1777415965")
.children([NodeBuilder::new("reporting")
.children([NodeBuilder::new("reporting_tag")
.bytes(vec![1u8; 16])
.build()])
.build()])
.build();
let info = parse_message_info(&node.as_node_ref(), &own_pn, None).unwrap();
assert!(info.meta_info.reporting_tag.is_some());
assert!(info.meta_info.reporting_token.is_none());
assert!(info.meta_info.reporting_token_version.is_none());
}
#[test]
fn meta_and_reporting_absent_leaves_all_none() {
let own_pn = Jid::from_str("559900000000@s.whatsapp.net").unwrap();
let node = NodeBuilder::new("message")
.attr("from", "99000000000001@s.whatsapp.net")
.attr("type", "text")
.attr("id", "MSG-PLAIN")
.attr("t", "1777415965")
.build();
let info = parse_message_info(&node.as_node_ref(), &own_pn, None).unwrap();
assert!(info.meta_info.content_type.is_none());
assert!(info.meta_info.appdata.is_none());
assert!(info.meta_info.reporting_tag.is_none());
assert!(info.meta_info.reporting_token.is_none());
}
#[test]
fn status_broadcast_with_participant_pn_populates_sender_alt() {
let own_pn = Jid::from_str("559900000000@s.whatsapp.net").unwrap();
let own_lid = Jid::from_str("100000000000000@lid").unwrap();
let pn_user = "559980000001";
let lid_user = "100000012345678";
let node = NodeBuilder::new("message")
.attr("from", "status@broadcast")
.attr("type", "media")
.attr("id", "TEST_LID_FIRST_MSG_ID")
.attr("t", "1777415965")
.attr("participant", format!("{lid_user}@lid").as_str())
.attr(
"participant_pn",
format!("{pn_user}@s.whatsapp.net").as_str(),
)
.build();
let info = parse_message_info(&node.as_node_ref(), &own_pn, Some(&own_lid))
.expect("parse_message_info should succeed for LID-addressed status");
assert_eq!(info.source.sender.user, lid_user);
assert_eq!(info.source.sender.server, wacore_binary::Server::Lid);
let alt = info
.source
.sender_alt
.as_ref()
.expect("LID-addressed status broadcast must expose participant_pn as sender_alt");
assert_eq!(alt.user, pn_user);
assert_eq!(alt.server, wacore_binary::Server::Pn);
}
#[test]
fn random_pad_len_is_uniform_1_to_16() {
let mut saw_16 = false;
for _ in 0..5_000 {
let p = MessageUtils::random_pad_len();
assert!((1..=16).contains(&p), "pad len {p} out of 1..=16");
saw_16 |= p == 16;
}
assert!(
saw_16,
"pad len 16 must be reachable (was unreachable before)"
);
}
#[test]
fn phash_crosscheck_vectors() {
fn dev(user: &str, device: u16, server: wacore_binary::Server) -> Jid {
Jid {
user: user.into(),
server,
agent: 0,
device,
integrator: 0,
}
}
let single = vec![dev("5511999999999", 3, wacore_binary::Server::Pn)];
assert_eq!(
single[0].to_phash_form_string(),
"5511999999999.0:3@s.whatsapp.net"
);
let h_single = MessageUtils::participant_list_hash(&single).unwrap();
let control = vec![dev("5511999999999", 0, wacore_binary::Server::Pn)];
let h_control = MessageUtils::participant_list_hash(&control).unwrap();
let multi = vec![
dev("5511988887777", 14, wacore_binary::Server::Pn),
dev("7469250125917", 21, wacore_binary::Server::Pn),
];
let h_multi = MessageUtils::participant_list_hash(&multi).unwrap();
eprintln!("RUST_PHASH single = {h_single}");
eprintln!("RUST_PHASH control = {h_control}");
eprintln!("RUST_PHASH multi = {h_multi}");
assert_eq!(h_single, "2:5s+YxCff");
assert_eq!(h_control, "2:RJWVxcMQ");
assert_eq!(h_multi, "2:AAv/hwhn");
}
#[test]
fn phash_arena_matches_per_string_reference() {
use sha2::{Digest, Sha256};
fn dev(user: &str, agent: u8, device: u16, server: wacore_binary::Server) -> Jid {
Jid {
user: user.into(),
server,
agent,
device,
integrator: 0,
}
}
let devices = vec![
dev("5511999990000", 0, 14, wacore_binary::Server::Pn),
dev("111", 0, 0, wacore_binary::Server::Pn),
dev("1110", 0, 0, wacore_binary::Server::Pn),
dev("100000000000001", 2, 3, wacore_binary::Server::Lid),
dev("5511999990000", 0, 14, wacore_binary::Server::Pn),
dev("5511888880000", 1, 0, wacore_binary::Server::Hosted),
dev("999", 0, 65535, wacore_binary::Server::Bot),
];
let mut reference: Vec<String> = devices.iter().map(|j| j.to_phash_form_string()).collect();
reference.sort_unstable();
let mut hasher = Sha256::new();
for jid in &reference {
hasher.update(jid.as_bytes());
}
let digest = hasher.finalize();
let mut expected = String::with_capacity(10);
expected.push_str("2:");
use base64::Engine as _;
base64::prelude::BASE64_STANDARD_NO_PAD.encode_string(&digest[..6], &mut expected);
assert_eq!(
MessageUtils::participant_list_hash(&devices).unwrap(),
expected
);
}
#[test]
fn validate_bcl_hash_matches_and_rejects() {
let participants = vec![
Jid::from_str("100000000000001@lid").unwrap(),
Jid::from_str("100000000000002@lid").unwrap(),
];
let good = MessageUtils::participant_list_hash(&participants).unwrap();
assert!(MessageUtils::validate_bcl_hash(&participants, &good));
assert!(!MessageUtils::validate_bcl_hash(&participants, "2:wrongxx"));
}
#[test]
fn broadcast_populates_bcl_participants() {
let own_pn = Jid::from_str("559900000000@s.whatsapp.net").unwrap();
let node = NodeBuilder::new("message")
.attr("from", "status@broadcast")
.attr("type", "media")
.attr("id", "BCL-1")
.attr("t", "1777415965")
.attr("participant", "559980000001@s.whatsapp.net")
.children([NodeBuilder::new("participants")
.children([
NodeBuilder::new("to")
.attr("jid", "100000000000001@lid")
.build(),
NodeBuilder::new("to")
.attr("jid", "100000000000002@lid")
.build(),
])
.build()])
.build();
let info = parse_message_info(&node.as_node_ref(), &own_pn, None).unwrap();
assert_eq!(info.bcl_participants.len(), 2);
}
#[test]
fn group_participants_do_not_populate_bcl() {
let own_pn = Jid::from_str("559900000000@s.whatsapp.net").unwrap();
let node = NodeBuilder::new("message")
.attr("from", "120363000000000001@g.us")
.attr("participant", "559980000001@s.whatsapp.net")
.attr("type", "text")
.attr("id", "G-1")
.attr("t", "1777415965")
.children([NodeBuilder::new("participants")
.children([NodeBuilder::new("to")
.attr("jid", "559980000002:3@s.whatsapp.net")
.build()])
.build()])
.build();
let info = parse_message_info(&node.as_node_ref(), &own_pn, None).unwrap();
assert!(
info.bcl_participants.is_empty(),
"group fanout participants are not a bcl"
);
}
}
#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod device_sent_tests {
#[test]
fn a_jid_measures_itself_exactly_as_it_renders() {
use wacore_binary::jid::Jid;
let cases = [
"5511987650001@s.whatsapp.net",
"5511987650001:5@s.whatsapp.net",
"5511987650001.2:5@s.whatsapp.net",
"120363021033254949@g.us",
"100000012345678:25@lid",
"867051314767696:0@bot",
"status@broadcast",
"ẞünïcodé-ñ@s.whatsapp.net",
];
for case in cases {
let jid: Jid = case.parse().unwrap_or_else(|e| panic!("parse {case}: {e}"));
let mut written = Vec::new();
jid.write_into(&mut written);
assert_eq!(
jid.encoded_len(),
written.len(),
"{case}: the counted length must equal the bytes written"
);
assert_eq!(
written,
jid.to_string().into_bytes(),
"{case}: writing directly must match rendering through a String"
);
}
}
#[test]
fn naming_the_destination_by_jid_matches_naming_it_by_string() {
use wacore_binary::jid::Jid;
let jid: Jid = "5511987650001:5@s.whatsapp.net".parse().expect("parse");
let message = wa::Message {
conversation: Some("destination check".to_string()),
..Default::default()
};
let content = waproto::codec::message_to_vec(&message);
let by_string =
MessageUtils::dm_plaintexts_from_encoded(&content, None, jid.to_string().as_str());
let by_jid = MessageUtils::dm_plaintexts_from_encoded(&content, None, &jid);
let prefix = by_jid.own_devices.len().min(by_string.own_devices.len()) - 16;
assert_eq!(
by_jid.own_devices[..prefix],
by_string.own_devices[..prefix],
"the DSM bytes must not depend on how the destination was named"
);
}
#[test]
fn every_string_wrapper_names_the_same_destination() {
use std::borrow::Cow;
use std::rc::Rc;
use std::sync::Arc;
let message = wa::Message {
conversation: Some("wrapper check".to_string()),
..Default::default()
};
let content = waproto::codec::message_to_vec(&message);
let dest = "5511987650001:5@s.whatsapp.net";
let mut owned = dest.to_string();
let boxed: Box<str> = dest.into();
let rc: Rc<str> = dest.into();
let arc: Arc<str> = dest.into();
let cow: Cow<'_, str> = Cow::Borrowed(dest);
let mut owned_mut = dest.to_string();
let reference = MessageUtils::dm_plaintexts_from_encoded(&content, None, dest);
let prefix = reference.own_devices.len() - 16;
let by_mut_string =
MessageUtils::dm_plaintexts_from_encoded(&content, None, &mut owned_mut);
let by_mut_str =
MessageUtils::dm_plaintexts_from_encoded(&content, None, &mut *owned.as_mut_str());
let by_mut_through_encode =
MessageUtils::encode_dm_plaintexts(&message, None, &mut owned_mut);
#[allow(
clippy::needless_borrows_for_generic_args,
reason = "the nested reference is what this asserts is accepted"
)]
let (by_double, by_triple, by_ref_to_owned) = (
MessageUtils::dm_plaintexts_from_encoded(&content, None, &dest),
MessageUtils::dm_plaintexts_from_encoded(&content, None, &&dest),
MessageUtils::dm_plaintexts_from_encoded(&content, None, &&owned),
);
for (name, produced) in [
("&&str", by_double),
("&&&str", by_triple),
("&&String", by_ref_to_owned),
("&mut String", by_mut_string),
("&mut str", by_mut_str),
(
"&mut String via encode_dm_plaintexts",
by_mut_through_encode,
),
(
"String",
MessageUtils::dm_plaintexts_from_encoded(&content, None, &owned),
),
(
"Box<str>",
MessageUtils::dm_plaintexts_from_encoded(&content, None, &boxed),
),
(
"Rc<str>",
MessageUtils::dm_plaintexts_from_encoded(&content, None, &rc),
),
(
"Arc<str>",
MessageUtils::dm_plaintexts_from_encoded(&content, None, &arc),
),
(
"Cow<str>",
MessageUtils::dm_plaintexts_from_encoded(&content, None, &cow),
),
] {
assert_eq!(
produced.own_devices[..prefix],
reference.own_devices[..prefix],
"a destination held in {name} must name itself exactly as &str does"
);
}
}
use super::*;
fn msg_with_secret(secret: &[u8]) -> wa::Message {
wa::Message {
conversation: Some("hi".into()),
message_context_info: wa::MessageContextInfo {
message_secret: Some(secret.to_vec()),
..Default::default()
}
.into(),
..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_from_slice(bytes.as_slice()).unwrap();
assert_eq!(
decoded
.message_context_info
.as_option()
.and_then(|c| c.message_secret.as_deref()),
Some(secret.as_slice())
);
let inner = decoded
.device_sent_message
.as_option()
.unwrap()
.message
.as_option()
.unwrap();
assert!(inner.message_context_info.is_unset());
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_unset());
let dsm = wrapped.device_sent_message.as_option().unwrap();
assert_eq!(dsm.destination_jid.as_deref(), Some("1@s.whatsapp.net"));
assert!(
dsm.message
.as_option()
.unwrap()
.message_context_info
.is_unset()
);
}
#[test]
fn wrap_then_unwrap_preserves_non_secret_context_fields() {
let inner = wa::Message {
message_context_info: wa::MessageContextInfo {
message_add_on_duration_in_secs: Some(604800),
..Default::default()
}
.into(),
..Default::default()
};
let unwrapped = unwrap_device_sent(wrap_device_sent(inner, "1@s.whatsapp.net".into()));
assert_eq!(
unwrapped
.message_context_info
.as_option()
.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
.as_option()
.and_then(|c| c.message_secret.as_deref()),
Some(secret.as_slice())
);
}
fn decode_padded(b: &[u8]) -> wa::Message {
wa::Message::decode_from_slice(MessageUtils::unpad_message_ref(b, 2).unwrap()).unwrap()
}
fn assert_splice_matches(message: wa::Message, dest: &str) {
let recipient_old = decode_padded(&MessageUtils::encode_and_pad(&message));
let dsm_old = decode_padded(&MessageUtils::encode_and_pad(&wrap_device_sent(
message.clone(),
dest.to_string(),
)));
let DmPlaintexts {
recipient,
own_devices,
} = MessageUtils::encode_dm_plaintexts(&message, None, dest);
assert_eq!(
decode_padded(&recipient),
recipient_old,
"recipient mismatch"
);
assert_eq!(
decode_padded(&own_devices),
dsm_old,
"own-device DSM mismatch"
);
}
#[test]
fn splice_matches_prost_across_message_shapes() {
let dest = "5511999998888:3@s.whatsapp.net";
assert_splice_matches(
wa::Message {
conversation: Some("ping".into()),
..Default::default()
},
dest,
);
assert_splice_matches(
wa::Message {
conversation: Some("héllo 🚀 ".repeat(500)),
..Default::default()
},
dest,
);
assert_splice_matches(msg_with_secret(&[42u8; 32]), dest);
assert_splice_matches(
wa::Message {
extended_text_message: wa::message::ExtendedTextMessage {
text: Some("quoted".into()),
context_info: wa::ContextInfo {
is_forwarded: Some(true),
..Default::default()
}
.into(),
..Default::default()
}
.into(),
message_context_info: wa::MessageContextInfo {
message_secret: Some(vec![1, 2, 3, 4]),
..Default::default()
}
.into(),
..Default::default()
},
dest,
);
assert_splice_matches(
wa::Message {
image_message: wa::message::ImageMessage {
url: Some("https://mmg.example/abc".into()),
media_key: Some(vec![9u8; 32]),
file_sha256: Some(vec![8u8; 32]),
mimetype: Some("image/jpeg".into()),
..Default::default()
}
.into(),
..Default::default()
},
dest,
);
assert_splice_matches(wa::Message::default(), dest);
assert_splice_matches(
wa::Message {
message_context_info: wa::MessageContextInfo {
message_secret: Some(vec![7u8; 32]),
..Default::default()
}
.into(),
..Default::default()
},
dest,
);
assert_splice_matches(
wa::Message {
conversation: Some("x".into()),
..Default::default()
},
"",
);
}
#[test]
fn splice_tags_match_generated_schema() {
fn first_field_number(mut bytes: &[u8]) -> u32 {
buffa::encoding::Tag::decode(&mut bytes)
.expect("probe should start with a valid protobuf tag")
.field_number()
}
let outer_dsm = wa::Message {
device_sent_message: wa::message::DeviceSentMessage::default().into(),
..Default::default()
};
assert_eq!(
first_field_number(&outer_dsm.encode_to_vec()),
TAG_DEVICE_SENT_MESSAGE,
"Message.device_sent_message tag drifted from the .proto"
);
let outer_mci = wa::Message {
message_context_info: wa::MessageContextInfo::default().into(),
..Default::default()
};
assert_eq!(
first_field_number(&outer_mci.encode_to_vec()),
TAG_MESSAGE_CONTEXT_INFO,
"Message.message_context_info tag drifted from the .proto"
);
let dsm_dest = wa::message::DeviceSentMessage {
destination_jid: Some("x".into()),
..Default::default()
};
assert_eq!(
first_field_number(&dsm_dest.encode_to_vec()),
TAG_DSM_DESTINATION_JID,
"DeviceSentMessage.destination_jid tag drifted from the .proto"
);
let dsm_msg = wa::message::DeviceSentMessage {
message: wa::Message::default().into(),
..Default::default()
};
assert_eq!(
first_field_number(&dsm_msg.encode_to_vec()),
TAG_DSM_MESSAGE,
"DeviceSentMessage.message tag drifted from the .proto"
);
}
fn context_test_shapes() -> Vec<wa::Message> {
vec![
wa::Message {
conversation: Some("ping".into()),
..Default::default()
},
wa::Message {
conversation: Some("poll".into()),
message_context_info: wa::MessageContextInfo {
message_add_on_duration_in_secs: Some(604800),
message_secret: Some(vec![1u8; 32]),
..Default::default()
}
.into(),
..Default::default()
},
]
}
fn reporting_context(secret: &[u8; 32]) -> wa::MessageContextInfo {
wa::MessageContextInfo {
message_secret: Some(secret.to_vec()),
reporting_token_version: Some(crate::reporting_token::REPORTING_TOKEN_VERSION),
..Default::default()
}
}
#[test]
fn splice_with_reporting_context_matches_prepare() {
let dest = "5511999998888:3@s.whatsapp.net";
let secret = [0x5Au8; 32];
let extra = reporting_context(&secret);
for message in context_test_shapes() {
let reference = crate::reporting_token::prepare_message_with_context(&message, &secret);
let recipient_ref = decode_padded(&MessageUtils::encode_and_pad(&reference));
let dsm_ref = decode_padded(&MessageUtils::encode_and_pad(&wrap_device_sent(
reference.clone(),
dest.to_string(),
)));
let DmPlaintexts {
recipient,
own_devices,
} = MessageUtils::encode_dm_plaintexts(&message, Some(&extra), dest);
assert_eq!(
decode_padded(&recipient),
recipient_ref,
"recipient mismatch for {message:?}"
);
assert_eq!(
decode_padded(&own_devices),
dsm_ref,
"own-device DSM mismatch for {message:?}"
);
}
}
#[test]
fn recipient_only_encode_matches_dm_recipient_without_top_level_mci() {
let dest = "5511999998888:3@s.whatsapp.net";
let reporting_ctx = reporting_context(&[0x5Au8; 32]);
let shapes = [
wa::Message {
conversation: Some("ping".into()),
..Default::default()
},
wa::Message {
image_message: wa::message::ImageMessage {
url: Some("https://mmg.example/abc".into()),
media_key: Some(vec![9u8; 32]),
..Default::default()
}
.into(),
..Default::default()
},
];
for message in shapes {
assert!(
message.message_context_info.is_unset(),
"fast path only applies to messages without a top-level mci"
);
for extra in [None, Some(&reporting_ctx)] {
let recipient_only = MessageUtils::encode_and_pad_with_context(&message, extra);
let dm_recipient =
MessageUtils::encode_dm_plaintexts(&message, extra, dest).recipient;
assert_eq!(
decode_padded(&recipient_only),
decode_padded(&dm_recipient),
"recipient-only encode diverged from encode_dm_plaintexts ({message:?}, extra={extra:?})"
);
}
}
}
#[test]
fn from_encoded_encoders_match_reencoding_without_top_level_mci() {
let dest = "5511999998888:3@s.whatsapp.net";
let reporting_ctx = reporting_context(&[0x5Au8; 32]);
let unpad = |b: &[u8]| MessageUtils::unpad_message_ref(b, 2).unwrap().to_vec();
let shapes = [
wa::Message {
conversation: Some("ping".into()),
..Default::default()
},
wa::Message {
conversation: Some("héllo 🚀 ".repeat(500)),
..Default::default()
},
wa::Message {
image_message: wa::message::ImageMessage {
url: Some("https://mmg.example/abc".into()),
media_key: Some(vec![9u8; 32]),
..Default::default()
}
.into(),
..Default::default()
},
];
for message in shapes {
assert!(
message.message_context_info.is_unset(),
"the _from_encoded path only applies to messages without a top-level mci"
);
let content = message.encode_to_vec();
for extra in [None, Some(&reporting_ctx)] {
assert_eq!(
unpad(&MessageUtils::pad_with_context_from_encoded(
&content, extra
)),
unpad(&MessageUtils::encode_and_pad_with_context(&message, extra)),
"pad_with_context_from_encoded diverged ({message:?}, extra={extra:?})"
);
let from_encoded = MessageUtils::dm_plaintexts_from_encoded(&content, extra, dest);
let reencoded = MessageUtils::encode_dm_plaintexts(&message, extra, dest);
assert_eq!(
unpad(&from_encoded.recipient),
unpad(&reencoded.recipient),
"dm_plaintexts_from_encoded recipient diverged ({message:?}, extra={extra:?})"
);
assert_eq!(
unpad(&from_encoded.own_devices),
unpad(&reencoded.own_devices),
"dm_plaintexts_from_encoded own_devices diverged ({message:?}, extra={extra:?})"
);
}
}
}
#[test]
fn group_encode_with_context_matches_prepare() {
let secret = [0x33u8; 32];
let extra = reporting_context(&secret);
for message in context_test_shapes() {
let reference = crate::reporting_token::prepare_message_with_context(&message, &secret);
let ref_decoded = decode_padded(&MessageUtils::encode_and_pad(&reference));
let got = decode_padded(&MessageUtils::encode_and_pad_with_context(
&message,
Some(&extra),
));
assert_eq!(got, ref_decoded, "group encode-with-context mismatch");
}
let plain = wa::Message {
conversation: Some("x".into()),
..Default::default()
};
assert_eq!(
decode_padded(&MessageUtils::encode_and_pad_with_context(&plain, None)),
decode_padded(&MessageUtils::encode_and_pad(&plain)),
"encode_and_pad_with_context(None) must equal encode_and_pad"
);
}
}