use smallvec::SmallVec;
use std::{fmt, sync::LazyLock};
use anyhow::{Result, anyhow};
use hkdf::Hkdf;
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
use wacore_binary::Jid;
use wacore_binary::Node;
use wacore_binary::builder::NodeBuilder;
use waproto::whatsapp as wa;
mod wire_type {
pub const VARINT: u32 = 0;
pub const FIXED64: u32 = 1;
pub const LENGTH_DELIMITED: u32 = 2;
pub const FIXED32: u32 = 5;
}
#[derive(Debug, Clone, Copy)]
pub struct ReportingField {
pub field_number: u32,
pub subfields: Option<&'static [ReportingField]>,
pub is_message: bool,
}
impl ReportingField {
pub const fn new(field_number: u32) -> Self {
Self {
field_number,
subfields: None,
is_message: false,
}
}
pub const fn with_subfields(field_number: u32, subfields: &'static [ReportingField]) -> Self {
Self {
field_number,
subfields: Some(subfields),
is_message: false,
}
}
pub const fn message(field_number: u32) -> Self {
Self {
field_number,
subfields: None,
is_message: true,
}
}
}
static CONTEXT_INFO_SUBFIELDS: &[ReportingField] = &[
ReportingField::new(21), ReportingField::new(22), ];
static FUTURE_PROOF_SUBFIELDS: &[ReportingField] = &[ReportingField::message(1)];
static IMAGE_MESSAGE_SUBFIELDS: &[ReportingField] = &[
ReportingField::new(2), ReportingField::new(3), ReportingField::new(8), ReportingField::new(11), ReportingField::with_subfields(17, CONTEXT_INFO_SUBFIELDS), ReportingField::new(25), ];
static CONTACT_MESSAGE_SUBFIELDS: &[ReportingField] = &[
ReportingField::new(1), ReportingField::new(16), ReportingField::with_subfields(17, CONTEXT_INFO_SUBFIELDS), ];
static LOCATION_MESSAGE_SUBFIELDS: &[ReportingField] = &[
ReportingField::new(3), ReportingField::new(4), ReportingField::new(5), ReportingField::new(16), ReportingField::with_subfields(17, CONTEXT_INFO_SUBFIELDS), ];
static EXTENDED_TEXT_MESSAGE_SUBFIELDS: &[ReportingField] = &[
ReportingField::new(1), ReportingField::with_subfields(17, CONTEXT_INFO_SUBFIELDS), ReportingField::new(30), ];
static DOCUMENT_MESSAGE_SUBFIELDS: &[ReportingField] = &[
ReportingField::new(2), ReportingField::new(7), ReportingField::new(10), ReportingField::with_subfields(17, CONTEXT_INFO_SUBFIELDS), ReportingField::new(20), ];
static AUDIO_MESSAGE_SUBFIELDS: &[ReportingField] = &[
ReportingField::new(2), ReportingField::new(7), ReportingField::new(9), ReportingField::with_subfields(17, CONTEXT_INFO_SUBFIELDS), ReportingField::new(21), ];
static VIDEO_MESSAGE_SUBFIELDS: &[ReportingField] = &[
ReportingField::new(2), ReportingField::new(6), ReportingField::new(7), ReportingField::new(13), ReportingField::with_subfields(17, CONTEXT_INFO_SUBFIELDS), ReportingField::new(20), ];
static PROTOCOL_MESSAGE_SUBFIELDS: &[ReportingField] = &[
ReportingField::new(1), ReportingField::new(2), ReportingField::message(14), ReportingField::new(15), ];
static LIVE_LOCATION_MESSAGE_SUBFIELDS: &[ReportingField] = &[
ReportingField::new(6), ReportingField::new(16), ReportingField::with_subfields(17, CONTEXT_INFO_SUBFIELDS), ];
static STICKER_MESSAGE_SUBFIELDS: &[ReportingField] = &[
ReportingField::new(4), ReportingField::new(5), ReportingField::new(8), ReportingField::new(13), ReportingField::with_subfields(17, CONTEXT_INFO_SUBFIELDS), ];
static GROUP_INVITE_MESSAGE_SUBFIELDS: &[ReportingField] = &[
ReportingField::new(1), ReportingField::new(2), ReportingField::new(4), ReportingField::new(5), ReportingField::new(6), ReportingField::with_subfields(7, CONTEXT_INFO_SUBFIELDS), ];
static POLL_OPTION_SUBFIELDS: &[ReportingField] = &[
ReportingField::new(1), ReportingField::new(2), ];
static POLL_CREATION_MESSAGE_SUBFIELDS: &[ReportingField] = &[
ReportingField::new(2), ReportingField::with_subfields(3, POLL_OPTION_SUBFIELDS), ReportingField::with_subfields(5, CONTEXT_INFO_SUBFIELDS), ReportingField::with_subfields(8, POLL_OPTION_SUBFIELDS), ];
static POLL_RESULT_OPTION_SUBFIELDS: &[ReportingField] = &[
ReportingField::new(1), ];
static POLL_RESULT_SUBFIELDS: &[ReportingField] = &[
ReportingField::new(1), ReportingField::with_subfields(2, POLL_RESULT_OPTION_SUBFIELDS), ReportingField::with_subfields(3, CONTEXT_INFO_SUBFIELDS), ];
pub static REPORTING_FIELDS: &[ReportingField] = &[
ReportingField::new(1), ReportingField::with_subfields(3, IMAGE_MESSAGE_SUBFIELDS), ReportingField::with_subfields(4, CONTACT_MESSAGE_SUBFIELDS), ReportingField::with_subfields(5, LOCATION_MESSAGE_SUBFIELDS), ReportingField::with_subfields(6, EXTENDED_TEXT_MESSAGE_SUBFIELDS), ReportingField::with_subfields(7, DOCUMENT_MESSAGE_SUBFIELDS), ReportingField::with_subfields(8, AUDIO_MESSAGE_SUBFIELDS), ReportingField::with_subfields(9, VIDEO_MESSAGE_SUBFIELDS), ReportingField::with_subfields(12, PROTOCOL_MESSAGE_SUBFIELDS), ReportingField::with_subfields(18, LIVE_LOCATION_MESSAGE_SUBFIELDS), ReportingField::with_subfields(26, STICKER_MESSAGE_SUBFIELDS), ReportingField::with_subfields(28, GROUP_INVITE_MESSAGE_SUBFIELDS), ReportingField::with_subfields(37, FUTURE_PROOF_SUBFIELDS), ReportingField::with_subfields(49, POLL_CREATION_MESSAGE_SUBFIELDS), ReportingField::with_subfields(53, FUTURE_PROOF_SUBFIELDS), ReportingField::with_subfields(55, FUTURE_PROOF_SUBFIELDS), ReportingField::with_subfields(58, FUTURE_PROOF_SUBFIELDS), ReportingField::with_subfields(59, FUTURE_PROOF_SUBFIELDS), ReportingField::with_subfields(60, POLL_CREATION_MESSAGE_SUBFIELDS), ReportingField::with_subfields(64, POLL_CREATION_MESSAGE_SUBFIELDS), ReportingField::with_subfields(66, VIDEO_MESSAGE_SUBFIELDS), ReportingField::with_subfields(74, FUTURE_PROOF_SUBFIELDS), ReportingField::with_subfields(87, FUTURE_PROOF_SUBFIELDS), ReportingField::with_subfields(88, POLL_RESULT_SUBFIELDS), ReportingField::with_subfields(92, FUTURE_PROOF_SUBFIELDS), ReportingField::with_subfields(93, FUTURE_PROOF_SUBFIELDS), ReportingField::with_subfields(94, FUTURE_PROOF_SUBFIELDS), ];
pub const REPORTING_TOKEN_VERSION: i32 = 2;
pub const MESSAGE_SECRET_SIZE: usize = 32;
pub const REPORTING_TOKEN_KEY_SIZE: usize = 32;
pub const REPORTING_TOKEN_SIZE: usize = 16;
const USE_CASE_REPORT_TOKEN: &str = "Report Token";
const REPORTING_TOKEN_INFO_INLINE_CAPACITY: usize = 128;
enum ReportingTokenInfo {
Inline {
bytes: [u8; REPORTING_TOKEN_INFO_INLINE_CAPACITY],
len: usize,
},
Heap(Vec<u8>),
}
impl ReportingTokenInfo {
fn with_capacity(capacity: usize) -> Self {
if capacity <= REPORTING_TOKEN_INFO_INLINE_CAPACITY {
Self::Inline {
bytes: [0; REPORTING_TOKEN_INFO_INLINE_CAPACITY],
len: 0,
}
} else {
Self::Heap(Vec::with_capacity(capacity))
}
}
fn extend_from_slice(&mut self, value: &[u8]) {
match self {
Self::Inline { bytes, len } => {
let end = *len + value.len();
if end <= bytes.len() {
bytes[*len..end].copy_from_slice(value);
*len = end;
return;
}
let mut heap = Vec::with_capacity(end + REPORTING_TOKEN_INFO_INLINE_CAPACITY);
heap.extend_from_slice(&bytes[..*len]);
heap.extend_from_slice(value);
*self = Self::Heap(heap);
}
Self::Heap(bytes) => bytes.extend_from_slice(value),
}
}
fn as_bytes(&self) -> &[u8] {
match self {
Self::Inline { bytes, len } => &bytes[..*len],
Self::Heap(bytes) => bytes,
}
}
}
impl fmt::Write for ReportingTokenInfo {
fn write_str(&mut self, value: &str) -> fmt::Result {
self.extend_from_slice(value.as_bytes());
Ok(())
}
}
static REPORTING_TOKEN_EXTRACT_HMAC: LazyLock<Hmac<Sha256>> =
LazyLock::new(|| Hmac::<Sha256>::new_from_slice(&[0u8; 32]).expect("32-byte HMAC key"));
pub fn generate_message_secret() -> [u8; MESSAGE_SECRET_SIZE] {
use rand::RngExt;
rand::rng().random()
}
fn build_hkdf_info_with(
stanza_id: &str,
sender_len: usize,
remote_len: usize,
write_jids: impl FnOnce(&mut ReportingTokenInfo) -> fmt::Result,
) -> Result<ReportingTokenInfo> {
let capacity = stanza_id
.len()
.checked_add(sender_len)
.and_then(|len| len.checked_add(remote_len))
.and_then(|len| len.checked_add(USE_CASE_REPORT_TOKEN.len()))
.ok_or_else(|| anyhow!("Reporting token HKDF info length overflow"))?;
let mut info = ReportingTokenInfo::with_capacity(capacity);
info.extend_from_slice(stanza_id.as_bytes());
write_jids(&mut info).map_err(|_| anyhow!("Failed to format reporting token JIDs"))?;
info.extend_from_slice(USE_CASE_REPORT_TOKEN.as_bytes());
Ok(info)
}
fn build_hkdf_info(
stanza_id: &str,
sender_jid: &str,
remote_jid: &str,
) -> Result<ReportingTokenInfo> {
build_hkdf_info_with(stanza_id, sender_jid.len(), remote_jid.len(), |info| {
info.extend_from_slice(sender_jid.as_bytes());
info.extend_from_slice(remote_jid.as_bytes());
Ok(())
})
}
fn build_hkdf_info_for_jids(
stanza_id: &str,
sender_jid: &Jid,
remote_jid: &Jid,
) -> Result<ReportingTokenInfo> {
build_hkdf_info_with(stanza_id, 0, 0, |info| {
sender_jid.write_display_to(info)?;
remote_jid.write_display_to(info)
})
}
fn validate_message_secret(message_secret: &[u8]) -> Result<()> {
if message_secret.len() == MESSAGE_SECRET_SIZE {
return Ok(());
}
Err(anyhow!(
"Invalid message secret size: expected {}, got {}",
MESSAGE_SECRET_SIZE,
message_secret.len()
))
}
fn derive_reporting_token_key_from_info(
message_secret: &[u8],
info: &[u8],
) -> Result<[u8; REPORTING_TOKEN_KEY_SIZE]> {
let mut extract = REPORTING_TOKEN_EXTRACT_HMAC.clone();
extract.update(message_secret);
let prk = extract.finalize().into_bytes();
let mut key = [0u8; REPORTING_TOKEN_KEY_SIZE];
Hkdf::<Sha256>::from_prk(&prk)
.expect("PRK is hash-sized")
.expand(info, &mut key)
.map_err(|e| anyhow!("HKDF expand failed: {}", e))?;
Ok(key)
}
pub fn derive_reporting_token_key(
message_secret: &[u8],
stanza_id: &str,
sender_jid: &str,
remote_jid: &str,
) -> Result<[u8; REPORTING_TOKEN_KEY_SIZE]> {
validate_message_secret(message_secret)?;
let info = build_hkdf_info(stanza_id, sender_jid, remote_jid)?;
derive_reporting_token_key_from_info(message_secret, info.as_bytes())
}
fn derive_reporting_token_key_for_jids(
message_secret: &[u8],
stanza_id: &str,
sender_jid: &Jid,
remote_jid: &Jid,
) -> Result<[u8; REPORTING_TOKEN_KEY_SIZE]> {
validate_message_secret(message_secret)?;
let info = build_hkdf_info_for_jids(stanza_id, sender_jid, remote_jid)?;
derive_reporting_token_key_from_info(message_secret, info.as_bytes())
}
fn decode_varint(data: &[u8]) -> Option<(u64, usize)> {
let mut value: u64 = 0;
let mut shift = 0;
for (i, &byte) in data.iter().enumerate() {
value |= ((byte & 0x7F) as u64) << shift;
if byte & 0x80 == 0 {
return Some((value, i + 1));
}
shift += 7;
if shift > 63 {
return None;
}
}
None
}
const MAX_VARINT_LEN: usize = 10;
#[inline]
fn encode_varint_to_buf(mut value: u64, buf: &mut [u8; MAX_VARINT_LEN]) -> usize {
let mut i = 0;
loop {
let mut byte = (value & 0x7F) as u8;
value >>= 7;
if value != 0 {
byte |= 0x80;
}
buf[i] = byte;
i += 1;
if value == 0 {
break;
}
}
i
}
#[cfg(test)]
#[inline]
fn encode_varint(value: u64) -> Vec<u8> {
let mut buf = [0u8; MAX_VARINT_LEN];
let len = encode_varint_to_buf(value, &mut buf);
buf[..len].to_vec()
}
enum Piece {
Borrowed(core::ops::Range<usize>),
Owned(Vec<u8>),
}
impl Piece {
fn len(&self) -> usize {
match self {
Piece::Borrowed(range) => range.len(),
Piece::Owned(bytes) => bytes.len(),
}
}
}
#[cfg(test)]
thread_local! {
static COLLECT_CALLS: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
}
fn collect_reporting_token_pieces(
data: &[u8],
whitelist: &[ReportingField],
) -> Option<SmallVec<[(u32, Piece); 4]>> {
#[cfg(test)]
COLLECT_CALLS.with(|c| c.set(c.get() + 1));
let mut extracted: SmallVec<[(u32, Piece); 4]> = SmallVec::new();
let mut pos = 0;
while pos < data.len() {
let (tag, tag_len) = decode_varint(&data[pos..])?;
let field_number = (tag >> 3) as u32;
let wire_type = (tag & 0x7) as u32;
let field_start = pos;
pos += tag_len;
if pos > data.len() {
break;
}
let entry = whitelist.iter().find(|f| f.field_number == field_number);
match wire_type {
wire_type::VARINT => {
let (_, val_len) = decode_varint(&data[pos..])?;
pos += val_len;
if entry.is_some() {
extracted.push((field_number, Piece::Borrowed(field_start..pos)));
}
}
wire_type::FIXED64 => {
if pos + 8 > data.len() {
break;
}
pos += 8;
if entry.is_some() {
extracted.push((field_number, Piece::Borrowed(field_start..pos)));
}
}
wire_type::FIXED32 => {
if pos + 4 > data.len() {
break;
}
pos += 4;
if entry.is_some() {
extracted.push((field_number, Piece::Borrowed(field_start..pos)));
}
}
wire_type::LENGTH_DELIMITED => {
let (len, len_size) = decode_varint(&data[pos..])?;
let value_start = pos + len_size;
let value_end = value_start + len as usize;
if value_end > data.len() {
break;
}
pos = value_end;
if let Some(entry) = entry {
if entry.is_message {
if let Some(nested) = extract_reporting_token_content(
&data[value_start..value_end],
REPORTING_FIELDS,
)
.filter(|n| !n.is_empty())
{
let mut tag_buf = [0u8; MAX_VARINT_LEN];
let tag_len = encode_varint_to_buf(tag, &mut tag_buf);
let mut len_buf = [0u8; MAX_VARINT_LEN];
let len_len = encode_varint_to_buf(nested.len() as u64, &mut len_buf);
let mut field_bytes =
Vec::with_capacity(tag_len + len_len + nested.len());
field_bytes.extend_from_slice(&tag_buf[..tag_len]);
field_bytes.extend_from_slice(&len_buf[..len_len]);
field_bytes.extend(nested);
extracted.push((field_number, Piece::Owned(field_bytes)));
}
} else if let Some(subfields) = entry.subfields {
if let Some(nested) = extract_reporting_token_content(
&data[value_start..value_end],
subfields,
)
.filter(|n| !n.is_empty())
{
let mut tag_buf = [0u8; MAX_VARINT_LEN];
let tag_len = encode_varint_to_buf(tag, &mut tag_buf);
let mut len_buf = [0u8; MAX_VARINT_LEN];
let len_len = encode_varint_to_buf(nested.len() as u64, &mut len_buf);
let mut field_bytes =
Vec::with_capacity(tag_len + len_len + nested.len());
field_bytes.extend_from_slice(&tag_buf[..tag_len]);
field_bytes.extend_from_slice(&len_buf[..len_len]);
field_bytes.extend(nested);
extracted.push((field_number, Piece::Owned(field_bytes)));
}
} else {
extracted.push((field_number, Piece::Borrowed(field_start..pos)));
}
}
}
_ => {
return None;
}
}
}
if extracted.is_empty() {
return None;
}
extracted.sort_by_key(|(num, _)| *num);
Some(extracted)
}
pub fn extract_reporting_token_content(
data: &[u8],
whitelist: &[ReportingField],
) -> Option<Vec<u8>> {
let pieces = collect_reporting_token_pieces(data, whitelist)?;
let total_len: usize = pieces.iter().map(|(_, piece)| piece.len()).sum();
let mut result = Vec::with_capacity(total_len);
for (_, piece) in pieces {
match piece {
Piece::Borrowed(range) => result.extend_from_slice(&data[range]),
Piece::Owned(bytes) => result.extend_from_slice(&bytes),
}
}
debug_assert_eq!(
result.len(),
total_len,
"the reservation disagreed with the bytes written"
);
Some(result)
}
pub fn should_include_reporting_token(message: &wa::Message) -> bool {
message.reaction_message.is_unset()
&& message.enc_reaction_message.is_unset()
&& message.poll_update_message.is_unset()
&& message.keep_in_chat_message.is_unset()
}
pub fn generate_reporting_token_content(message: &wa::Message) -> Option<Vec<u8>> {
if !should_include_reporting_token(message) {
return None;
}
let message_bytes = waproto::codec::message_to_vec(message);
extract_reporting_token_content(&message_bytes, REPORTING_FIELDS)
}
pub fn calculate_reporting_token(
reporting_token_key: &[u8; REPORTING_TOKEN_KEY_SIZE],
content: &[u8],
) -> Result<[u8; REPORTING_TOKEN_SIZE]> {
let mut mac = Hmac::<Sha256>::new_from_slice(reporting_token_key)
.map_err(|_| anyhow!("Failed to create HMAC"))?;
mac.update(content);
let result = mac.finalize().into_bytes();
let mut token = [0u8; REPORTING_TOKEN_SIZE];
token.copy_from_slice(&result[..REPORTING_TOKEN_SIZE]);
Ok(token)
}
fn calculate_reporting_token_over_pieces(
reporting_token_key: &[u8; REPORTING_TOKEN_KEY_SIZE],
data: &[u8],
pieces: &[(u32, Piece)],
) -> Option<[u8; REPORTING_TOKEN_SIZE]> {
if pieces.is_empty() {
return None;
}
let mut mac = Hmac::<Sha256>::new_from_slice(reporting_token_key).ok()?;
for (_, piece) in pieces {
match piece {
Piece::Borrowed(range) => mac.update(&data[range.clone()]),
Piece::Owned(bytes) => mac.update(bytes),
}
}
let result = mac.finalize().into_bytes();
let mut token = [0u8; REPORTING_TOKEN_SIZE];
token.copy_from_slice(&result[..REPORTING_TOKEN_SIZE]);
Some(token)
}
#[derive(Debug, Clone)]
pub struct ReportingTokenResult {
pub message_secret: [u8; MESSAGE_SECRET_SIZE],
pub reporting_token: [u8; REPORTING_TOKEN_SIZE],
pub version: i32,
}
pub fn generate_reporting_token(
message: &wa::Message,
stanza_id: &str,
sender_jid: &Jid,
remote_jid: &Jid,
existing_secret: Option<&[u8]>,
) -> Option<ReportingTokenResult> {
if !should_include_reporting_token(message) {
return None;
}
let encoded = waproto::codec::message_to_vec(message);
generate_reporting_token_from_encoded(
message,
&encoded,
stanza_id,
sender_jid,
remote_jid,
existing_secret,
)
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(name = "wa.send.reporting_token", level = "debug", skip_all)
)]
pub fn generate_reporting_token_from_encoded(
message: &wa::Message,
encoded_message: &[u8],
stanza_id: &str,
sender_jid: &Jid,
remote_jid: &Jid,
existing_secret: Option<&[u8]>,
) -> Option<ReportingTokenResult> {
if !should_include_reporting_token(message) {
return None;
}
let pieces = collect_reporting_token_pieces(encoded_message, REPORTING_FIELDS)?;
let message_secret: [u8; MESSAGE_SECRET_SIZE] = if let Some(secret) = existing_secret {
if secret.len() != MESSAGE_SECRET_SIZE {
log::warn!("Invalid existing secret size, generating new one");
generate_message_secret()
} else {
secret.try_into().ok()?
}
} else {
generate_message_secret()
};
let key =
derive_reporting_token_key_for_jids(&message_secret, stanza_id, sender_jid, remote_jid)
.ok()?;
let token = calculate_reporting_token_over_pieces(&key, encoded_message, &pieces)?;
Some(ReportingTokenResult {
message_secret,
reporting_token: token,
version: REPORTING_TOKEN_VERSION,
})
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(name = "wa.send.reporting_node", level = "debug", skip_all)
)]
pub fn build_reporting_node(result: &ReportingTokenResult) -> Node {
let token_node = NodeBuilder::new("reporting_token")
.attr("v", result.version)
.bytes(result.reporting_token.to_vec())
.build();
NodeBuilder::new("reporting").children([token_node]).build()
}
pub fn prepare_message_with_context(
message: &wa::Message,
message_secret: &[u8; MESSAGE_SECRET_SIZE],
) -> wa::Message {
let mut new_message = message.clone();
let mut context_info = new_message.message_context_info.take().unwrap_or_default();
context_info.message_secret = Some(message_secret.to_vec());
context_info.reporting_token_version = Some(REPORTING_TOKEN_VERSION);
new_message.message_context_info = buffa::MessageField::some(context_info);
new_message
}
pub fn reporting_context_info(result: &ReportingTokenResult) -> wa::MessageContextInfo {
wa::MessageContextInfo {
message_secret: Some(result.message_secret.to_vec()),
reporting_token_version: Some(REPORTING_TOKEN_VERSION),
..Default::default()
}
}
pub fn extract_message_secret(message: &wa::Message) -> Option<&[u8]> {
message
.message_context_info
.as_option()
.and_then(|ctx| ctx.message_secret.as_deref())
}
#[cfg(test)]
mod tests {
#[test]
fn generating_a_token_collects_the_pieces_once() {
let message = wa::Message {
extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage {
text: Some("nested content owns its bytes".to_string()),
..Default::default()
}),
..Default::default()
};
let encoded = waproto::codec::message_to_vec(&message);
let sender: Jid = "5511987650001@s.whatsapp.net".parse().expect("sender");
let remote: Jid = "5511987650002@s.whatsapp.net".parse().expect("remote");
COLLECT_CALLS.with(|c| c.set(0));
let result = generate_reporting_token_from_encoded(
&message,
&encoded,
"3EB0ABCDEF",
&sender,
&remote,
None,
);
assert!(result.is_some(), "the message must produce a token");
assert_eq!(
COLLECT_CALLS.with(|c| c.get()),
2,
"the pieces must be collected once and reused for the hmac"
);
}
#[test]
fn streaming_and_concatenating_produce_the_same_token() {
let key = [0x5au8; REPORTING_TOKEN_KEY_SIZE];
let cases: Vec<(&str, Vec<u8>)> = vec![
("flat text field", {
let m = wa::Message {
conversation: Some("hello reporting".to_string()),
..Default::default()
};
waproto::codec::message_to_vec(&m)
}),
("nested field", {
let m = wa::Message {
extended_text_message: buffa::MessageField::some(
wa::message::ExtendedTextMessage {
text: Some("nested body".to_string()),
..Default::default()
},
),
..Default::default()
};
waproto::codec::message_to_vec(&m)
}),
("two fields, so order matters", {
let m = wa::Message {
conversation: Some("first by field number".to_string()),
extended_text_message: buffa::MessageField::some(
wa::message::ExtendedTextMessage {
text: Some("sixth by field number".to_string()),
..Default::default()
},
),
..Default::default()
};
waproto::codec::message_to_vec(&m)
}),
("multibyte payload", {
let m = wa::Message {
conversation: Some("olá 🌍 ünïcode".repeat(4)),
..Default::default()
};
waproto::codec::message_to_vec(&m)
}),
];
for (name, encoded) in cases {
let concatenated = extract_reporting_token_content(&encoded, REPORTING_FIELDS)
.unwrap_or_else(|| panic!("{name}: the case must extract something"));
let expected = calculate_reporting_token(&key, &concatenated)
.unwrap_or_else(|_| panic!("{name}: hmac over the concatenation"));
let pieces = collect_reporting_token_pieces(&encoded, REPORTING_FIELDS)
.unwrap_or_else(|| panic!("{name}: the case must collect something"));
let streamed = calculate_reporting_token_over_pieces(&key, &encoded, &pieces)
.unwrap_or_else(|| panic!("{name}: hmac over the pieces"));
assert_eq!(streamed, expected, "{name}: the token bytes are contract");
}
}
#[test]
fn a_message_with_nothing_to_report_produces_no_token() {
let key = [0x5au8; REPORTING_TOKEN_KEY_SIZE];
let encoded = waproto::codec::message_to_vec(&wa::Message::default());
assert!(extract_reporting_token_content(&encoded, REPORTING_FIELDS).is_none());
assert!(collect_reporting_token_pieces(&encoded, REPORTING_FIELDS).is_none());
assert!(
calculate_reporting_token_over_pieces(&key, &encoded, &[]).is_none(),
"hashing no pieces must not mint a token"
);
}
use super::*;
#[test]
fn test_generate_message_secret() {
let secret1 = generate_message_secret();
let secret2 = generate_message_secret();
assert_eq!(secret1.len(), MESSAGE_SECRET_SIZE);
assert_eq!(secret2.len(), MESSAGE_SECRET_SIZE);
assert_ne!(secret1, secret2);
}
#[test]
fn reporting_token_reuses_existing_message_secret() {
let secret = [0x42u8; MESSAGE_SECRET_SIZE];
let msg = wa::Message {
conversation: Some("hi".into()),
message_context_info: buffa::MessageField::some(wa::MessageContextInfo {
message_secret: Some(secret.to_vec()),
..Default::default()
}),
..Default::default()
};
let to: Jid = "5511999999999@s.whatsapp.net".parse().unwrap();
let result = generate_reporting_token(&msg, "MID", &to, &to, extract_message_secret(&msg))
.expect("a text message produces a reporting token");
assert_eq!(
result.message_secret, secret,
"existing secret must be reused"
);
let prepared = prepare_message_with_context(&msg, &result.message_secret);
assert_eq!(extract_message_secret(&prepared), Some(secret.as_slice()));
}
#[test]
fn test_derive_reporting_token_key() {
let secret = [0x42u8; MESSAGE_SECRET_SIZE];
let stanza_id = "3EB0E0E5F2D4F618589C0B";
let sender_jid = "5511999887766@s.whatsapp.net";
let remote_jid = "5511888776655@s.whatsapp.net";
let key = derive_reporting_token_key(&secret, stanza_id, sender_jid, remote_jid)
.expect("valid secret should derive key successfully");
assert_eq!(key.len(), REPORTING_TOKEN_KEY_SIZE);
let key2 = derive_reporting_token_key(&secret, stanza_id, sender_jid, remote_jid)
.expect("valid secret should derive key successfully");
assert_eq!(key, key2);
let key3 = derive_reporting_token_key(&secret, "different_id", sender_jid, remote_jid)
.expect("valid secret should derive key successfully");
assert_ne!(key, key3);
}
#[test]
fn jid_key_derivation_matches_string_api_for_inline_and_heap_info() {
let secret = [0x42; MESSAGE_SECRET_SIZE];
let stanza_id = "3EB0E0E5F2D4F618589C0B";
let cases = [
(
Jid::pn_device("5511999887766".to_owned(), 7),
Jid::pn_device("5511888776655".to_owned(), 3),
),
(
Jid::pn("sender".repeat(REPORTING_TOKEN_INFO_INLINE_CAPACITY)),
Jid::pn("remote".repeat(REPORTING_TOKEN_INFO_INLINE_CAPACITY)),
),
];
for (case_index, (sender, remote)) in cases.iter().enumerate() {
let sender_string = sender.to_string();
let remote_string = remote.to_string();
let expected =
derive_reporting_token_key(&secret, stanza_id, &sender_string, &remote_string)
.expect("string inputs should derive a key");
let actual = derive_reporting_token_key_for_jids(&secret, stanza_id, sender, remote)
.expect("JID inputs should derive a key");
assert_eq!(
actual, expected,
"JID derivation mismatch in case {case_index}"
);
let info = build_hkdf_info_for_jids(stanza_id, sender, remote)
.expect("JIDs should build HKDF info");
assert_eq!(
matches!(info, ReportingTokenInfo::Heap(_)),
case_index == 1,
"only the oversized case should use the heap fallback"
);
}
}
#[test]
fn heap_promotion_reserves_space_for_follow_up_writes() {
let mut info = ReportingTokenInfo::with_capacity(REPORTING_TOKEN_INFO_INLINE_CAPACITY);
info.extend_from_slice(&[0; REPORTING_TOKEN_INFO_INLINE_CAPACITY]);
info.extend_from_slice(&[1]);
let ReportingTokenInfo::Heap(bytes) = info else {
panic!("overflowing inline reporting info should promote to heap");
};
assert!(
bytes.capacity() >= bytes.len() + REPORTING_TOKEN_INFO_INLINE_CAPACITY,
"heap promotion should absorb subsequent formatter writes"
);
}
#[test]
fn derive_key_matches_plain_hkdf_extract() {
let stanza_id = "3EB0E0E5F2D4F618589C0B";
let sender_jid = "5511999887766@s.whatsapp.net";
let remote_jid = "5511888776655@s.whatsapp.net";
let info = build_hkdf_info(stanza_id, sender_jid, remote_jid)
.expect("test inputs should build HKDF info");
for seed in 0u8..32 {
let secret = [seed.wrapping_mul(37).wrapping_add(11); MESSAGE_SECRET_SIZE];
let mut expected = [0u8; REPORTING_TOKEN_KEY_SIZE];
Hkdf::<Sha256>::new(None, &secret)
.expand(info.as_bytes(), &mut expected)
.expect("valid output length");
let got = derive_reporting_token_key(&secret, stanza_id, sender_jid, remote_jid)
.expect("valid secret should derive key successfully");
assert_eq!(got, expected, "mismatch for seed {seed}");
}
}
#[test]
fn test_decode_varint() {
assert_eq!(decode_varint(&[0x01]), Some((1, 1)));
assert_eq!(decode_varint(&[0x7F]), Some((127, 1)));
assert_eq!(decode_varint(&[0x80, 0x01]), Some((128, 2)));
assert_eq!(decode_varint(&[0xAC, 0x02]), Some((300, 2)));
assert_eq!(decode_varint(&[]), None);
}
#[test]
fn test_encode_varint() {
assert_eq!(encode_varint(1), vec![0x01]);
assert_eq!(encode_varint(127), vec![0x7F]);
assert_eq!(encode_varint(128), vec![0x80, 0x01]);
assert_eq!(encode_varint(300), vec![0xAC, 0x02]);
}
#[test]
fn test_varint_roundtrip() {
for value in [0u64, 1, 127, 128, 255, 256, 16383, 16384, 1000000] {
let encoded = encode_varint(value);
let (decoded, _) =
decode_varint(&encoded).expect("encoded varint should decode successfully");
assert_eq!(decoded, value);
}
}
#[test]
fn test_generate_reporting_token_content_text() {
let message = wa::Message {
conversation: Some("Hello, World!".to_string()),
..Default::default()
};
let content = generate_reporting_token_content(&message);
assert!(content.is_some());
let content = content.expect("text message should generate reporting token content");
assert!(!content.is_empty());
let content2 = generate_reporting_token_content(&message)
.expect("text message should generate reporting token content");
assert_eq!(content, content2);
}
#[test]
fn test_generate_reporting_token_content_extended_text() {
let message = wa::Message {
extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage {
text: Some("Extended text message".to_string()),
..Default::default()
}),
..Default::default()
};
let content = generate_reporting_token_content(&message);
assert!(content.is_some());
let content2 = generate_reporting_token_content(&message)
.expect("extended text message should generate content");
assert_eq!(
content.expect("extended text message should generate content"),
content2
);
}
#[test]
fn test_should_include_reporting_token() {
let normal_message = wa::Message {
conversation: Some("Hello".to_string()),
..Default::default()
};
assert!(should_include_reporting_token(&normal_message));
let reaction_message = wa::Message {
reaction_message: buffa::MessageField::some(wa::message::ReactionMessage {
text: Some("\u{1f44d}".to_string()),
..Default::default()
}),
..Default::default()
};
assert!(!should_include_reporting_token(&reaction_message));
let poll_update = wa::Message {
poll_update_message: buffa::MessageField::some(
wa::message::PollUpdateMessage::default(),
),
..Default::default()
};
assert!(!should_include_reporting_token(&poll_update));
}
#[test]
fn test_extract_reporting_token_content_simple() {
let message = wa::Message {
conversation: Some("Test".to_string()),
..Default::default()
};
let message_bytes = waproto::codec::message_to_vec(&message);
let extracted = extract_reporting_token_content(&message_bytes, REPORTING_FIELDS);
assert!(extracted.is_some());
assert_eq!(
extracted.expect("conversation message should extract successfully"),
message_bytes
);
}
#[test]
fn test_extract_filters_non_whitelisted_fields() {
let message = wa::Message {
extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage {
text: Some("Hello".to_string()),
context_info: buffa::MessageField::some(wa::ContextInfo {
stanza_id: Some("should-be-excluded".to_string()), is_forwarded: Some(true), ..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let content = generate_reporting_token_content(&message);
assert!(content.is_some());
let content_bytes = content.expect("message with contextInfo should generate content");
let content_str = String::from_utf8_lossy(&content_bytes);
assert!(!content_str.contains("should-be-excluded"));
}
#[test]
fn test_calculate_reporting_token() {
let key = [0x55u8; REPORTING_TOKEN_KEY_SIZE];
let content = b"test content";
let token = calculate_reporting_token(&key, content)
.expect("valid key and content should calculate token");
assert_eq!(token.len(), REPORTING_TOKEN_SIZE);
let token2 = calculate_reporting_token(&key, content)
.expect("valid key and content should calculate token");
assert_eq!(token, token2);
let token3 = calculate_reporting_token(&key, b"different content")
.expect("valid key and content should calculate token");
assert_ne!(token, token3);
}
#[test]
fn test_generate_reporting_token_full() {
let message = wa::Message {
conversation: Some("Test message".to_string()),
..Default::default()
};
let sender = Jid::pn("5511999887766");
let remote = Jid::pn("5511888776655");
let result = generate_reporting_token(&message, "test_stanza_id", &sender, &remote, None)
.expect("valid message should generate reporting token");
assert_eq!(result.message_secret.len(), MESSAGE_SECRET_SIZE);
assert_eq!(result.reporting_token.len(), REPORTING_TOKEN_SIZE);
assert_eq!(result.version, REPORTING_TOKEN_VERSION);
}
#[test]
fn test_generate_reporting_token_with_existing_secret() {
let message = wa::Message {
conversation: Some("Test message".to_string()),
..Default::default()
};
let sender = Jid::pn("5511999887766");
let remote = Jid::pn("5511888776655");
let existing_secret = [0xAAu8; MESSAGE_SECRET_SIZE];
let result = generate_reporting_token(
&message,
"test_stanza_id",
&sender,
&remote,
Some(&existing_secret),
)
.expect("valid message with existing secret should generate token");
assert_eq!(result.message_secret, existing_secret);
}
#[test]
fn from_encoded_matches_generate_and_skips_excluded_types() {
let sender = Jid::pn("5511999887766");
let remote = Jid::pn("5511888776655");
let secret = [0x5Au8; MESSAGE_SECRET_SIZE];
let message = wa::Message {
conversation: Some("Test message".to_string()),
..Default::default()
};
let encoded = waproto::codec::message_to_vec(&message);
let direct = generate_reporting_token(&message, "SID", &sender, &remote, Some(&secret))
.expect("token-bearing message");
let shared = generate_reporting_token_from_encoded(
&message,
&encoded,
"SID",
&sender,
&remote,
Some(&secret),
)
.expect("token-bearing message");
assert_eq!(direct.reporting_token, shared.reporting_token);
assert_eq!(direct.message_secret, shared.message_secret);
let reaction = wa::Message {
reaction_message: buffa::MessageField::some(wa::message::ReactionMessage {
text: Some("👍".to_string()),
..Default::default()
}),
..Default::default()
};
let reaction_encoded = waproto::codec::message_to_vec(&reaction);
assert!(
generate_reporting_token(&reaction, "SID", &sender, &remote, Some(&secret)).is_none()
);
assert!(
generate_reporting_token_from_encoded(
&reaction,
&reaction_encoded,
"SID",
&sender,
&remote,
Some(&secret)
)
.is_none()
);
}
#[test]
fn test_build_reporting_node() {
use wacore_binary::NodeContent;
let expected_token = [
0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66,
0x77, 0x88,
];
let result = ReportingTokenResult {
message_secret: [0u8; MESSAGE_SECRET_SIZE],
reporting_token: expected_token,
version: 2,
};
let node = build_reporting_node(&result);
assert_eq!(node.tag, "reporting");
let token_node = node.get_children_by_tag("reporting_token").next().unwrap();
assert!(token_node.attrs.get("v").is_some_and(|v| v == "2"));
match &token_node.content {
Some(NodeContent::Bytes(bytes)) => {
assert_eq!(
bytes.as_slice(),
&expected_token,
"Token bytes must match the original binary token"
);
}
Some(NodeContent::String(s)) => {
panic!(
"REGRESSION: reporting_token content is a String '{}', but must be Bytes! \
This will cause WhatsApp error 479.",
s
);
}
other => {
panic!(
"reporting_token content must be NodeContent::Bytes, got {:?}",
other
);
}
}
let bumped = ReportingTokenResult {
version: 7,
..result
};
let node = build_reporting_node(&bumped);
let token_node = node.get_children_by_tag("reporting_token").next().unwrap();
assert!(token_node.attrs.get("v").is_some_and(|v| v == "7"));
}
#[test]
fn test_prepare_message_with_context() {
let message = wa::Message {
conversation: Some("Test".to_string()),
..Default::default()
};
let secret = [0x42u8; MESSAGE_SECRET_SIZE];
let prepared = prepare_message_with_context(&message, &secret);
assert!(
prepared.message_context_info.is_set(),
"prepared message should have context info"
);
assert_eq!(
prepared.message_context_info.message_secret,
Some(secret.to_vec())
);
assert_eq!(
prepared.message_context_info.reporting_token_version,
Some(REPORTING_TOKEN_VERSION)
);
}
#[test]
fn test_extract_message_secret() {
let secret = vec![0x55u8; MESSAGE_SECRET_SIZE];
let message = wa::Message {
message_context_info: buffa::MessageField::some(wa::MessageContextInfo {
message_secret: Some(secret.clone()),
..Default::default()
}),
..Default::default()
};
let extracted = extract_message_secret(&message);
assert!(extracted.is_some());
assert_eq!(
extracted.expect("message should have extractable secret"),
secret.as_slice()
);
}
#[test]
fn test_unsupported_message_type_returns_none() {
let message = wa::Message {
..Default::default()
};
let sender = Jid::pn("5511999887766");
let remote = Jid::pn("5511888776655");
let result = generate_reporting_token(&message, "test_id", &sender, &remote, None);
assert!(result.is_none());
}
fn test_jid(user: &str) -> Jid {
Jid::pn(user)
}
#[test]
fn test_golden_hkdf_key_derivation() {
let secret = [0x42u8; MESSAGE_SECRET_SIZE];
let stanza_id = "3EB0E0E5F2D4F618589C0B";
let sender_jid = "5511999887766@s.whatsapp.net";
let remote_jid = "5511888776655@s.whatsapp.net";
let key = derive_reporting_token_key(&secret, stanza_id, sender_jid, remote_jid)
.expect("valid inputs should derive key for golden test");
let expected_key = [
0xba, 0x50, 0xb2, 0x2b, 0xe5, 0xcc, 0x25, 0x71, 0x7d, 0x32, 0xb7, 0xd2, 0x77, 0xda,
0xe1, 0xbc, 0x9f, 0xa8, 0xad, 0x12, 0x2c, 0xdd, 0xb0, 0xec, 0x4f, 0xbc, 0x87, 0x24,
0x52, 0xa5, 0xe0, 0x8c,
];
assert_eq!(
key, expected_key,
"HKDF key derivation changed! Expected: {:02x?}, Got: {:02x?}",
expected_key, key
);
}
#[test]
fn test_golden_hmac_token_calculation() {
let key = [0x55u8; REPORTING_TOKEN_KEY_SIZE];
let content = b"Hello, World!";
let token = calculate_reporting_token(&key, content)
.expect("valid key and content should calculate token for golden test");
let expected_token = [
0xc2, 0x2b, 0x68, 0x1d, 0x7d, 0x7e, 0xef, 0xbc, 0x59, 0xa2, 0x02, 0xfc, 0x14, 0x1e,
0xb5, 0xf8,
];
assert_eq!(
token, expected_token,
"HMAC token calculation changed! Expected: {:02x?}, Got: {:02x?}",
expected_token, token
);
}
#[test]
fn test_golden_conversation_content_extraction() {
let message = wa::Message {
conversation: Some("Test".to_string()),
..Default::default()
};
let content = generate_reporting_token_content(&message)
.expect("conversation message should generate content for golden test");
let expected = vec![0x0a, 0x04, b'T', b'e', b's', b't'];
assert_eq!(
content, expected,
"Conversation content extraction changed! Expected: {:02x?}, Got: {:02x?}",
expected, content
);
}
#[test]
fn test_golden_extended_text_content_extraction() {
let message = wa::Message {
extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage {
text: Some("Hi".to_string()),
..Default::default()
}),
..Default::default()
};
let content = generate_reporting_token_content(&message)
.expect("conversation message should generate content for golden test");
let expected = vec![0x32, 0x04, 0x0a, 0x02, b'H', b'i'];
assert_eq!(
content, expected,
"ExtendedText content extraction changed! Expected: {:02x?}, Got: {:02x?}",
expected, content
);
}
#[test]
fn test_golden_full_token_generation() {
let message = wa::Message {
conversation: Some("Hello".to_string()),
..Default::default()
};
let secret = [0xAA; MESSAGE_SECRET_SIZE];
let sender = test_jid("sender");
let remote = test_jid("remote");
let result =
generate_reporting_token(&message, "STANZA123", &sender, &remote, Some(&secret))
.expect("valid message should generate token for golden test");
assert_eq!(result.message_secret, secret);
assert_eq!(result.version, REPORTING_TOKEN_VERSION);
let result2 =
generate_reporting_token(&message, "STANZA123", &sender, &remote, Some(&secret))
.expect("repeated generation should succeed");
assert_eq!(
result.reporting_token, result2.reporting_token,
"Token generation is not deterministic!"
);
let expected_token = result.reporting_token;
let result3 =
generate_reporting_token(&message, "STANZA123", &sender, &remote, Some(&secret))
.expect("repeated generation should succeed");
assert_eq!(
result3.reporting_token, expected_token,
"Token changed across calls with same inputs!"
);
}
#[test]
fn test_context_info_filtering_only_extracts_whitelisted() {
let message = wa::Message {
extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage {
text: Some("Test".to_string()),
context_info: buffa::MessageField::some(wa::ContextInfo {
stanza_id: Some("SHOULD_BE_EXCLUDED".to_string()), participant: Some("ALSO_EXCLUDED".to_string()), is_forwarded: Some(true), forwarding_score: Some(5), ..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let content = generate_reporting_token_content(&message)
.expect("conversation message should generate content for golden test");
let content_str = String::from_utf8_lossy(&content);
assert!(
!content_str.contains("SHOULD_BE_EXCLUDED"),
"stanza_id should be excluded from contextInfo"
);
assert!(
!content_str.contains("ALSO_EXCLUDED"),
"participant should be excluded from contextInfo"
);
assert!(!content.is_empty());
}
#[test]
fn test_field_extraction_order_is_deterministic() {
let message = wa::Message {
conversation: Some("Text".to_string()), ..Default::default()
};
let content1 = generate_reporting_token_content(&message)
.expect("message should generate content for determinism test");
let content2 = generate_reporting_token_content(&message)
.expect("message should generate content for determinism test");
let content3 = generate_reporting_token_content(&message)
.expect("message should generate content for determinism test");
assert_eq!(content1, content2, "Content extraction not deterministic");
assert_eq!(content2, content3, "Content extraction not deterministic");
}
#[test]
fn test_varint_edge_cases() {
let test_cases = [
(0u64, vec![0x00]),
(1, vec![0x01]),
(127, vec![0x7F]), (128, vec![0x80, 0x01]), (16383, vec![0xFF, 0x7F]), (16384, vec![0x80, 0x80, 0x01]), (u32::MAX as u64, vec![0xFF, 0xFF, 0xFF, 0xFF, 0x0F]), ];
for (value, expected_bytes) in test_cases {
let encoded = encode_varint(value);
assert_eq!(
encoded, expected_bytes,
"encode_varint({}) = {:02x?}, expected {:02x?}",
value, encoded, expected_bytes
);
let (decoded, len) =
decode_varint(&encoded).expect("valid encoded bytes should decode");
assert_eq!(
decoded, value,
"decode_varint round-trip failed for {}",
value
);
assert_eq!(
len,
expected_bytes.len(),
"varint length mismatch for {}",
value
);
}
}
#[test]
fn test_extraction_handles_empty_nested_message() {
let message = wa::Message {
extended_text_message: buffa::MessageField::some(wa::message::ExtendedTextMessage {
text: Some("Content".to_string()),
context_info: buffa::MessageField::some(wa::ContextInfo::default()), ..Default::default()
}),
..Default::default()
};
let content = generate_reporting_token_content(&message);
assert!(
content.is_some(),
"Should extract text even with empty contextInfo"
);
let content = content.expect("message with empty contextInfo should generate content");
assert!(
content.windows(7).any(|w| w == b"Content"),
"Text 'Content' should be in extracted bytes"
);
}
#[test]
fn test_raw_protobuf_extraction_simple_fields() {
let data = vec![
0x08, 0x96, 0x01, 0x12, 0x05, b'h', b'e', b'l', b'l', b'o', ];
let whitelist = &[ReportingField::new(1)];
let extracted = extract_reporting_token_content(&data, whitelist)
.expect("raw protobuf with whitelisted field should extract");
assert_eq!(extracted, vec![0x08, 0x96, 0x01]);
}
#[test]
fn test_raw_protobuf_extraction_nested_with_subfields() {
let inner = vec![
0x0a, 0x01, b'a', 0x12, 0x01, b'b', ];
let mut data = vec![0x32, inner.len() as u8];
data.extend(&inner);
static TEST_SUBFIELDS: &[ReportingField] = &[ReportingField::new(1)];
let whitelist = &[ReportingField::with_subfields(6, TEST_SUBFIELDS)];
let extracted = extract_reporting_token_content(&data, whitelist)
.expect("nested protobuf with subfield filtering should extract");
let expected = vec![0x32, 0x03, 0x0a, 0x01, b'a'];
assert_eq!(
extracted, expected,
"Nested extraction with subfield filtering failed"
);
}
#[test]
fn no_matching_field_yields_no_content() {
let data = vec![
0x08, 0x96, 0x01, 0x12, 0x05, b'h', b'e', b'l', b'l', b'o', ];
let whitelist = &[ReportingField::new(9)];
assert!(extract_reporting_token_content(&data, whitelist).is_none());
assert!(extract_reporting_token_content(&[], whitelist).is_none());
}
#[test]
fn fields_are_concatenated_in_field_number_order_not_wire_order() {
let low = [0x0a, 0x01, b'a']; let high = [0x12, 0x01, b'b']; let whitelist = &[ReportingField::new(1), ReportingField::new(2)];
let mut wire_ascending = Vec::new();
wire_ascending.extend_from_slice(&low);
wire_ascending.extend_from_slice(&high);
let mut wire_descending = Vec::new();
wire_descending.extend_from_slice(&high);
wire_descending.extend_from_slice(&low);
let expected = [0x0a, 0x01, b'a', 0x12, 0x01, b'b'];
assert_eq!(
extract_reporting_token_content(&wire_ascending, whitelist).unwrap(),
expected
);
assert_eq!(
extract_reporting_token_content(&wire_descending, whitelist).unwrap(),
expected
);
}
#[test]
fn repeats_of_one_field_keep_their_wire_order() {
let data = vec![
0x0a, 0x01, b'x', 0x12, 0x01, b'm', 0x0a, 0x01, b'y', ];
let whitelist = &[ReportingField::new(1), ReportingField::new(2)];
assert_eq!(
extract_reporting_token_content(&data, whitelist).unwrap(),
vec![0x0a, 0x01, b'x', 0x0a, 0x01, b'y', 0x12, 0x01, b'm'],
);
}
#[test]
fn more_fields_than_the_inline_list_holds_still_concatenate_in_order() {
let mut data = Vec::new();
let mut whitelist = Vec::new();
for field in (1u8..=8).rev() {
data.push((field << 3) | 2);
data.push(1);
data.push(b'a' + field);
whitelist.push(ReportingField::new(u32::from(field)));
}
let extracted = extract_reporting_token_content(&data, &whitelist)
.expect("eight whitelisted fields extract");
let mut expected = Vec::new();
for field in 1u8..=8 {
expected.push((field << 3) | 2);
expected.push(1);
expected.push(b'a' + field);
}
assert_eq!(extracted, expected);
}
#[test]
fn a_multibyte_payload_survives_the_copy_byte_for_byte() {
let text = "héllo ✅ 日本";
let bytes = text.as_bytes();
let mut data = vec![0x0a, u8::try_from(bytes.len()).unwrap()];
data.extend_from_slice(bytes);
data.extend_from_slice(&[0x12, 0x02, 0xff, 0xfe]);
let whitelist = &[ReportingField::new(1)];
let extracted = extract_reporting_token_content(&data, whitelist)
.expect("the whitelisted field extracts");
assert_eq!(extracted, data[..2 + bytes.len()]);
assert_eq!(std::str::from_utf8(&extracted[2..]).unwrap(), text);
}
#[test]
fn a_nested_field_is_rebuilt_and_still_ordered_with_the_flat_ones() {
let inner = vec![
0x0a, 0x01, b'a', 0x12, 0x01, b'b', ];
let mut data = vec![0x0a, 0x01, b'z'];
data.push(0x32);
data.push(u8::try_from(inner.len()).unwrap());
data.extend_from_slice(&inner);
static TEST_SUBFIELDS: &[ReportingField] = &[ReportingField::new(1)];
let whitelist = &[
ReportingField::new(1),
ReportingField::with_subfields(6, TEST_SUBFIELDS),
];
let extracted = extract_reporting_token_content(&data, whitelist)
.expect("flat + nested fields extract");
assert_eq!(
extracted,
vec![
0x0a, 0x01, b'z', 0x32, 0x03, 0x0a, 0x01, b'a', ],
);
}
#[test]
fn a_nested_field_with_nothing_kept_contributes_nothing() {
let inner = vec![0x12, 0x01, b'b']; let mut data = vec![0x32, u8::try_from(inner.len()).unwrap()];
data.extend_from_slice(&inner);
static TEST_SUBFIELDS: &[ReportingField] = &[ReportingField::new(1)];
let whitelist = &[ReportingField::with_subfields(6, TEST_SUBFIELDS)];
assert!(extract_reporting_token_content(&data, whitelist).is_none());
}
#[test]
fn the_result_is_allocated_once_for_the_exact_length_it_holds() {
let inner = vec![0x0a, 0x01, b'a'];
let mut nested_and_flat = vec![0x0a, 0x0a];
nested_and_flat.extend_from_slice(b"0123456789");
nested_and_flat.push(0x32);
nested_and_flat.push(u8::try_from(inner.len()).unwrap());
nested_and_flat.extend_from_slice(&inner);
static TEST_SUBFIELDS: &[ReportingField] = &[ReportingField::new(1)];
let cases: [(&[u8], &[ReportingField]); 2] = [
(&[0x0a, 0x01, b'z'], &[ReportingField::new(1)]),
(
&nested_and_flat,
&[
ReportingField::new(1),
ReportingField::with_subfields(6, TEST_SUBFIELDS),
],
),
];
for (data, whitelist) in cases {
let extracted =
extract_reporting_token_content(data, whitelist).expect("content extracts");
assert!(
!extracted.is_empty(),
"each case must actually extract something, or it feeds the check nothing"
);
}
}
#[test]
fn test_excluded_message_types() {
let reaction = wa::Message {
reaction_message: buffa::MessageField::some(wa::message::ReactionMessage {
text: Some("\u{1f44d}".to_string()),
..Default::default()
}),
..Default::default()
};
assert!(!should_include_reporting_token(&reaction));
assert!(generate_reporting_token_content(&reaction).is_none());
let enc_reaction = wa::Message {
enc_reaction_message: buffa::MessageField::some(
wa::message::EncReactionMessage::default(),
),
..Default::default()
};
assert!(!should_include_reporting_token(&enc_reaction));
let poll_update = wa::Message {
poll_update_message: buffa::MessageField::some(
wa::message::PollUpdateMessage::default(),
),
..Default::default()
};
assert!(!should_include_reporting_token(&poll_update));
let keep_in_chat = wa::Message {
keep_in_chat_message: buffa::MessageField::some(
wa::message::KeepInChatMessage::default(),
),
..Default::default()
};
assert!(!should_include_reporting_token(&keep_in_chat));
}
#[test]
fn test_hkdf_info_construction() {
let info = build_hkdf_info("STANZA", "sender@s.whatsapp.net", "remote@s.whatsapp.net")
.expect("test inputs should build HKDF info");
let expected = b"STANZAsender@s.whatsapp.netremote@s.whatsapp.netReport Token";
assert_eq!(
info.as_bytes(),
expected,
"HKDF info construction changed! This will break token verification."
);
}
#[test]
fn test_message_secret_in_prepared_message() {
let original = wa::Message {
conversation: Some("Test".to_string()),
..Default::default()
};
let secret = [0x12u8; MESSAGE_SECRET_SIZE];
let prepared = prepare_message_with_context(&original, &secret);
assert_eq!(prepared.conversation, original.conversation);
let ctx = prepared
.message_context_info
.as_option()
.expect("prepared message should have context info");
assert_eq!(
ctx.message_secret
.as_ref()
.expect("context info should have message secret"),
&secret.to_vec()
);
assert_eq!(ctx.reporting_token_version, Some(REPORTING_TOKEN_VERSION));
}
#[test]
fn test_prepare_message_preserves_existing_context_info() {
let original = wa::Message {
conversation: Some("Test".to_string()),
message_context_info: buffa::MessageField::some(wa::MessageContextInfo {
device_list_metadata_version: Some(42), ..Default::default()
}),
..Default::default()
};
let secret = [0x12u8; MESSAGE_SECRET_SIZE];
let prepared = prepare_message_with_context(&original, &secret);
let ctx = prepared
.message_context_info
.as_option()
.expect("prepared message should have existing context info preserved");
assert_eq!(
ctx.message_secret
.as_ref()
.expect("context info should have message secret"),
&secret.to_vec()
);
assert_eq!(ctx.reporting_token_version, Some(REPORTING_TOKEN_VERSION));
assert_eq!(ctx.device_list_metadata_version, Some(42));
}
#[test]
fn test_invalid_secret_size_generates_new() {
let message = wa::Message {
conversation: Some("Test".to_string()),
..Default::default()
};
let invalid_secret = [0u8; 16]; let sender = test_jid("sender");
let remote = test_jid("remote");
let result =
generate_reporting_token(&message, "STANZA", &sender, &remote, Some(&invalid_secret));
let result = result.expect("message should generate token even with invalid secret");
assert_eq!(result.message_secret.len(), MESSAGE_SECRET_SIZE);
assert_ne!(result.message_secret, [0u8; MESSAGE_SECRET_SIZE]);
}
}