Skip to content
1 change: 1 addition & 0 deletions src/client/iq_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ impl Client {
self.ab_props
.apply_props(response.delta_update, response.experiment_props.into_iter())
.await;
self.latch_lid_migrated_from_props().await;

if let Some(new_hash) = response.hash {
self.persistence_manager
Expand Down
479 changes: 453 additions & 26 deletions src/client/lid_pn.rs

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions src/message/receive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1392,6 +1392,24 @@ impl Client {
}
}

// 1:1 LID-migration mappings are pushed by the primary to its own
// companions (WA Web HandleMsgProcess -> setLidMigrationMappings).
// Self-only: a peer could otherwise flip the account to LID addressing
// and poison the LID-PN cache.
if let Some(protocol_msg) = &msg.protocol_message
&& let Some(mapping_sync) = &protocol_msg.lid_migration_mapping_sync_message
{
if info.source.is_from_me {
self.handle_lid_migration_mapping_sync(mapping_sync).await;
} else {
warn!(
"[msg:{}] Dropping lid_migration_mapping_sync from non-self sender {}",
info.id,
info.source.sender.observe()
);
}
}

// PDO responses come from our own account (is_from_me) via device 0 (primary phone)
if info.source.is_from_me
&& let Some(protocol_msg) = &msg.protocol_message
Expand Down
69 changes: 69 additions & 0 deletions src/message/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7442,6 +7442,75 @@ async fn app_state_sync_key_share_honored_only_from_self() {
);
}

/// Security regression: the primary's `lid_migration_mapping_sync_message`
/// must be honoured only from self — a peer could otherwise poison the
/// LID-PN cache and flip the account to LID wire addressing.
#[tokio::test]
async fn lid_migration_mapping_sync_honored_only_from_self() {
use prost::Message as _;
use wacore::messages::MessageUtils;

let client = crate::test_utils::create_test_client().await;
ensure_bob_paired(&client).await;

let peer_pn = "5510000123456";
let peer_lid = "222000033334444";
let payload = wa::LidMigrationMappingSyncPayload {
pn_to_lid_mappings: vec![wa::LidMigrationMapping {
pn: peer_pn.parse().unwrap(),
assigned_lid: peer_lid.parse().unwrap(),
latest_lid: None,
}],
chat_db_migration_timestamp: None,
};
let sync = wa::Message {
protocol_message: Some(Box::new(wa::message::ProtocolMessage {
lid_migration_mapping_sync_message: Some(wa::LidMigrationMappingSyncMessage {
encoded_mapping_payload: Some(payload.encode_to_vec()),
}),
..Default::default()
})),
..Default::default()
};
let padded = MessageUtils::encode_and_pad(&sync);

// Non-self sender: the mapping push must be dropped.
let mut info =
create_test_message_info("5510000@s.whatsapp.net", "LMS1", "5510000@s.whatsapp.net");
info.source.is_from_me = false;
client
.clone()
.handle_decrypted_plaintext("msg", &padded, 2, &Arc::new(info))
.await
.unwrap();
assert!(
client.lid_pn_cache.get_current_lid(peer_pn).await.is_none(),
"mapping from a non-self sender must not be learned"
);

// Self sender: mappings are learned.
let mut info = create_test_message_info(
"9000000000000@s.whatsapp.net",
"LMS2",
"9000000000000@s.whatsapp.net",
);
info.source.is_from_me = true;
client
.clone()
.handle_decrypted_plaintext("msg", &padded, 2, &Arc::new(info))
.await
.unwrap();
assert_eq!(
client
.lid_pn_cache
.get_current_lid(peer_pn)
.await
.as_deref(),
Some(peer_lid),
"mapping from self must be learned"
);
}

// ---- msmsg inbound dispatch -----------------------------------------

fn find_message_nack_error(frames: &[bytes::Bytes], id: &str) -> Option<u32> {
Expand Down
24 changes: 24 additions & 0 deletions src/pair.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,30 @@ async fn handle_pair_success<'a>(
)))
.await;

// The primary reports whether the account is 1:1-LID-migrated via
// <client-props> (WA Web HandlePairSuccess -> setIsLidMigrated).
// This gates outbound DM wire addressing between LID and PN.
// Pairing a different account must not inherit the previous
// account's state, but a same-account relink whose pair-success
// omitted client-props must not lose it either.
let props_migrated = PairUtils::extract_pairing_props(success_node)
.is_some_and(|props| props.is_chat_db_lid_migrated());
let account_changed = device_snapshot
.pn
.as_ref()
.is_none_or(|prev| prev.user != jid.user);
if let Some(lid_migrated) =
PairUtils::lid_migrated_update(props_migrated, account_changed)
{
info!("Account 1:1-LID-migrated (pair-success client-props): {lid_migrated}");
client
.persistence_manager
.process_command(crate::store::commands::DeviceCommand::SetLidMigrated(
lid_migrated,
))
.await;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// A prior pairing's `server_has_prekeys=true` would make
// `upload_pre_keys_at_login` skip and leave the server bundle stale.
// Reset it so the next connect re-uploads, matching WA Web where a
Expand Down
186 changes: 158 additions & 28 deletions src/send/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1680,20 +1680,13 @@ impl Client {
// DM fanout: all known recipient devices + own companions.
// WAWebSendUserMsgJob reads local device table only on the send
// path; WAWebDBDeviceListFanout excludes hosted devices.
let recipient_bare = self.resolve_encryption_jid(&to).await.into_non_ad();
// The LID-vs-PN wire namespace is an account-level decision: the
// server 400-nacks LID-addressed DMs from accounts that are not
// 1:1-LID-migrated (issue #941).
let recipient_bare = self.resolve_dm_wire_jid(&to).await;
let recipient_is_lid = recipient_bare.is_lid();

// The outer `<message to>`, the DeviceSentMessage destinationJid, and
// the reporting-token remote jid must share the participants' namespace.
// WAWebSendMsgCreateFanoutStanza builds the whole stanza from one
// CHAT_JID, so for a LID-addressed peer the `to` is the resolved LID,
// not the caller's PN. A PN `to` over LID participants is rejected
// wholesale by the server with `ack error="400"`.
let stanza_to = if recipient_is_lid {
recipient_bare.clone()
} else {
to.clone()
};
let stanza_to = dm_stanza_to(&recipient_bare, &to);

// Local registry first; network warm only on miss to avoid
// unnecessary LID-migration side effects from get_user_devices
Expand Down Expand Up @@ -2012,11 +2005,46 @@ pub(crate) fn is_self_dm_recipient(
}
}

/// The outer `<message to>`, the DeviceSentMessage destinationJid, and the
/// reporting-token remote jid must share the participants' namespace.
/// WAWebSendMsgCreateFanoutStanza builds the whole stanza from one CHAT_JID
/// (always a bare user wid), so the `to` is the resolved wire jid whenever
/// the caller's namespace differs from it (LID upgrade, or PN downgrade on
/// an unmigrated account), and a device-qualified caller jid is normalized
/// to the bare chat jid. A `to` mixing namespaces with the participants is
/// rejected wholesale by the server with `ack error="400"`.
pub(crate) fn dm_stanza_to(recipient_bare: &Jid, to: &Jid) -> Jid {
if recipient_bare.is_lid() || to.is_lid() {
recipient_bare.clone()
} else {
to.to_non_ad()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;

#[test]
fn dm_stanza_to_follows_resolved_wire_namespace() {
let pn: Jid = "5511987650001@s.whatsapp.net".parse().unwrap();
let lid: Jid = "111000011112222@lid".parse().unwrap();

// PN caller, PN wire (unmigrated or unmapped): caller jid preserved.
assert_eq!(dm_stanza_to(&pn, &pn), pn);
// PN caller upgraded to LID wire: `to` must be the LID.
assert_eq!(dm_stanza_to(&lid, &pn), lid);
// LID caller kept on LID wire: unchanged.
assert_eq!(dm_stanza_to(&lid, &lid), lid);
// LID caller downgraded to PN wire (unmigrated account): `to` must be
// the PN — reusing the caller's LID would mix namespaces.
assert_eq!(dm_stanza_to(&pn, &lid), pn);
// Device-qualified caller jid is normalized to the bare chat jid.
let pn_device: Jid = "5511987650001:5@s.whatsapp.net".parse().unwrap();
assert_eq!(dm_stanza_to(&pn, &pn_device), pn);
}

#[test]
fn ensure_self_in_group_shares_when_present_and_appends_when_absent() {
use wacore::client::context::GroupInfo;
Expand Down Expand Up @@ -3898,21 +3926,17 @@ mod tests {
);
}

/// Regression for #730: a DM to a LID-mapped peer must address the outer
/// `<message to>` by LID, matching the LID `<participants>`. Pre-fix the
/// outer `to` kept the caller's PN, so a PN-to over LID participants was
/// rejected wholesale by the server with `ack error="400"` and never
/// delivered (while the send still returned Ok). WAWebSendMsgCreateFanoutStanza
/// builds the whole stanza from one CHAT_JID (the LID after migration).
#[tokio::test]
async fn dm_to_lid_mapped_peer_addresses_outer_to_by_lid() {
/// Shared setup for the DM wire-namespace regression tests: own PN/LID +
/// account, the peer's LID mapping, device-registry entries for both peer
/// namespaces and self, offline-sync completion, and a seeded Signal
/// session for the peer's LID device so the offline fanout can encrypt
/// without a socket. Returns `(peer_pn, peer_lid)`.
async fn seed_dm_wire_namespace_state(client: &Arc<Client>) -> (Jid, Jid) {
use wacore::libsignal::protocol::{
IdentityKeyPair, KeyPair, PreKeyBundle, SignalProtocolError, UsePQRatchet,
process_prekey_bundle,
};

let client = crate::test_utils::create_test_client_with_name("lid_dm_to").await;

// A LID-addressed DM requires the device's own PN and LID to be known.
let own_pn: Jid = "111111111111@s.whatsapp.net".parse().unwrap();
let own_lid: Jid = "222222222222@lid".parse().unwrap();
Expand All @@ -3929,7 +3953,8 @@ mod tests {
.process_command(DeviceCommand::SetAccount(Some(peer_test_account_proto())))
.await;

// The peer is LID-mapped: resolve_encryption_jid must switch PN to LID.
// The peer is LID-mapped: the wire namespace is then decided solely by
// the account's migration state.
let peer_pn: Jid = "100000000000777@s.whatsapp.net".parse().unwrap();
let peer_lid: Jid = "555000000000777@lid".parse().unwrap();
client
Expand All @@ -3941,10 +3966,15 @@ mod tests {
.await
.expect("seed lid mapping");

// Pre-seed the device registry for the peer (LID) and self (PN) so the
// offline send resolves the fanout from cache instead of blocking on a
// network device-list fetch (which would time out with no socket).
for user in [peer_lid.user.to_string(), own_pn.user.to_string()] {
// Pre-seed the device registry for the peer (both namespaces) and self
// so the offline send resolves the fanout from cache instead of
// blocking on a network device-list fetch (which would time out with
// no socket).
for user in [
peer_lid.user.to_string(),
peer_pn.user.to_string(),
own_pn.user.to_string(),
] {
client
.update_device_list(wacore::store::traits::DeviceListRecord {
user,
Expand All @@ -3965,7 +3995,9 @@ mod tests {
client.complete_offline_sync(0);

// Seed a Signal session for the peer's LID device so the offline fanout
// can encrypt without fetching prekeys over the (absent) socket.
// can encrypt without fetching prekeys over the (absent) socket. The
// session lives under the LID address in both tests: Signal addressing
// is LID-first regardless of the wire namespace (WAWebSignalAddress).
let lid_addr = peer_lid.to_non_ad();
let bundle =
tokio::task::spawn_blocking(|| -> Result<PreKeyBundle, SignalProtocolError> {
Expand Down Expand Up @@ -4004,6 +4036,27 @@ mod tests {
.expect("peer lid session");
}

(peer_pn, peer_lid)
}

/// Regression for #730: on a 1:1-LID-migrated account, a DM to a
/// LID-mapped peer must address the outer `<message to>` by LID, matching
/// the LID `<participants>`. Pre-fix the outer `to` kept the caller's PN,
/// so a PN-to over LID participants was rejected wholesale by the server
/// with `ack error="400"` and never delivered (while the send still
/// returned Ok). WAWebSendMsgCreateFanoutStanza builds the whole stanza
/// from one CHAT_JID (the LID after migration).
#[tokio::test]
async fn dm_to_lid_mapped_peer_addresses_outer_to_by_lid() {
let client = crate::test_utils::create_test_client_with_name("lid_dm_to").await;
let (peer_pn, peer_lid) = seed_dm_wire_namespace_state(&client).await;

// LID wire addressing is gated on the account being 1:1-LID-migrated.
client
.persistence_manager
.process_command(DeviceCommand::SetLidMigrated(true))
.await;

let request_id = "LID_DM_TO_1";
let waiter = client
.wait_for_sent_node(crate::client::NodeFilter::tag("message").attr("id", request_id));
Expand Down Expand Up @@ -4074,6 +4127,83 @@ mod tests {
}
}

/// Regression for #941: an account that is NOT 1:1-LID-migrated must keep
/// DM wire addressing on PN even with a cached LID mapping — the server
/// 400-nacks LID-addressed DMs from unmigrated accounts. WA Web only
/// addresses 1:1 chats by LID once `Lid1X1MigrationUtils.isLidMigrated()`.
#[tokio::test]
async fn dm_from_unmigrated_account_addresses_outer_to_by_pn() {
let client = crate::test_utils::create_test_client_with_name("pn_dm_to").await;
let (peer_pn, _peer_lid) = seed_dm_wire_namespace_state(&client).await;

let request_id = "PN_DM_TO_1";
let waiter = client
.wait_for_sent_node(crate::client::NodeFilter::tag("message").attr("id", request_id));
let msg = wa::Message {
conversation: Some("hi".into()),
..Default::default()
};
let result = client
.send_message_impl(
peer_pn.clone(),
&msg,
Some(request_id.to_string()),
false,
false,
None,
vec![],
None,
)
.await;
assert!(
result.is_err(),
"test client has no socket; send captures the stanza then errors"
);

let node = tokio::time::timeout(std::time::Duration::from_secs(1), waiter)
.await
.expect("sent node should be captured")
.expect("sent node waiter should resolve");

let to_str = node
.attrs()
.optional_string("to")
.expect("message has a to")
.into_owned();
let to_jid: Jid = to_str.parse().expect("to parses");
assert!(
to_jid.is_pn(),
"outer <message to> must stay PN on an unmigrated account, got {to_str}"
);
assert_eq!(
to_jid.user.as_str(),
peer_pn.user.as_str(),
"outer to user must be the peer PN"
);

// Uniformity guard: every <participants>/<to> is PN too (no mix).
let participants = node
.get_optional_child("participants")
.expect("stanza has participants");
let entries = participants.children().expect("participants has children");
assert!(
!entries.is_empty(),
"fanout must target at least the recipient"
);
for entry in entries {
let pj: Jid = entry
.attrs()
.optional_string("jid")
.expect("participant jid")
.parse()
.expect("participant jid parses");
assert!(
pj.is_pn(),
"participant {pj} must be PN (uniform namespace)"
);
}
}

/// Newsletter JIDs must be rejected at the E2E send path root (covers the
/// mis-routed pin/edit/revoke producers that call send_message_impl directly).
#[tokio::test]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE device DROP COLUMN lid_migrated;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE device ADD COLUMN lid_migrated BOOLEAN NOT NULL DEFAULT 0;
Loading
Loading