Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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: 29 additions & 0 deletions src/client/lid_pn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,35 @@ impl Client {
self.lid_pn_cache.add(&entry).await;
Ok(Some(entry))
}

/// Resolve any user JID to its bare LID form, or `None` when no LID is
/// available. Mirrors WA Web's `WAWebLidMigrationUtils.toUserLid`
/// (`docs/captured-js/WAWeb/Lid/MigrationUtils.js:17-20`): LID passes
/// through, PN goes through the cache-aside mapping, anything else and
/// any lookup failure returns `None`.
///
/// Used by `send_status_message` to replicate WA Web's
/// `compactMap(list, toUserLid)` skip-on-unresolvable semantics.
pub(crate) async fn resolve_recipient_to_lid(&self, jid: &Jid) -> Option<Jid> {
if jid.is_lid() {
return Some(jid.to_non_ad());
}
if !jid.is_pn() {
return None;
}
match self.get_lid_pn_entry(jid).await {
Ok(Some(entry)) => Some(Jid::new(entry.lid, wacore_binary::Server::Lid)),
Ok(None) => None,
Err(e) => {
log::warn!(
"resolve_recipient_to_lid: LID lookup for {} failed: {:?}",
jid,
e
);
None
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Propagate recipient mapping lookup failures

When get_lid_pn_entry fails (e.g., transient DB/backend outage), this path converts the error into None, and send_status_message treats that recipient as merely “unresolvable” and silently drops it. That means status sends can partially succeed while omitting valid recipients without surfacing an error to callers, which is especially risky for status revoke flows that must target the original audience. Lookup failures should be surfaced distinctly from a true “no mapping” miss.

Useful? React with 👍 / 👎.

}
}
}

#[cfg(test)]
Expand Down
70 changes: 30 additions & 40 deletions src/send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -292,8 +292,20 @@ impl Client {

/// Send a status/story update to the given recipients using sender key encryption.
///
/// This builds a `GroupInfo` from the provided recipients (always PN addressing mode),
/// then reuses the group encryption pipeline with `to = status@broadcast`.
/// Status messages use LID addressing, matching `WAWebEncryptAndSendStatusMsg`
/// (`docs/captured-js/WAWeb/Encrypt/AndSendStatusMsg.js:46`), which maps the
/// recipient list through `WAWebLidMigrationUtils.toUserLid` and filters
/// unresolvable entries via `compactMap`. Concretely:
///
/// - recipients already in LID form pass through;
/// - PN recipients are converted to LID via the cache-aside lookup in
/// `Client::get_lid_pn_entry` (warms from the backend on a cold cache);
/// - recipients we cannot resolve to a LID are skipped silently, not
/// errored, so a single unknown contact does not fail the whole send.
///
/// After resolution the list feeds a `GroupInfo` with `AddressingMode::Lid`,
/// which drives `prepare_group_stanza` to use `own_lid` for signing and emit
/// `addressing_mode="lid"` on the stanza.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
pub(crate) async fn send_status_message(
&self,
message: wa::Message,
Expand Down Expand Up @@ -321,53 +333,27 @@ impl Client {
.take()
.unwrap_or_else(|| own_jid.clone());

// Status always uses PN addressing. Resolve any LID recipients to their
// phone numbers so we don't end up with duplicate PN+LID entries for the
// same user (which causes server error 400).
// Reject non-user JIDs (groups, broadcasts, etc.) to prevent invalid
// <participants> entries that cause server errors.
let mut resolved_recipients = Vec::with_capacity(recipients.len());
// Reject non-user JIDs up-front (cheap guard; a programming bug, not
// something to skip silently).
for jid in recipients {
if jid.is_group() || jid.is_status_broadcast() || jid.is_broadcast_list() {
return Err(anyhow!(
"Invalid status recipient {}: must be a user JID, not a group/broadcast",
jid
));
}
if jid.is_lid() {
if let Some(pn) = self.lid_pn_cache.get_phone_number(&jid.user).await {
resolved_recipients.push(Jid::new(&pn, Server::Pn));
} else {
return Err(anyhow!(
"No PN mapping for LID {}. Ensure the recipient has been \
contacted previously.",
jid
));
}
} else {
resolved_recipients.push(jid.clone());
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

if resolved_recipients.is_empty() {
return Err(anyhow!("No valid PN recipients after LID resolution"));
// Resolve every recipient to its LID form (LID passes through; PN goes
// through the cache-aside lookup; everything else is skipped silently
// — matches WA Web `compactMap(list, toUserLid)`).
let mut resolved: Vec<Option<Jid>> = Vec::with_capacity(recipients.len());
for jid in recipients {
resolved.push(self.resolve_recipient_to_lid(jid).await);
}

// Deduplicate by user (in case both LID and PN were provided for the same user)
let mut seen_users = std::collections::HashSet::new();
resolved_recipients.retain(|jid| seen_users.insert(jid.user.clone()));

let mut group_info = GroupInfo::new(resolved_recipients, AddressingMode::Pn);

// Ensure we're in the participant list
let own_base = own_jid.to_non_ad();
if !group_info
.participants
.iter()
.any(|p| p.is_same_user_as(&own_base))
{
group_info.participants.push(own_base);
}
let participants = wacore::send::assemble_status_participants(resolved, &own_lid)?;
let mut group_info = GroupInfo::new(participants, AddressingMode::Lid);
Comment thread
jlucaso1 marked this conversation as resolved.
Outdated

self.add_recent_message(&to, &request_id, &message).await;

Expand All @@ -376,7 +362,11 @@ impl Client {

let force_skdm = {
use wacore::libsignal::store::sender_key_name::SenderKeyName;
let sender_address = own_jid.to_protocol_address();
// Sender key name tracks the addressing mode of the group stanza.
// Since status now uses LID addressing (see send_status_message
// header), the key is stored under own_lid, matching the address
// prepare_group_stanza derives internally.
let sender_address = own_lid.to_protocol_address();
let sender_key_name = SenderKeyName::from_parts(&to_str, sender_address.as_str());
Comment on lines +374 to 379

@coderabbitai coderabbitai Bot Apr 18, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Normalize the sender-key identity to the bare LID before building the key name.

Right now this keys sender-key state off the device-scoped own_lid. src/message.rs loads sender keys with info.source.sender.to_non_ad(), so this status path can write/look up user:device@lid while receive-side lookup uses user@lid. That mismatch will keep missing cached sender-key state and can break follow-up status decrypt/reuse. Use own_lid.to_non_ad().to_protocol_address() here, and make sure prepare_group_stanza is using the same bare form.

Suggested fix
         let force_skdm = {
             use wacore::libsignal::store::sender_key_name::SenderKeyName;
@@
-            let sender_address = own_lid.to_protocol_address();
+            let sender_address = own_lid.to_non_ad().to_protocol_address();
             let sender_key_name = SenderKeyName::from_parts(&to_str, sender_address.as_str());
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/send.rs` around lines 372 - 377, The sender-key lookup uses a
device-scoped own_lid but receiver-side expects a bare LID; change the
construction of the sender key so it normalizes own_lid to the non-device (bare)
LID before converting to protocol address: call
own_lid.to_non_ad().to_protocol_address() and use that result when creating the
SenderKeyName in the block that currently calls
SenderKeyName::from_parts(&to_str, sender_address.as_str()); also verify
prepare_group_stanza uses the same bare form (replace any use of
own_lid.to_protocol_address() with own_lid.to_non_ad().to_protocol_address()
where this status path builds sender keys) so both write and read use the same
user@lid identity.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked the statement "receiver-side expects bare LID" in docs/captured-js/WAWeb/Signal/Address.js:15-34 and Signal/CommonUtils.js:30-48:

toString = function () {

var t = this.wid.device != null && this.wid.device !== 0 ? ":" + this.wid.device : "";

...

return [u.user, t, "@lid"].join("");

}

WA Web includes :device in the name when device != 0, and createSignalLikeSenderKeyName uses this toString() + .0. It forms device-scoped, not bare.

The current code uses own_lid.to_protocol_address() both in the force_skdm check and within prepare_group_stanza (line 1054). Both match each other and match with WA Web. Changing to .to_non_ad().to_protocol_address() would break:

Parity with WA Web receivers (which expect device-scoped).

Consistency with the group send path, which already works with device-scoped.

Applying this finding would introduce a bug where there wasn't one. The reviewer started from an incorrect premise about what the receiver reads.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jlucaso1, you're right — I was wrong on the underlying premise. WA Web's SenderKeyName derivation is device-scoped: toString() includes :device when device !== 0, and createSignalLikeSenderKeyName appends .0 to that full device-scoped address. The current code using own_lid.to_protocol_address() (device-scoped) is consistent between the force-SKDM check and prepare_group_stanza, and matches what WA Web produces. My suggestion to normalize to .to_non_ad() would have broken that parity and introduced a real bug. I'm withdrawing this comment — the implementation is correct as-is.


✏️ Learnings added
Learnt from: jlucaso1
Repo: oxidezap/whatsapp-rust PR: 568
File: src/send.rs:374-379
Timestamp: 2026-04-18T16:30:22.140Z
Learning: In the whatsapp-rust codebase (src/send.rs), WA Web derives sender-key names using a device-scoped LID address, not a bare one. WA Web's Signal/Address.js toString() includes `:device` when device != 0, and createSignalLikeSenderKeyName appends `.0` to that full string. So the correct form for SenderKeyName construction is `own_lid.to_protocol_address()` (device-scoped), NOT `own_lid.to_non_ad().to_protocol_address()`. Both the force_skdm check in send_status_message and prepare_group_stanza use device-scoped own_lid consistently, matching WA Web behavior.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


let device_guard = device_store_arc.read().await;
Expand All @@ -396,7 +386,7 @@ impl Client {
let skdm_target_devices: Option<Vec<Jid>> = if force_skdm {
None
} else {
self.resolve_skdm_targets(&to_str, &group_info.participants, &own_jid)
self.resolve_skdm_targets(&to_str, &group_info.participants, &own_lid)
.await
Comment thread
jlucaso1 marked this conversation as resolved.
Outdated
};

Expand Down
120 changes: 120 additions & 0 deletions wacore/src/send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1424,6 +1424,41 @@ pub fn ensure_status_participants(
stanza
}

/// Dedup a pre-resolved status recipient list by user, then anchor the sender's
/// own LID. Returns `Err` if no resolvable recipient was provided, matching
/// WA Web's behaviour where `compactMap(list, toUserLid)` over an empty result
/// has nothing to send to.
///
/// Pure function: no allocations besides the returned `Vec` and (when needed)
/// pushing the caller's own LID. Dedup is a linear Vec scan — status lists
/// stay small enough that the HashSet overhead isn't worth it.
///
/// Callers (see `Client::send_status_message`) are expected to have already
/// mapped each PN → LID via `Client::get_lid_pn_entry` (cache-aside),
/// silently skipping unresolvable entries — that's the `Option` filter in
/// `Iterator<Item = Option<Jid>>`.
pub fn assemble_status_participants<I>(resolved: I, own_lid: &Jid) -> anyhow::Result<Vec<Jid>>
where
I: IntoIterator<Item = Option<Jid>>,
{
let iter = resolved.into_iter();
let (lower, _upper) = iter.size_hint();
let mut out: Vec<Jid> = Vec::with_capacity(lower.saturating_add(1));
for jid in iter.flatten() {
if !out.iter().any(|r| r.user == jid.user) {
out.push(jid);
}
}
if out.is_empty() {
anyhow::bail!("No valid status recipients after LID resolution");
}
let own_base = own_lid.to_non_ad();
if !out.iter().any(|r| r.user == own_base.user) {
out.push(own_base);
}
Ok(out)
}

/// Build a `Message.ProtocolMessage` for `GROUP_MEMBER_LABEL_CHANGE`.
///
/// Sent via the standard E2EE fanout, not an IQ. Empty `label` clears.
Expand All @@ -1450,6 +1485,91 @@ mod tests {
use std::collections::HashMap;
use wacore_binary::Jid;

mod assemble_status_participants {
use super::*;

fn lid(u: &str) -> Jid {
u.parse().expect("parse LID jid")
}

#[test]
fn dedup_keeps_first_entry_per_user_and_anchors_own() {
let own = lid("99999999999999@lid");
let out = assemble_status_participants(
vec![
Some(lid("111@lid")),
Some(lid("222@lid")),
Some(lid("111@lid")),
Some(lid("333@lid")),
],
&own,
)
.expect("should succeed");
let users: Vec<&str> = out.iter().map(|j| j.user.as_str()).collect();
assert_eq!(users, ["111", "222", "333", "99999999999999"]);
}

#[test]
fn skips_none_entries_matching_wa_web_compactmap() {
// Unresolvable recipients arrive as `None` and must be silently
// dropped — mirrors WA Web's `compactMap(list, toUserLid)`.
let own = lid("me@lid");
let out = assemble_status_participants(
vec![None, Some(lid("111@lid")), None, Some(lid("222@lid"))],
&own,
)
.expect("should succeed");
let users: Vec<&str> = out.iter().map(|j| j.user.as_str()).collect();
assert_eq!(users, ["111", "222", "me"]);
}

#[test]
fn does_not_duplicate_own_when_already_in_list() {
let own = lid("me@lid");
let out =
assemble_status_participants(vec![Some(lid("111@lid")), Some(lid("me@lid"))], &own)
.expect("should succeed");
let users: Vec<&str> = out.iter().map(|j| j.user.as_str()).collect();
assert_eq!(users, ["111", "me"]);
}

#[test]
fn errors_when_every_recipient_is_unresolvable() {
// Regression guard for the original bug: a single LID-only
// contact used to hard-abort the send with
// `No PN mapping for LID ...`. The new contract is softer —
// individual unresolvable entries are dropped — but we still
// refuse to send when the entire list came back empty, rather
// than silently broadcasting to own devices only.
let own = lid("me@lid");
let err = assemble_status_participants(vec![None, None, None], &own)
.expect_err("all-None list must error");
assert!(err.to_string().contains("No valid status recipients"));
}

#[test]
fn errors_when_list_is_empty() {
let own = lid("me@lid");
let err = assemble_status_participants(Vec::<Option<Jid>>::new(), &own)
.expect_err("empty list must error");
assert!(err.to_string().contains("No valid status recipients"));
}

#[test]
fn strips_device_suffix_from_own_lid() {
// Snapshot lid from the device store carries a device id; the
// participant list uses bare USER JIDs.
let own: Jid = "me:5@lid".parse().unwrap();
let out = assemble_status_participants(vec![Some(lid("111@lid"))], &own)
.expect("should succeed");
let me = out
.iter()
.find(|j| j.user.as_str() == "me")
.expect("own LID should be present");
assert_eq!(me.device, 0, "own LID should be non-ad (device=0)");
}
}

#[test]
fn build_member_label_message_sets_fields() {
let msg = build_member_label_message("VIP".to_string(), 1_766_847_151);
Expand Down
Loading