use anyhow::{Result, anyhow};
use hkdf::Hkdf;
use hmac::{Hmac, KeyInit, Mac};
use prost::Message;
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";
pub fn generate_message_secret() -> [u8; MESSAGE_SECRET_SIZE] {
use rand::RngExt;
let mut rng = rand::make_rng::<rand::rngs::StdRng>();
rng.random()
}
fn build_hkdf_info(stanza_id: &str, sender_jid: &str, remote_jid: &str) -> Vec<u8> {
let cap = stanza_id.len() + sender_jid.len() + remote_jid.len() + USE_CASE_REPORT_TOKEN.len();
let mut info = Vec::with_capacity(cap);
info.extend_from_slice(stanza_id.as_bytes());
info.extend_from_slice(sender_jid.as_bytes());
info.extend_from_slice(remote_jid.as_bytes());
info.extend_from_slice(USE_CASE_REPORT_TOKEN.as_bytes());
info
}
pub fn derive_reporting_token_key(
message_secret: &[u8],
stanza_id: &str,
sender_jid: &str,
remote_jid: &str,
) -> Result<[u8; REPORTING_TOKEN_KEY_SIZE]> {
if message_secret.len() != MESSAGE_SECRET_SIZE {
return Err(anyhow!(
"Invalid message secret size: expected {}, got {}",
MESSAGE_SECRET_SIZE,
message_secret.len()
));
}
let info = build_hkdf_info(stanza_id, sender_jid, remote_jid);
let hk = Hkdf::<Sha256>::new(None, message_secret);
let mut key = [0u8; REPORTING_TOKEN_KEY_SIZE];
hk.expand(&info, &mut key)
.map_err(|e| anyhow!("HKDF expand failed: {}", e))?;
Ok(key)
}
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()
}
pub fn extract_reporting_token_content(
data: &[u8],
whitelist: &[ReportingField],
) -> Option<Vec<u8>> {
let mut extracted: Vec<(u32, Vec<u8>)> = Vec::with_capacity(4);
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, data[field_start..pos].to_vec()));
}
}
wire_type::FIXED64 => {
if pos + 8 > data.len() {
break;
}
pos += 8;
if entry.is_some() {
extracted.push((field_number, data[field_start..pos].to_vec()));
}
}
wire_type::FIXED32 => {
if pos + 4 > data.len() {
break;
}
pos += 4;
if entry.is_some() {
extracted.push((field_number, data[field_start..pos].to_vec()));
}
}
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, 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, field_bytes));
}
} else {
extracted.push((field_number, data[field_start..pos].to_vec()));
}
}
}
_ => {
return None;
}
}
}
if extracted.is_empty() {
return None;
}
extracted.sort_by_key(|(num, _)| *num);
let total_len: usize = extracted.iter().map(|(_, v)| v.len()).sum();
let mut result = Vec::with_capacity(total_len);
for (_, bytes) in extracted {
result.extend(bytes);
}
Some(result)
}
pub fn should_include_reporting_token(message: &wa::Message) -> bool {
message.reaction_message.is_none()
&& message.enc_reaction_message.is_none()
&& message.poll_update_message.is_none()
&& message.keep_in_chat_message.is_none()
}
pub fn generate_reporting_token_content(message: &wa::Message) -> Option<Vec<u8>> {
if !should_include_reporting_token(message) {
return None;
}
let message_bytes = message.encode_to_vec();
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)
}
#[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> {
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 sender_jid_str = sender_jid.to_string();
let remote_jid_str = remote_jid.to_string();
let key =
derive_reporting_token_key(&message_secret, stanza_id, &sender_jid_str, &remote_jid_str)
.ok()?;
let content = generate_reporting_token_content(message)?;
let token = calculate_reporting_token(&key, &content).ok()?;
Some(ReportingTokenResult {
message_secret,
reporting_token: token,
version: REPORTING_TOKEN_VERSION,
})
}
pub fn build_reporting_node(result: &ReportingTokenResult) -> Node {
let token_node = NodeBuilder::new("reporting_token")
.attrs([("v", result.version.to_string())])
.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 = Some(context_info);
new_message
}
pub fn extract_message_secret(message: &wa::Message) -> Option<&[u8]> {
message
.message_context_info
.as_ref()
.and_then(|ctx| ctx.message_secret.as_deref())
}
#[cfg(test)]
mod tests {
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 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 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: Some(Box::new(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: Some(wa::message::ReactionMessage {
key: None,
text: Some("👍".to_string()),
..Default::default()
}),
..Default::default()
};
assert!(!should_include_reporting_token(&reaction_message));
let poll_update = wa::Message {
poll_update_message: 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 = message.encode_to_vec();
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: Some(Box::new(wa::message::ExtendedTextMessage {
text: Some("Hello".to_string()),
context_info: Some(Box::new(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 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
);
}
}
}
#[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);
let ctx = prepared
.message_context_info
.expect("prepared message should have context info");
assert_eq!(ctx.message_secret, Some(secret.to_vec()));
assert_eq!(ctx.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: 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: Some(Box::new(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: Some(Box::new(wa::message::ExtendedTextMessage {
text: Some("Test".to_string()),
context_info: Some(Box::new(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: Some(Box::new(wa::message::ExtendedTextMessage {
text: Some("Content".to_string()),
context_info: Some(Box::new(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 test_excluded_message_types() {
let reaction = wa::Message {
reaction_message: Some(wa::message::ReactionMessage {
text: Some("👍".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: Some(wa::message::EncReactionMessage::default()),
..Default::default()
};
assert!(!should_include_reporting_token(&enc_reaction));
let poll_update = wa::Message {
poll_update_message: Some(wa::message::PollUpdateMessage::default()),
..Default::default()
};
assert!(!should_include_reporting_token(&poll_update));
let keep_in_chat = wa::Message {
keep_in_chat_message: 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");
let expected = b"STANZAsender@s.whatsapp.netremote@s.whatsapp.netReport Token";
assert_eq!(
info,
expected.to_vec(),
"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_ref()
.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: 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_ref()
.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]);
}
}