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
42 changes: 42 additions & 0 deletions src/client/sender_keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,48 @@ mod tests {
);
}

// The WARM mark also excludes own devices (mirrors WA Web's `!isMeDevice` guard,
// applied to markHasSenderKey too). Our own companions must never be memoized, or
// the forget path (which also excludes own) could never un-mark one whose single
// SKDM encryption failed — a permanent orphan. The external member IS marked warm.
#[tokio::test]
async fn warm_mark_excludes_own_devices() {
let client = create_test_client().await;
let own_lid: Jid = "888000888000888:3@lid".parse().unwrap();
client
.persistence_manager
.process_command(crate::store::commands::DeviceCommand::SetLid(Some(
own_lid.clone(),
)))
.await;

let group = "120363000000000001@g.us";
let own_companion: Jid = "888000888000888:5@lid".parse().unwrap();
let member: Jid = "111000111000111:0@lid".parse().unwrap();

client
.set_sender_key_status_for_devices(group, &[own_companion, member], true, true)
.await
.unwrap();

let rows = client
.persistence_manager
.get_sender_key_devices(group)
.await
.unwrap();
let map = SenderKeyDeviceMap::from_db_rows(&rows);
assert_eq!(
map.device_has_key("111000111000111", 0),
Some(true),
"the external member is marked warm"
);
assert_eq!(
map.device_has_key("888000888000888", 5),
None,
"our own companion is never memoized"
);
}

// When every named device is our own, nothing is kept: no DB write, no flip,
// and (crucially) no generation bump that would churn the warm memo.
#[tokio::test]
Expand Down
66 changes: 65 additions & 1 deletion src/send/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1158,13 +1158,22 @@ impl Client {
/// for the stanza, avoiding a redundant `resolve_devices` call and preventing
/// the clear-then-fail race where a transient resolver failure leaves the map empty.
/// Mark devices as `has_key=true` after successful SKDM distribution.
///
/// Excludes our own devices (`exclude_own_devices=true`), mirroring WA Web's
/// `ParticipantStore` helper, which guards every `markHasSenderKey` mutation
/// with `!isMeDevice`. Own companions are therefore never memoized as warm, so
/// `filter_skdm_targets` re-distributes their SKDM on every send — the same
/// reason WA Web can't orphan its own companions. Marking them here instead
/// would be one-directional: the retry-receipt forget path also excludes own
/// devices (to stop an inbound retry tearing down our own session), so an own
/// companion whose one SKDM encryption failed could never be re-sent one.
async fn update_sender_key_devices(&self, group_jid: &str, devices: &[Jid]) {
if devices.is_empty() {
return;
}

if let Err(e) = self
.set_sender_key_status_for_devices(group_jid, devices, true, false)
.set_sender_key_status_for_devices(group_jid, devices, true, true)
.await
{
log::warn!(
Expand Down Expand Up @@ -2909,6 +2918,61 @@ mod tests {
);
}

/// End-to-end: after a send marks its SKDM targets, our own companion is NOT
/// memoized (WA Web `!isMeDevice` guard on `markHasSenderKey`), so the next send
/// re-distributes its SKDM — it can't be orphaned by a one-off encryption
/// failure (the retry/forget path also excludes own devices). An external member
/// stays warm and is not re-targeted.
#[tokio::test]
async fn own_companion_is_never_memoized_so_it_redistributes_every_send() {
use crate::sender_key_device_cache::SenderKeyDeviceMap;

let client = crate::test_utils::create_test_client().await;
let own_lid = Jid::from_str("888000888000888:1@lid").unwrap();
client
.persistence_manager
.process_command(crate::store::commands::DeviceCommand::SetLid(Some(
own_lid.clone(),
)))
.await;

let group = "120363000000000009@g.us";
let own_companion = Jid::from_str("888000888000888:5@lid").unwrap();
let member = Jid::from_str("111000111000111@lid").unwrap();

// A send marks its full target set warm (own companion + external member).
client
.update_sender_key_devices(group, &[own_companion.clone(), member.clone()])
.await;

// Persisted: the external member is warm; our own companion was skipped.
let rows = client
.persistence_manager
.get_sender_key_devices(group)
.await
.unwrap();
let map = SenderKeyDeviceMap::from_db_rows(&rows);
assert_eq!(
map.device_has_key("111000111000111", 0),
Some(true),
"external member is memoized warm"
);
assert_eq!(
map.device_has_key("888000888000888", 5),
None,
"own companion is never memoized (re-distributed every send)"
);

// Next send: only the own companion is re-targeted; the member stays warm.
let devices = [own_companion.clone(), member];
let needs = client.filter_skdm_targets(group, &devices, &map, &own_lid);
assert_eq!(
needs,
vec![own_companion],
"own companion redistributes; external member stays warm"
);
}

#[test]
fn test_skdm_filtering_large_group() {
use std::collections::HashSet;
Expand Down
13 changes: 7 additions & 6 deletions wacore/src/send/group.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,13 @@ where
/// tracking without re-resolving devices.
pub struct PreparedGroupStanza {
pub node: Node,
/// 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`).
/// Full SKDM distribution target set. After the server ACK the persist step
/// (`update_sender_key_devices`) marks `has_key=true`, mirroring WA Web
/// `markHasSenderKey(x, M)`: the whole target `M`, not only the devices that
/// encrypted successfully, so a failed external device (406 / no bundle) isn't
/// re-targeted every send (the retry-receipt path repairs any alive-but-keyless
/// one). Own devices are filtered out at persist time (WA Web `!isMeDevice`), so
/// own companions are never memoized and get a fresh SKDM every send.
pub skdm_devices: Vec<Jid>,
/// Users whose device registry should be invalidated because their
/// devices returned 406 (unregistered) during SKDM prekey fetch.
Expand Down
Loading