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
79 changes: 54 additions & 25 deletions src/send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -462,12 +462,15 @@ impl Client {
let mut store_adapter = self.signal_adapter_from(device_store_arc.clone());
let mut stores = store_adapter.as_signal_stores();

// Determine which devices need SKDM using the unified per-device map
// Determine which devices need SKDM using the unified per-device map.
// Status keeps the prior phash behavior, so we drop the full device set
// and only use the SKDM-target subset.
let skdm_target_devices: Option<Vec<Jid>> = if force_skdm {
None
} else {
self.resolve_skdm_targets(&to_str, &group_info, &own_lid)
.await
.map(|(_all, needs)| needs)
};

// `<meta status_setting>` describes the POSTER's privacy on their own
Expand Down Expand Up @@ -497,6 +500,9 @@ impl Client {
request_id.clone(),
force_skdm,
skdm_target_devices,
// Status broadcasts keep the prior phash behavior (no full-set/self
// augmentation) — that path is group-only.
None,
None,
&extra_stanza_nodes,
)
Expand Down Expand Up @@ -540,6 +546,7 @@ impl Client {
true,
None,
None,
None,
&extra_stanza_nodes,
)
.await?
Expand Down Expand Up @@ -591,8 +598,12 @@ impl Client {
})
}

/// Resolve which devices need SKDM. Returns `None` for full distribution
/// (no cache data), or `Some(devices)` listing devices that need fresh SKDM.
/// Resolve the group's device set for a warm/partial send. Returns
/// `None` when device resolution fails (caller falls back to the full
/// `force_skdm` path), otherwise `Some((all_devices, needs_skdm))` where
/// `all_devices` is the complete resolved set (feeds the phash) and
/// `needs_skdm` is the subset still missing the sender key (feeds SKDM
/// distribution). `needs_skdm` may be empty (fully warm send).
///
/// For LID mode, uses `group_info.phone_jid_for_lid_user` to query devices
/// via PN when available (LID usync is unreliable for own JID), then
Expand All @@ -602,7 +613,7 @@ impl Client {
group_jid: &str,
group_info: &wacore::client::context::GroupInfo,
own_sending_jid: &Jid,
) -> Option<Vec<Jid>> {
) -> Option<(Vec<Jid>, Vec<Jid>)> {
use crate::sender_key_device_cache::SenderKeyDeviceMap;

// Atomic get-or-init: if another task invalidated the cache during our
Expand Down Expand Up @@ -655,8 +666,11 @@ impl Client {
all_devices
};

// Borrow for the filter so `all_devices` survives to feed the
// phash (the full set), while `needs_skdm` is just the subset
// still missing the key.
let needs_skdm: Vec<Jid> = all_devices
.into_iter()
.iter()
.filter(|device| {
if device.is_hosted() {
return false;
Expand All @@ -672,18 +686,16 @@ impl Client {
.unwrap_or(false)
|| cached_map.is_user_forgotten(&device.user)
})
.cloned()
.collect();

if needs_skdm.is_empty() {
Some(vec![])
} else {
log::debug!(
"Found {} devices needing SKDM for {}",
needs_skdm.len(),
group_jid
);
Some(needs_skdm)
}
log::debug!(
"Resolved {} devices ({} need SKDM) for {}",
all_devices.len(),
needs_skdm.len(),
group_jid
);
Some((all_devices, needs_skdm))
}
Err(e) => {
log::warn!(
Expand Down Expand Up @@ -1137,12 +1149,23 @@ impl Client {

// Determine which devices need SKDM distribution using the unified
// per-device sender key map (matches WA Web's participant.senderKey Map).
let skdm_target_devices: Option<Vec<Jid>> = if force_skdm {
None
} else {
self.resolve_skdm_targets(&to_str, &group_info, &own_sending_jid)
.await
};
// `all_devices_for_phash` carries the FULL resolved set so the phash
// covers every device + self even on a warm send (WA Web sends a
// phash on every group send); `skdm_target_devices` is the subset
// still missing the key. On the cold/`force_skdm` path both are
// `None` and `prepare_group_stanza` resolves the set itself.
let (all_devices_for_phash, skdm_target_devices): (Option<Vec<Jid>>, Option<Vec<Jid>>) =
if force_skdm {
(None, None)
} else {
match self
.resolve_skdm_targets(&to_str, &group_info, &own_sending_jid)
.await
{
Some((all, needs)) => (Some(all), Some(needs)),
None => (None, None),
}
};

match wacore::send::prepare_group_stanza(
&*self.runtime,
Expand All @@ -1157,6 +1180,7 @@ impl Client {
request_id.clone(),
force_skdm,
skdm_target_devices,
all_devices_for_phash,
edit.clone(),
&extra_stanza_nodes,
)
Expand Down Expand Up @@ -1204,6 +1228,7 @@ impl Client {
request_id,
true,
None,
None,
edit.clone(),
&extra_stanza_nodes,
)
Expand Down Expand Up @@ -2606,14 +2631,18 @@ mod tests {

let group_info = GroupInfo::new(participants.clone(), AddressingMode::Lid);

let result = client
let (all_devices, needs_skdm) = client
.resolve_skdm_targets(group_jid, &group_info, &own_lid)
.await
.expect("None means the empty-cache early-exit is back");
.expect("None means device resolution failed");

assert_eq!(result.len(), participants.len());
// Empty cache → every participant needs SKDM, and the full set equals
// the target set on this cold path.
assert_eq!(needs_skdm.len(), participants.len());
assert_eq!(all_devices.len(), participants.len());
for user in &participant_users {
assert!(result.iter().any(|j| j.user == *user));
assert!(needs_skdm.iter().any(|j| j.user == *user));
assert!(all_devices.iter().any(|j| j.user == *user));
}
}

Expand Down
7 changes: 7 additions & 0 deletions wacore/benches/send_receive_benchmark.rs
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,7 @@ fn setup_group_recv() -> GrpRecvData {
false,
None,
None,
None,
&[],
))
.unwrap();
Expand Down Expand Up @@ -712,6 +713,11 @@ fn bench_dm_recv(mut d: DmRecvData) {

fn run_group_send(d: &mut GrpSendData) {
let own_jid = d.alice.jid.clone();
// Warm sends (force_skdm=false) distribute no SKDM, so prepare_group_stanza
// only emits a phash if it gets the full device set. Mirror the real
// warm-send caller by passing it; the cold/force_skdm path resolves the set
// itself and keeps None.
let all_devices_for_phash = (!d.force_skdm).then(|| d.participants.clone());
let mut group_info = GroupInfo::new(std::mem::take(&mut d.participants), AddressingMode::Pn);
let mut stores = SignalStores {
sender_key_store: &mut d.alice.sender_keys,
Expand All @@ -734,6 +740,7 @@ fn run_group_send(d: &mut GrpSendData) {
"b-grp".into(),
d.force_skdm,
None,
all_devices_for_phash,
None,
&[],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
))
Expand Down
48 changes: 47 additions & 1 deletion wacore/src/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,12 @@ impl MessageUtils {
.finalize_sha256_array()
.map_err(|e| anyhow!("failed to finalize hash: {:?}", e))?;

// Standard base64 ('+'/'/'), matching whatsmeow (`base64.RawStdEncoding`)
// and WA Web (`WABase64.encodeB64`). URL-safe ('-'/'_') diverges from the
// server on ~22% of phashes (any output hitting base64 index 62/63).
Ok(format!(
"2:{hash}",
hash = base64::prelude::BASE64_URL_SAFE_NO_PAD.encode(&full_hash[..6])
hash = base64::prelude::BASE64_STANDARD_NO_PAD.encode(&full_hash[..6])
))
}

Expand Down Expand Up @@ -604,4 +607,47 @@ mod parse_message_info_tests {
"pad len 16 must be reachable (was unreachable before)"
);
}

// Cross-impl phash parity vs whatsmeow (`base64.RawStdEncoding`) and WA Web
// (`WABase64.encodeB64` = standard '+'/'/'). Inputs engineered so
// sha256(adstrings)[..6] hits base64 index 62/63 — these are exactly the
// bytes that URL-safe ('-'/'_') would have encoded differently from the
// server. Pins our output to the standard alphabet the server expects.
#[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_ad_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}");

// Standard-base64 outputs (match whatsmeow + WA Web = the server).
// `single` and `multi` carry a 62/63 byte, so they differ from the
// old URL-safe output (`2:5s-YxCff` / `2:AAv_hwhn`); `control` has
// neither, so it is unchanged across alphabets.
assert_eq!(h_single, "2:5s+YxCff");
assert_eq!(h_control, "2:RJWVxcMQ");
assert_eq!(h_multi, "2:AAv/hwhn");
}
}
Loading
Loading