diff --git a/src/send.rs b/src/send.rs index ef8e61a6e..77d197819 100644 --- a/src/send.rs +++ b/src/send.rs @@ -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> = if force_skdm { None } else { self.resolve_skdm_targets(&to_str, &group_info, &own_lid) .await + .map(|(_all, needs)| needs) }; // `` describes the POSTER's privacy on their own @@ -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, ) @@ -540,6 +546,7 @@ impl Client { true, None, None, + None, &extra_stanza_nodes, ) .await? @@ -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 @@ -602,7 +613,7 @@ impl Client { group_jid: &str, group_info: &wacore::client::context::GroupInfo, own_sending_jid: &Jid, - ) -> Option> { + ) -> Option<(Vec, Vec)> { use crate::sender_key_device_cache::SenderKeyDeviceMap; // Atomic get-or-init: if another task invalidated the cache during our @@ -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 = all_devices - .into_iter() + .iter() .filter(|device| { if device.is_hosted() { return false; @@ -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!( @@ -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> = 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>, Option>) = + 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, @@ -1157,6 +1180,7 @@ impl Client { request_id.clone(), force_skdm, skdm_target_devices, + all_devices_for_phash, edit.clone(), &extra_stanza_nodes, ) @@ -1204,6 +1228,7 @@ impl Client { request_id, true, None, + None, edit.clone(), &extra_stanza_nodes, ) @@ -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)); } } diff --git a/wacore/benches/send_receive_benchmark.rs b/wacore/benches/send_receive_benchmark.rs index e474c55fd..06fb35fef 100644 --- a/wacore/benches/send_receive_benchmark.rs +++ b/wacore/benches/send_receive_benchmark.rs @@ -664,6 +664,7 @@ fn setup_group_recv() -> GrpRecvData { false, None, None, + None, &[], )) .unwrap(); @@ -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, @@ -734,6 +740,7 @@ fn run_group_send(d: &mut GrpSendData) { "b-grp".into(), d.force_skdm, None, + all_devices_for_phash, None, &[], )) diff --git a/wacore/src/messages.rs b/wacore/src/messages.rs index a7504e873..320baa453 100644 --- a/wacore/src/messages.rs +++ b/wacore/src/messages.rs @@ -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]) )) } @@ -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"); + } } diff --git a/wacore/src/send.rs b/wacore/src/send.rs index e1bff6ac9..16109f508 100644 --- a/wacore/src/send.rs +++ b/wacore/src/send.rs @@ -1314,7 +1314,12 @@ where /// tracking without re-resolving devices. pub struct PreparedGroupStanza { pub node: Node, - /// Devices that actually received SKDM (successfully encrypted). + /// Full SKDM distribution target set, marked `has_key=true` after the + /// server ACK. Mirrors WA Web `markHasSenderKey(x, M)` which marks the + /// whole target set `M`, not only the devices that encrypted successfully: + /// devices that failed (406 / no bundle) are marked too so they are not + /// re-targeted on every send (the retry-receipt path repairs any that are + /// actually alive and keyless via `mark_forget_sender_key`). pub skdm_devices: Vec, /// Users whose device registry should be invalidated because their /// devices returned 406 (unregistered) during SKDM prekey fetch. @@ -1350,6 +1355,11 @@ pub async fn prepare_group_stanza< request_id: String, force_skdm_distribution: bool, skdm_target_devices: Option>, + // Full resolved device set for the phash (groups only). `Some` on warm/partial + // sends so the phash covers every device + self even when no SKDM is sent; + // `None` on the cold `force_skdm` path (the set is resolved here) and for + // status broadcasts (which keep the prior phash behavior). + all_devices_for_phash: Option>, edit: Option, extra_stanza_nodes: &[Node], ) -> Result { @@ -1513,15 +1523,37 @@ pub async fn prepare_group_stanza< None }; - let mut had_unregistered_devices = false; - - if let Some(ref distribution_list) = distribution_list { - // WA Web computes phash from the full distribution list (target set at - // send time), not the actual encrypted outcome + // Phash (groups): cover the FULL participant device set + the sending device + // on EVERY send, matching WA Web `phashV2([].concat(A, [B]))`. Verified + // against a real WA Web capture: the recipient set plus the sending device + // reproduced the on-wire phash exactly, the recipient set alone did not. The + // server validates it silently (it is not echoed on a normal ack). Status + // broadcasts keep the prior behavior (phash over the distribution list only, + // when distributing); WA Web's status path does not augment with self. + if to_jid.is_group() { + // Warm/partial sends pass the complete set in `all_devices_for_phash`; + // the cold `force_skdm` path leaves it None and `distribution_list` + // already holds the full resolved set. + if let Some(src) = all_devices_for_phash + .as_deref() + .or(distribution_list.as_deref()) + { + let phash_set = build_group_phash_set(src, &own_sending_jid); + match MessageUtils::participant_list_hash(&phash_set) { + Ok(phash) => phash_for_stanza = Some(phash), + Err(e) => log::warn!("Failed to compute group phash for {}: {:?}", to_jid, e), + } + } + } else if let Some(ref distribution_list) = distribution_list { match MessageUtils::participant_list_hash(distribution_list) { Ok(phash) => phash_for_stanza = Some(phash), - Err(e) => log::warn!("Failed to compute phash for group {}: {:?}", to_jid, e), + Err(e) => log::warn!("Failed to compute phash for {}: {:?}", to_jid, e), } + } + + let mut had_unregistered_devices = false; + + if let Some(ref distribution_list) = distribution_list { let axolotl_skdm_bytes = create_sender_key_distribution_message_for_group( stores.sender_key_store, &sender_key_name, @@ -1661,13 +1693,35 @@ pub async fn prepare_group_stanza< Ok(PreparedGroupStanza { node: stanza, - skdm_devices: skdm_encrypted_devices, + // Mark the full target set (matches WA Web `markHasSenderKey(x, M)`), not + // just `skdm_encrypted_devices`. `stale_users` above already used the + // encrypted subset to find which devices to re-resolve. + skdm_devices: distribution_list.unwrap_or_default(), stale_device_users: stale_users, message_secret: reporting_result.map(|r| r.message_secret), sender_identity: own_sending_jid, }) } +/// Build the device set hashed into a group `phash`, matching WA Web +/// `phashV2([].concat(A, [B]))`: every participant device (`A`) plus the +/// sending device `B`. `devices` is the resolved set (recipients); the sending +/// device is excluded from it (we never SKDM ourselves) so it is appended here. +/// Hosted devices don't take part in group E2EE and are dropped, mirroring the +/// SKDM distribution filter. `participant_list_hash` sorts before hashing, so +/// order here is irrelevant. +pub(crate) fn build_group_phash_set(devices: &[Jid], own_sending_jid: &Jid) -> Vec { + let mut set: Vec = devices.iter().filter(|d| !d.is_hosted()).cloned().collect(); + if !set + .iter() + .any(|d| d.user == own_sending_jid.user && d.device == own_sending_jid.device) + { + set.push(own_sending_jid.clone()); + } + crate::types::jid::sort_dedup_by_device(&mut set); + set +} + /// Collect users whose devices failed SKDM so the caller can invalidate their /// registry entries. In LID-mode groups, both the LID and PN aliases are /// emitted when the group knows the mapping — `invalidate_device_cache` needs @@ -4254,4 +4308,370 @@ mod tests { assert!(out.is_empty()); } } + + /// Item 2 — WA Web `markHasSenderKey(x, M)`: a key-distributing group send + /// marks the FULL SKDM target set `has_key=true`, not only the devices that + /// encrypted successfully. A device whose SKDM encryption fails (no session + /// and no bundle, mimicking a 406) must still land in + /// `PreparedGroupStanza.skdm_devices`, so the next send does not re-target + /// it every time (the fan-out storm); the retry-receipt path repairs any + /// device that is actually alive and keyless. + mod mark_full_distribution_list { + use super::*; + use crate::libsignal::protocol::{ + Direction, IdentityChange, IdentityKey, IdentityKeyStore, PreKeyId, PreKeyRecord, + PreKeyStore, ProtocolAddress, SenderKeyRecord, SenderKeyStore, SessionStore, + SignedPreKeyId, SignedPreKeyRecord, SignedPreKeyStore, UsePQRatchet, + process_prekey_bundle, + }; + use crate::libsignal::store::sender_key_name::SenderKeyName; + use crate::runtime::{AbortHandle, Runtime}; + use crate::types::jid::JidExt; + use crate::types::message::AddressingMode; + use std::future::Future; + use std::pin::Pin; + use std::time::Duration; + + type SigResult = crate::libsignal::protocol::error::Result; + + #[derive(Clone, Default)] + struct MemSessionStore(HashMap>); + #[async_trait::async_trait] + impl SessionStore for MemSessionStore { + async fn load_session( + &self, + a: &ProtocolAddress, + ) -> SigResult> { + Ok(self + .0 + .get(a) + .and_then(|b| crate::libsignal::protocol::SessionRecord::deserialize(b).ok())) + } + async fn has_session(&self, a: &ProtocolAddress) -> SigResult { + Ok(self.0.contains_key(a)) + } + async fn store_session( + &mut self, + a: &ProtocolAddress, + r: crate::libsignal::protocol::SessionRecord, + ) -> SigResult<()> { + self.0.insert(a.clone(), r.serialize()?); + Ok(()) + } + } + + #[derive(Clone)] + struct MemIdentityStore { + pair: IdentityKeyPair, + reg_id: u32, + known: HashMap, + } + #[async_trait::async_trait] + impl IdentityKeyStore for MemIdentityStore { + async fn get_identity_key_pair(&self) -> SigResult { + Ok(self.pair.clone()) + } + async fn get_local_registration_id(&self) -> SigResult { + Ok(self.reg_id) + } + async fn save_identity( + &mut self, + a: &ProtocolAddress, + id: &IdentityKey, + ) -> SigResult { + self.known.insert(a.clone(), *id); + Ok(IdentityChange::from_changed(false)) + } + async fn is_trusted_identity( + &self, + _: &ProtocolAddress, + _: &IdentityKey, + _: Direction, + ) -> SigResult { + Ok(true) + } + async fn get_identity(&self, a: &ProtocolAddress) -> SigResult> { + Ok(self.known.get(a).copied()) + } + } + + #[derive(Default)] + struct MemSenderKeyStore(HashMap); + #[async_trait::async_trait] + impl SenderKeyStore for MemSenderKeyStore { + async fn store_sender_key( + &mut self, + n: &SenderKeyName, + r: SenderKeyRecord, + ) -> SigResult<()> { + self.0.insert(n.clone(), r); + Ok(()) + } + async fn load_sender_key( + &self, + n: &SenderKeyName, + ) -> SigResult> { + Ok(self.0.get(n).cloned()) + } + } + + // Outgoing group encryption never consumes our own prekeys, and device B + // has no bundle (so no session is established for it) — these are never + // called; present only to satisfy the generic bounds. + struct UnusedPreKeyStore; + #[async_trait::async_trait] + impl PreKeyStore for UnusedPreKeyStore { + async fn get_pre_key(&self, _: PreKeyId) -> SigResult { + unreachable!("prekey store not used in outgoing group encrypt") + } + async fn save_pre_key(&mut self, _: PreKeyId, _: &PreKeyRecord) -> SigResult<()> { + unreachable!() + } + async fn remove_pre_key(&mut self, _: PreKeyId) -> SigResult<()> { + unreachable!() + } + } + struct UnusedSignedPreKeyStore; + #[async_trait::async_trait] + impl SignedPreKeyStore for UnusedSignedPreKeyStore { + async fn get_signed_pre_key(&self, _: SignedPreKeyId) -> SigResult { + unreachable!("signed prekey store not used in outgoing group encrypt") + } + async fn save_signed_pre_key( + &mut self, + _: SignedPreKeyId, + _: &SignedPreKeyRecord, + ) -> SigResult<()> { + unreachable!() + } + } + + struct TokioTestRuntime; + #[async_trait::async_trait] + impl Runtime for TokioTestRuntime { + fn spawn( + &self, + future: Pin + Send + 'static>>, + ) -> AbortHandle { + let handle = tokio::spawn(future); + AbortHandle::new(move || handle.abort()) + } + fn sleep(&self, _d: Duration) -> Pin + Send>> { + // Not exercised on the send path; wacore dev-deps omit tokio's + // "time" feature, so resolve immediately rather than time out. + Box::pin(async {}) + } + fn spawn_blocking( + &self, + f: Box, + ) -> Pin + Send>> { + Box::pin(async move { + let _ = tokio::task::spawn_blocking(f).await; + }) + } + fn yield_now(&self) -> Option + Send>>> { + None + } + } + + // Establish a real Signal session for `a` so its SKDM encrypts; the + // returned identity store is the sender's (knows `a` after X3DH). + async fn established_stores(a: &Jid) -> (MemSessionStore, MemIdentityStore) { + let mut rng = rand::make_rng::(); + let sender = IdentityKeyPair::generate(&mut rng); + let receiver = IdentityKeyPair::generate(&mut rng); + let spk = KeyPair::generate(&mut rng); + let opk = KeyPair::generate(&mut rng); + let sig = receiver + .private_key() + .calculate_signature(&spk.public_key.serialize(), &mut rng) + .unwrap(); + let bundle = PreKeyBundle::new( + 1, + 1u32.into(), + Some((1u32.into(), opk.public_key)), + 1u32.into(), + spk.public_key, + sig.to_vec(), + *receiver.identity_key(), + ) + .unwrap(); + let mut ss = MemSessionStore::default(); + let mut is = MemIdentityStore { + pair: sender, + reg_id: 42, + known: HashMap::new(), + }; + process_prekey_bundle( + &a.to_protocol_address(), + &mut ss, + &mut is, + &bundle, + &mut rng, + UsePQRatchet::No, + ) + .await + .unwrap(); + (ss, is) + } + + #[tokio::test] + async fn failed_device_is_still_marked_has_key() { + let group: Jid = "120363000000000001@g.us".parse().unwrap(); + let own_jid: Jid = "559900000000@s.whatsapp.net".parse().unwrap(); + let own_lid: Jid = "100000000000000@lid".parse().unwrap(); + // A has a session (encrypts ok); B has neither session nor bundle, + // mimicking a device that 406'd / has no key material. + let a: Jid = "559911112222:0@s.whatsapp.net".parse().unwrap(); + let b: Jid = "559933334444:0@s.whatsapp.net".parse().unwrap(); + + let (mut ss, mut is) = established_stores(&a).await; + let mut sks = MemSenderKeyStore::default(); + let mut pks = UnusedPreKeyStore; + let spks = UnusedSignedPreKeyStore; + let mut stores = SignalStores { + sender_key_store: &mut sks, + session_store: &mut ss, + identity_store: &mut is, + prekey_store: &mut pks, + signed_prekey_store: &spks, + }; + + // Empty resolver: no LID overrides; B's prekey fetch returns nothing + // → B is dropped by the encrypt fan-out (not in encrypted_devices). + let resolver = MockSendContextResolver::new(); + let rt = TokioTestRuntime; + + let mut group_info = GroupInfo::new( + vec![own_jid.to_non_ad(), a.to_non_ad(), b.to_non_ad()], + AddressingMode::Pn, + ); + let msg = wa::Message { + conversation: Some("hi".into()), + ..Default::default() + }; + + let prepared = prepare_group_stanza( + &rt, + &mut stores, + &resolver, + &mut group_info, + &own_jid, + &own_lid, + None, + group, + &msg, + "TESTREQID".into(), + false, + Some(vec![a.clone(), b.clone()]), + None, + None, + &[], + ) + .await + .expect("prepare_group_stanza should succeed even when a device fails to encrypt"); + + let marked: std::collections::HashSet = prepared + .skdm_devices + .iter() + .map(|j| j.to_string()) + .collect(); + + assert!( + marked.contains(&a.to_string()), + "device that encrypted must be marked" + ); + assert!( + marked.contains(&b.to_string()), + "device whose SKDM encryption FAILED must still be marked has_key \ + (WA Web markHasSenderKey(x, M) marks the full target set → no re-fanout storm)" + ); + assert_eq!( + prepared.skdm_devices.len(), + 2, + "exactly the full distribution list (A + B), not just the encrypted subset" + ); + + // A key-distributing send must carry a phash (computed over the list). + assert!( + prepared.node.attrs().optional_string("phash").is_some(), + "a key-distributing group send must carry a phash" + ); + } + } + + /// Item 3 — phash device-set construction. The set hashed is the full + /// recipient list PLUS the sending device (which is never in the recipient + /// list, since we don't SKDM ourselves), matching WA Web + /// `phashV2([].concat(A, [B]))`. + /// + /// This was confirmed against a real WA Web capture sent to the production + /// server: the recipient `` set plus the sending device reproduced the + /// exact `phash` on the wire, while the recipient set alone did not — so the + /// sending device is part of the hash. Raw identifiers are not committed + /// (PII); the vectors below are fictitious but exercise the same logic. + mod group_phash_golden { + use super::*; + + #[test] + fn phash_set_includes_sending_device() { + // Fictitious group: a few users with bare (device 0) + companion + // devices. The self user appears as a companion (device 0) in the + // recipient list; its SENDING device (24) is excluded, mirroring a + // real send (we never SKDM ourselves). + let recipients: Vec = [ + "100000000000001@lid", + "100000000000001:5@lid", + "100000000000002@lid", + "100000000000003@lid", + "100000000000003:12@lid", + "100000000000099@lid", + ] + .iter() + .map(|s| s.parse().expect("valid LID jid")) + .collect(); + + let own_sending: Jid = "100000000000099:24@lid".parse().unwrap(); + assert!( + !recipients + .iter() + .any(|j: &Jid| j.user == "100000000000099" && j.device == 24), + "the sending device must not already be in the recipient list" + ); + + let set = build_group_phash_set(&recipients, &own_sending); + assert_eq!(set.len(), 7, "6 recipients + the sending device"); + + // Dropping the sending device changes the hash, proving it is part + // of the hashed set (WA Web `[].concat(A, [B])`). + let with_self = MessageUtils::participant_list_hash(&set).unwrap(); + let without_self = MessageUtils::participant_list_hash(&recipients).unwrap(); + assert_ne!(with_self, without_self); + + // Deterministic standard-base64 vectors (regression guard). + assert_eq!(without_self, "2:rZoSAdIV"); + assert_eq!(with_self, "2:sti8OtHX"); + } + + #[test] + fn phash_set_drops_hosted_devices() { + // Hosted (Cloud API) devices don't take part in group E2EE and must + // not enter the phash, mirroring the SKDM distribution filter. + let with_hosted: Vec = ["100000000000001@lid", "100000000000002:99@hosted"] + .iter() + .map(|s| s.parse().expect("valid jid")) + .collect(); + let without_hosted: Vec = ["100000000000001@lid"] + .iter() + .map(|s| s.parse().expect("valid jid")) + .collect(); + let own: Jid = "100000000000099:24@lid".parse().unwrap(); + + assert_eq!( + build_group_phash_set(&with_hosted, &own), + build_group_phash_set(&without_hosted, &own), + "hosted devices must not affect the phash set" + ); + } + } }