From f0a0ee4d159ba7c646d14172880880b28d50a01d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Apr 2026 17:16:31 -0300 Subject: [PATCH 1/4] fix(lid_pn): WA Web compliant signal address for Hosted JIDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolve_encryption_jid` previously fell through Hosted JIDs untouched, leaving Signal session keys at `{user}@hosted` while WA Web's `SignalAddress.toString()` (`WAWeb/Signal/Address.js`) keys them at `{lid_user}@hosted.lid` once a LID mapping is known. Same shape as the existing PN → LID upgrade — a single `lid_pn_cache` lookup mirrored across Hosted → HostedLid. Branches: Lid / HostedLid → already canonical, returned as-is. Pn → upgrade to Lid when mapping known. Hosted → upgrade to HostedLid when mapping known. Hot path is unchanged: PN/LID dispatch costs the same as before; the new Hosted branch is dormant for current send paths because they filter `is_hosted` out before reaching the resolver, but the logic is in place for any future hosted support and matches WA Web's intent. Also pins down the post-#391 wire encoding for direct-constructed Hosted/HostedLid JIDs in a regression test — `Jid::new(_, Hosted)` defaults `agent=0`, but `server_to_domain_type` derives the correct byte (128/129) from the server enum regardless. --- src/client/lid_pn.rs | 121 ++++++++++++++++++++++++++--------- wacore/binary/src/encoder.rs | 37 +++++++++++ 2 files changed, 127 insertions(+), 31 deletions(-) diff --git a/src/client/lid_pn.rs b/src/client/lid_pn.rs index 57c3c9799..35d2e79a8 100644 --- a/src/client/lid_pn.rs +++ b/src/client/lid_pn.rs @@ -295,42 +295,53 @@ impl Client { resolved } - /// Resolve the encryption JID for a given target JID. - /// This uses the same logic as the receiving path to ensure consistent - /// lock keys between sending and receiving. + /// Resolve a JID into the canonical form used for Signal session and + /// sender-key lookups. Mirrors WA Web's `SignalAddress.toString()` + /// (`WAWeb/Signal/Address.js`): /// - /// For PN JIDs, this checks if a LID mapping exists and returns the LID. - /// This ensures that sending and receiving use the same session lock. + /// - LID / HostedLid: already in canonical form, returned as-is. + /// - PN: upgraded to `{lid_user}@lid` when a mapping is known, kept as + /// `{user}@s.whatsapp.net` (mapped to `@c.us` at format time) otherwise. + /// - Hosted: upgraded to `{lid_user}@hosted.lid` when a mapping is known, + /// kept as `{user}@hosted` otherwise. WA Web hardcodes device=99 for + /// hosted; we preserve whatever device the input carries so a + /// misformed JID is still routable rather than silently dropped. pub(crate) async fn resolve_encryption_jid(&self, target: &Jid) -> Jid { - if target.is_lid() { - // Already a LID - use it directly - target.clone() - } else if target.is_pn() { - // PN JID - check if we have a LID mapping - if let Some(lid_user) = self.lid_pn_cache.get_current_lid(&target.user).await { - let lid_jid = Jid { - user: lid_user.into(), - server: wacore_binary::Server::Lid, - device: target.device, - agent: target.agent, - integrator: target.integrator, - }; - debug!( - "[SEND-LOCK] Resolved {} to LID {} for session lock", - target, lid_jid - ); - lid_jid - } else { - // No LID mapping - use PN as-is - debug!("[SEND-LOCK] No LID mapping for {}, using PN", target); - target.clone() - } - } else { - // Other server type - use as-is - target.clone() + use wacore_binary::Server; + match target.server { + Server::Lid | Server::HostedLid => target.clone(), + Server::Pn => self + .upgrade_to_lid_namespace(target, Server::Lid) + .await + .unwrap_or_else(|| { + debug!("[SEND-LOCK] No LID mapping for {target}, using PN"); + target.clone() + }), + Server::Hosted => self + .upgrade_to_lid_namespace(target, Server::HostedLid) + .await + .unwrap_or_else(|| target.clone()), + _ => target.clone(), } } + async fn upgrade_to_lid_namespace( + &self, + target: &Jid, + lid_server: wacore_binary::Server, + ) -> Option { + let lid_user = self.lid_pn_cache.get_current_lid(&target.user).await?; + let upgraded = Jid { + user: lid_user.into(), + server: lid_server, + device: target.device, + agent: target.agent, + integrator: target.integrator, + }; + debug!("[SEND-LOCK] Resolved {target} to {upgraded} for session lock"); + Some(upgraded) + } + /// Swap a JID's namespace between PN and LID, preserving device/agent/integrator. /// Returns `None` if no mapping exists or the JID is neither PN nor LID. pub(crate) async fn swap_pn_lid_namespace(&self, jid: &Jid) -> Option { @@ -549,6 +560,54 @@ mod tests { assert_eq!(resolved, pn_jid); } + /// WA Web `SignalAddress.toString()` for Hosted with known LID: + /// `[lid_user, ":99", "@hosted.lid"]`. + #[tokio::test] + async fn test_resolve_encryption_jid_hosted_with_lid_upgrades_to_hosted_lid() { + let client: Arc = create_test_client().await; + let user = "55999999999"; + let lid = "100000012345678"; + + client + .add_lid_pn_mapping(lid, user, LearningSource::PeerPnMessage) + .await + .unwrap(); + + let mut hosted = Jid::new(user, Server::Hosted); + hosted.device = 99; + let resolved = client.resolve_encryption_jid(&hosted).await; + + assert_eq!(resolved.user, lid); + assert_eq!(resolved.server, Server::HostedLid); + assert_eq!(resolved.device, 99); + } + + /// WA Web `SignalAddress.toString()` for Hosted without a known LID: + /// `[user, ":99", "@hosted"]` (no upgrade). + #[tokio::test] + async fn test_resolve_encryption_jid_hosted_no_mapping_keeps_hosted() { + let client: Arc = create_test_client().await; + let mut hosted = Jid::new("55999999999", Server::Hosted); + hosted.device = 99; + + let resolved = client.resolve_encryption_jid(&hosted).await; + + assert_eq!(resolved, hosted); + } + + /// HostedLid is already in the canonical (LID-keyed) form WA Web + /// uses internally, so resolution is a no-op. + #[tokio::test] + async fn test_resolve_encryption_jid_preserves_hosted_lid() { + let client: Arc = create_test_client().await; + let mut hosted_lid = Jid::new("100000012345678", Server::HostedLid); + hosted_lid.device = 99; + + let resolved = client.resolve_encryption_jid(&hosted_lid).await; + + assert_eq!(resolved, hosted_lid); + } + #[tokio::test] async fn test_get_lid_pn_entry_from_pn() { let client: Arc = create_test_client().await; diff --git a/wacore/binary/src/encoder.rs b/wacore/binary/src/encoder.rs index 8f77a3b93..9991c3a30 100644 --- a/wacore/binary/src/encoder.rs +++ b/wacore/binary/src/encoder.rs @@ -1430,6 +1430,43 @@ mod tests { Ok(()) } + /// Pre-#391, the encoder wrote `jid.agent` instead of the + /// server-derived `domain_type`. For directly constructed JIDs the + /// `agent` field defaults to 0, so any server other than `Pn` would + /// encode wrong. This test pins down the practical impact for Hosted + /// and HostedLid when they're built without going through the parser. + #[test] + fn test_direct_constructed_hosted_encodes_correct_domain_type() -> TestResult { + let mut hosted = Jid::new("100000000000001", jid::Server::Hosted); + hosted.device = 99; + assert_eq!( + hosted.agent, 0, + "default agent for direct construction is 0" + ); + + let mut hosted_lid = Jid::new("100000000000002", jid::Server::HostedLid); + hosted_lid.device = 99; + assert_eq!(hosted_lid.agent, 0); + + for (jid, expected) in [(&hosted, 128u8), (&hosted_lid, 129u8)] { + let node = NodeBuilder::new("to").attr("jid", jid.clone()).build(); + let mut buf = Vec::new(); + Encoder::new(Cursor::new(&mut buf))?.write_node(&node)?; + + let pos = buf + .iter() + .position(|&b| b == token::AD_JID) + .expect("AD_JID marker present"); + assert_eq!( + buf[pos + 1], + expected, + "direct-constructed {jid} must emit domain_type {expected} \ + (pre-#391 would have emitted agent=0)" + ); + } + Ok(()) + } + /// Regression test: strings at the PACKED_MAX boundary must be classified /// normally, while strings above it must be emitted as raw bytes (skipping /// SipHash/PHF classification entirely). From c5917edd465eef8540818cf3dd6253be9802d7d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Apr 2026 17:23:30 -0300 Subject: [PATCH 2/4] refactor(lid_pn): tighten resolve_encryption_jid dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single match maps Pn → Lid and Hosted → HostedLid; cache lookup runs once with target.clone() as the no-mapping fallback. Drops the upgrade_to_lid_namespace helper (one caller) and trims the doc to the WA Web reference. Same allocation profile (one Jid clone on the fallback path, one CompactString conversion on upgrade — both unavoidable given the Option return from lid_pn_cache). --- src/client/lid_pn.rs | 65 ++++++++++-------------------------- wacore/binary/src/encoder.rs | 7 ++-- 2 files changed, 19 insertions(+), 53 deletions(-) diff --git a/src/client/lid_pn.rs b/src/client/lid_pn.rs index 35d2e79a8..084743a9a 100644 --- a/src/client/lid_pn.rs +++ b/src/client/lid_pn.rs @@ -295,51 +295,26 @@ impl Client { resolved } - /// Resolve a JID into the canonical form used for Signal session and - /// sender-key lookups. Mirrors WA Web's `SignalAddress.toString()` - /// (`WAWeb/Signal/Address.js`): - /// - /// - LID / HostedLid: already in canonical form, returned as-is. - /// - PN: upgraded to `{lid_user}@lid` when a mapping is known, kept as - /// `{user}@s.whatsapp.net` (mapped to `@c.us` at format time) otherwise. - /// - Hosted: upgraded to `{lid_user}@hosted.lid` when a mapping is known, - /// kept as `{user}@hosted` otherwise. WA Web hardcodes device=99 for - /// hosted; we preserve whatever device the input carries so a - /// misformed JID is still routable rather than silently dropped. + /// Mirrors WA Web `SignalAddress.toString()` (`WAWeb/Signal/Address.js`): + /// upgrade Pn → Lid and Hosted → HostedLid when a mapping is known, else + /// preserve the input. pub(crate) async fn resolve_encryption_jid(&self, target: &Jid) -> Jid { use wacore_binary::Server; - match target.server { - Server::Lid | Server::HostedLid => target.clone(), - Server::Pn => self - .upgrade_to_lid_namespace(target, Server::Lid) - .await - .unwrap_or_else(|| { - debug!("[SEND-LOCK] No LID mapping for {target}, using PN"); - target.clone() - }), - Server::Hosted => self - .upgrade_to_lid_namespace(target, Server::HostedLid) - .await - .unwrap_or_else(|| target.clone()), - _ => target.clone(), - } - } - - async fn upgrade_to_lid_namespace( - &self, - target: &Jid, - lid_server: wacore_binary::Server, - ) -> Option { - let lid_user = self.lid_pn_cache.get_current_lid(&target.user).await?; - let upgraded = Jid { - user: lid_user.into(), - server: lid_server, - device: target.device, - agent: target.agent, - integrator: target.integrator, + let lid_server = match target.server { + Server::Pn => Server::Lid, + Server::Hosted => Server::HostedLid, + _ => return target.clone(), }; - debug!("[SEND-LOCK] Resolved {target} to {upgraded} for session lock"); - Some(upgraded) + match self.lid_pn_cache.get_current_lid(&target.user).await { + Some(lid_user) => Jid { + user: lid_user.into(), + server: lid_server, + device: target.device, + agent: target.agent, + integrator: target.integrator, + }, + None => target.clone(), + } } /// Swap a JID's namespace between PN and LID, preserving device/agent/integrator. @@ -560,8 +535,6 @@ mod tests { assert_eq!(resolved, pn_jid); } - /// WA Web `SignalAddress.toString()` for Hosted with known LID: - /// `[lid_user, ":99", "@hosted.lid"]`. #[tokio::test] async fn test_resolve_encryption_jid_hosted_with_lid_upgrades_to_hosted_lid() { let client: Arc = create_test_client().await; @@ -582,8 +555,6 @@ mod tests { assert_eq!(resolved.device, 99); } - /// WA Web `SignalAddress.toString()` for Hosted without a known LID: - /// `[user, ":99", "@hosted"]` (no upgrade). #[tokio::test] async fn test_resolve_encryption_jid_hosted_no_mapping_keeps_hosted() { let client: Arc = create_test_client().await; @@ -595,8 +566,6 @@ mod tests { assert_eq!(resolved, hosted); } - /// HostedLid is already in the canonical (LID-keyed) form WA Web - /// uses internally, so resolution is a no-op. #[tokio::test] async fn test_resolve_encryption_jid_preserves_hosted_lid() { let client: Arc = create_test_client().await; diff --git a/wacore/binary/src/encoder.rs b/wacore/binary/src/encoder.rs index 9991c3a30..52ff696bb 100644 --- a/wacore/binary/src/encoder.rs +++ b/wacore/binary/src/encoder.rs @@ -1430,11 +1430,8 @@ mod tests { Ok(()) } - /// Pre-#391, the encoder wrote `jid.agent` instead of the - /// server-derived `domain_type`. For directly constructed JIDs the - /// `agent` field defaults to 0, so any server other than `Pn` would - /// encode wrong. This test pins down the practical impact for Hosted - /// and HostedLid when they're built without going through the parser. + /// Pin domain_type for direct-constructed Hosted/HostedLid JIDs (default + /// `agent=0`); pre-#391 these encoded as `0` instead of `128`/`129`. #[test] fn test_direct_constructed_hosted_encodes_correct_domain_type() -> TestResult { let mut hosted = Jid::new("100000000000001", jid::Server::Hosted); From 1cbe5ad69cecc81fe97eeb8dca8b5bd50bb84bb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Apr 2026 17:25:30 -0300 Subject: [PATCH 3/4] test(lid_pn): cover non-99 hosted device in upgrade test Loop over [99, 7] in test_resolve_encryption_jid_hosted_with_lid_upgrades_to_hosted_lid to prove the input device is preserved, not coerced to 99. Same fixture, same upgrade path; the asymmetric value catches a regression that hardcodes :99 (which would still pass on the original :99 case). --- src/client/lid_pn.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/client/lid_pn.rs b/src/client/lid_pn.rs index 084743a9a..9e5683715 100644 --- a/src/client/lid_pn.rs +++ b/src/client/lid_pn.rs @@ -546,13 +546,18 @@ mod tests { .await .unwrap(); - let mut hosted = Jid::new(user, Server::Hosted); - hosted.device = 99; - let resolved = client.resolve_encryption_jid(&hosted).await; - - assert_eq!(resolved.user, lid); - assert_eq!(resolved.server, Server::HostedLid); - assert_eq!(resolved.device, 99); + for device in [99u16, 7] { + let mut hosted = Jid::new(user, Server::Hosted); + hosted.device = device; + let resolved = client.resolve_encryption_jid(&hosted).await; + + assert_eq!(resolved.user, lid); + assert_eq!(resolved.server, Server::HostedLid); + assert_eq!( + resolved.device, device, + "device must round-trip, not be coerced to 99" + ); + } } #[tokio::test] From 1ce12a0d2a36a711bc1d8618e693d19c26aa900e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 28 Apr 2026 17:31:27 -0300 Subject: [PATCH 4/4] test(lid_pn): lock full Jid contract on hosted upgrade Set non-default agent (0xAB) and integrator (0xBEEF) on the input and assert both round-trip alongside the existing user/server/device checks. Closes the gap where a regression that drops or rewrites these fields would slip through with all-zero defaults. --- src/client/lid_pn.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/client/lid_pn.rs b/src/client/lid_pn.rs index 9e5683715..569fc040d 100644 --- a/src/client/lid_pn.rs +++ b/src/client/lid_pn.rs @@ -549,6 +549,8 @@ mod tests { for device in [99u16, 7] { let mut hosted = Jid::new(user, Server::Hosted); hosted.device = device; + hosted.agent = 0xAB; + hosted.integrator = 0xBEEF; let resolved = client.resolve_encryption_jid(&hosted).await; assert_eq!(resolved.user, lid); @@ -557,6 +559,8 @@ mod tests { resolved.device, device, "device must round-trip, not be coerced to 99" ); + assert_eq!(resolved.agent, hosted.agent); + assert_eq!(resolved.integrator, hosted.integrator); } }