Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 19 additions & 10 deletions wacore/binary/src/jid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -607,20 +607,29 @@ impl Jid {
}

pub fn to_ad_string(&self) -> String {
if self.user.is_empty() {
return self.server.as_str().to_string();
}
let mut s = String::with_capacity(self.user.len() + 20);
s.push_str(&self.user);
s.push('.');
s.push_str(itoa::Buffer::new().format(self.agent));
s.push(':');
s.push_str(itoa::Buffer::new().format(self.device));
s.push('@');
s.push_str(self.server.as_str());
self.push_ad_to(&mut s);
s
}

/// Append the AD-string form (`user.agent:device@server`) to `buf`, for
/// callers that batch many JIDs into one shared buffer instead of paying
/// a heap `String` per JID (see `participant_list_hash`).
#[inline]
pub fn push_ad_to(&self, buf: &mut String) {
if self.user.is_empty() {
buf.push_str(self.server.as_str());
return;
}
buf.push_str(&self.user);
buf.push('.');
buf.push_str(itoa::Buffer::new().format(self.agent));
buf.push(':');
buf.push_str(itoa::Buffer::new().format(self.device));
buf.push('@');
buf.push_str(self.server.as_str());
}

/// Append the Display representation to `buf` using direct push operations,
/// bypassing `fmt::Display` and `dyn Write` dispatch.
#[inline]
Expand Down
68 changes: 63 additions & 5 deletions wacore/src/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,14 +195,25 @@ impl MessageUtils {
pub fn participant_list_hash<'a>(
devices: impl IntoIterator<Item = &'a wacore_binary::Jid>,
) -> Result<String> {
// Hash sorted ad_strings incrementally (avoids join() allocation).
let mut jids: Vec<String> = devices.into_iter().map(|j| j.to_ad_string()).collect();
jids.sort_unstable();
// Format every device into one shared arena and sort range views over
// it: two allocations total instead of a heap String per device (this
// runs over the full device set on every group send). Sorting the
// slices is the same lexicographic order as sorting the individual
// ad_strings, so the hashed concatenation is byte-identical.
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_ad_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 jid in &jids {
h.update(jid.as_bytes());
for &(start, end) in &ranges {
h.update(&arena.as_bytes()[start..end]);
}

let full_hash = h
Expand Down Expand Up @@ -932,6 +943,53 @@ mod parse_message_info_tests {
assert_eq!(h_multi, "2:AAv/hwhn");
}

/// Locks the arena-sorted phash against the straightforward reference
/// (one String per device, sorted, concatenated) over a mixed device set:
/// unsorted input, duplicate JIDs, agents, multiple servers, and the
/// prefix-ordering edge ("111" vs "1110" users) where a slice comparator
/// bug would diverge from String ordering.
#[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_ad_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
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// #6 — validate_bcl_hash accepts the matching phashV2 and rejects a tampered
// one (the WA Web validateBclHash check on device-sent broadcasts).
#[test]
Expand Down
Loading