diff --git a/.github/scripts/bench-comment.py b/.github/scripts/bench-comment.py index 20444832e..21a23b08d 100644 --- a/.github/scripts/bench-comment.py +++ b/.github/scripts/bench-comment.py @@ -17,6 +17,8 @@ def load_baseline(data_js_path: str, bench_name: str) -> dict[str, int]: text = open(data_js_path).read() # data.js format: window.BENCHMARK_DATA = { ... }; json_str = re.sub(r"^window\.BENCHMARK_DATA\s*=\s*", "", text).rstrip().rstrip(";") + if not json_str.strip(): + return {} data = json.loads(json_str) baseline = {} diff --git a/.github/workflows/benchmark-comment.yml b/.github/workflows/benchmark-comment.yml index 8a8d44565..0c4f8811f 100644 --- a/.github/workflows/benchmark-comment.yml +++ b/.github/workflows/benchmark-comment.yml @@ -41,8 +41,8 @@ jobs: - name: Fetch baseline data id: baseline run: | - gh api repos/${{ github.repository }}/contents/dev/bench/data.js?ref=gh-pages \ - --jq '.content' | base64 -d > /tmp/baseline_data.js + gh api "repos/${{ github.repository }}/contents/dev/bench/data.js?ref=gh-pages" \ + -H "Accept: application/vnd.github.raw" > /tmp/baseline_data.js env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} continue-on-error: true diff --git a/src/appstate_sync.rs b/src/appstate_sync.rs index 9d7b59f6d..d784f6aec 100644 --- a/src/appstate_sync.rs +++ b/src/appstate_sync.rs @@ -20,7 +20,7 @@ mod tests { use wacore::store::error::Result as StoreResult; use wacore::store::traits::{ AppStateSyncKey, AppSyncStore, DeviceListRecord, DeviceStore, LidPnMappingEntry, - ProtocolStore, SignalStore, + MsgSecretStore, ProtocolStore, SignalStore, }; use waproto::whatsapp as wa; @@ -230,6 +230,33 @@ mod tests { } } + #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] + #[cfg_attr(not(target_arch = "wasm32"), async_trait)] + impl MsgSecretStore for MockBackend { + async fn put_msg_secret( + &self, + _chat: &str, + _sender: &str, + _msg_id: &str, + _secret: &[u8], + ) -> StoreResult<()> { + Ok(()) + } + + async fn get_msg_secret( + &self, + _chat: &str, + _sender: &str, + _msg_id: &str, + ) -> StoreResult>> { + Ok(None) + } + + async fn delete_expired_msg_secrets(&self, _cutoff: i64) -> StoreResult { + Ok(0) + } + } + // Implement DeviceStore - Device persistence #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] #[cfg_attr(not(target_arch = "wasm32"), async_trait)] diff --git a/src/cache_config.rs b/src/cache_config.rs index 9cbe5b6fd..72a4c03ba 100644 --- a/src/cache_config.rs +++ b/src/cache_config.rs @@ -194,6 +194,13 @@ pub struct CacheConfig { /// 0 = no automatic cleanup. Default: 300 (5 minutes). pub sent_message_ttl_secs: u64, + // --- MsgSecret retention --- + /// TTL in seconds for stored `messageSecret` rows before periodic + /// cleanup. `0` (default) disables automatic pruning, matching + /// whatsmeow and WA Web. Set to a positive value (e.g. `30 * 86_400` + /// for 30 days) to bound DB growth on long-running deployments. + pub msg_secret_ttl_secs: u64, + // --- Custom store overrides --- /// Per-cache custom store overrides. /// @@ -222,6 +229,7 @@ impl std::fmt::Debug for CacheConfig { .field("session_locks_capacity", &self.session_locks_capacity) .field("chat_lanes_capacity", &self.chat_lanes_capacity) .field("sent_message_ttl_secs", &self.sent_message_ttl_secs) + .field("msg_secret_ttl_secs", &self.msg_secret_ttl_secs) .field( "cache_stores.group_cache", &self.cache_stores.group_cache.is_some(), @@ -260,6 +268,10 @@ impl Default for CacheConfig { session_locks_capacity: 10_000, chat_lanes_capacity: 5_000, sent_message_ttl_secs: 300, + // Disabled by default — match whatsmeow and WA Web (neither + // expires stored secrets). Callers expecting long-lived bot + // conversations, polls, or reactions can opt in. + msg_secret_ttl_secs: 0, cache_stores: CacheStores::default(), } } diff --git a/src/client.rs b/src/client.rs index f0b418e7b..6d786912b 100644 --- a/src/client.rs +++ b/src/client.rs @@ -3992,7 +3992,7 @@ impl Client { .attrs([ ("id", id), ("type", type_str.to_string()), - ("to", own_jid.to_non_ad().to_string()), + ("to", own_jid.to_non_ad_string()), ]) .build(); diff --git a/src/features/message_edit.rs b/src/features/message_edit.rs index 4ceca0205..49dbc1232 100644 --- a/src/features/message_edit.rs +++ b/src/features/message_edit.rs @@ -54,8 +54,8 @@ pub fn decrypt( original_sender_jid: &Jid, editor_jid: &Jid, ) -> Result { - let primary_orig = original_sender_jid.to_non_ad().to_string(); - let primary_editor = editor_jid.to_non_ad().to_string(); + let primary_orig = original_sender_jid.to_non_ad_string(); + let primary_editor = editor_jid.to_non_ad_string(); let primary = MessageEditContext { original_msg_id, original_sender_jid: &primary_orig, @@ -291,8 +291,8 @@ pub fn decrypt_secret_encrypted( original_sender_jid: &Jid, modification_sender_jid: &Jid, ) -> Result { - let orig = original_sender_jid.to_non_ad().to_string(); - let sender = modification_sender_jid.to_non_ad().to_string(); + let orig = original_sender_jid.to_non_ad_string(); + let sender = modification_sender_jid.to_non_ad_string(); let ctx = MessageEditContext { original_msg_id, original_sender_jid: &orig, @@ -321,16 +321,16 @@ pub fn decrypt_secret_encrypted_with_fallback( fallback_original_sender: Option<&Jid>, fallback_modification_sender: Option<&Jid>, ) -> Result { - let orig = original_sender_jid.to_non_ad().to_string(); - let sender = modification_sender_jid.to_non_ad().to_string(); + let orig = original_sender_jid.to_non_ad_string(); + let sender = modification_sender_jid.to_non_ad_string(); let primary = MessageEditContext { original_msg_id, original_sender_jid: &orig, editor_jid: &sender, }; - let fb_orig = fallback_original_sender.map(|j| j.to_non_ad().to_string()); - let fb_sender = fallback_modification_sender.map(|j| j.to_non_ad().to_string()); + let fb_orig = fallback_original_sender.map(|j| j.to_non_ad_string()); + let fb_sender = fallback_modification_sender.map(|j| j.to_non_ad_string()); let fb_orig_resolved = fb_orig.as_deref().unwrap_or(primary.original_sender_jid); let fb_sender_resolved = fb_sender.as_deref().unwrap_or(primary.editor_jid); let fallback_ctx = if fb_orig_resolved == primary.original_sender_jid diff --git a/src/features/polls.rs b/src/features/polls.rs index 3c9f81fd5..85fbc4b02 100644 --- a/src/features/polls.rs +++ b/src/features/polls.rs @@ -124,7 +124,7 @@ impl<'a> Polls<'a> { .resolve_voter_jid(poll_creator_jid, &my_base, poll_msg_id) .await; let voter_jid_str = voter_jid.to_string(); - let creator_jid_str = poll_creator_jid.to_non_ad().to_string(); + let creator_jid_str = poll_creator_jid.to_non_ad_string(); let selected_hashes: Vec> = option_names .iter() @@ -232,7 +232,7 @@ impl<'a> Polls<'a> { self.client .swap_pn_lid_namespace(jid) .await - .map(|j| j.to_non_ad().to_string()) + .map(|j| j.to_non_ad_string()) } /// Fallback pair only when both JIDs have a counterpart, keeping it diff --git a/src/features/signal.rs b/src/features/signal.rs index ab397c6c4..056c20fad 100644 --- a/src/features/signal.rs +++ b/src/features/signal.rs @@ -85,6 +85,11 @@ impl<'a> Signal<'a> { EncType::SenderKey => { return Err(anyhow!("use decrypt_group_message for sender-key messages")); } + EncType::MessageSecret => { + return Err(anyhow!( + "msmsg envelopes are not Signal messages; use the bot_message path" + )); + } }; let encryption_jid = self.client.resolve_encryption_jid(jid).await; diff --git a/src/keepalive.rs b/src/keepalive.rs index 4d7186398..767721bbb 100644 --- a/src/keepalive.rs +++ b/src/keepalive.rs @@ -118,6 +118,15 @@ impl Client { return; } + // Periodic DB retention (~every 12 ticks ≈ 5 min). Driven by + // the interval tick itself, BEFORE the idle-ping early-return, + // so busy connections (which skip the ping) still prune. + cleanup_counter += 1; + if cleanup_counter >= 12 { + cleanup_counter = 0; + self.spawn_retention_cleanup(sent_msg_ttl); + } + let last_recv = self.last_data_received_ms.load(Ordering::Relaxed); // WA Web: maybeScheduleHealthCheck — only send ping when idle. @@ -147,20 +156,6 @@ impl Client { debug!(target: "Client/Keepalive", "Keepalive restored after {error_count} failure(s)."); } error_count = 0; - - // Periodic cleanup of expired sent messages (~every 12 ticks ≈ 5 min) - cleanup_counter += 1; - if sent_msg_ttl > 0 && cleanup_counter >= 12 { - cleanup_counter = 0; - let backend = self.persistence_manager.backend(); - let cutoff = wacore::time::now_secs() - - sent_msg_ttl as i64; - self.runtime.spawn(Box::pin(async move { - if let Err(e) = backend.delete_expired_sent_messages(cutoff).await { - log::debug!(target: "Client/Keepalive", "Sent message cleanup error: {e}"); - } - })).detach(); - } } KeepaliveResult::FatalFailure => { debug!(target: "Client/Keepalive", "Fatal keepalive failure, exiting loop."); @@ -198,6 +193,42 @@ impl Client { } } } + + /// Fire-and-forget DB retention sweeps. Each TTL gates its own delete so + /// they enable/disable independently. `0` disables a sweep. TTLs are + /// converted with a checked cast (absurd values clamp instead of wrapping + /// the cutoff negative). + fn spawn_retention_cleanup(&self, sent_msg_ttl: u64) { + let now = wacore::time::now_secs(); + let cutoff_for = |ttl: u64| now.saturating_sub(i64::try_from(ttl).unwrap_or(i64::MAX)); + + if sent_msg_ttl > 0 { + let backend = self.persistence_manager.backend(); + let cutoff = cutoff_for(sent_msg_ttl); + self.runtime + .spawn(Box::pin(async move { + if let Err(e) = backend.delete_expired_sent_messages(cutoff).await { + log::debug!(target: "Client/Keepalive", "Sent message cleanup error: {e}"); + } + })) + .detach(); + } + + // msg_secrets retention: disabled by default (matches whatsmeow + WA + // Web). Caller opts in via CacheConfig.msg_secret_ttl_secs. + let secret_ttl = self.cache_config.msg_secret_ttl_secs; + if secret_ttl > 0 { + let backend = self.persistence_manager.backend(); + let cutoff = cutoff_for(secret_ttl); + self.runtime + .spawn(Box::pin(async move { + if let Err(e) = backend.delete_expired_msg_secrets(cutoff).await { + log::debug!(target: "Client/Keepalive", "msg_secrets cleanup error: {e}"); + } + })) + .detach(); + } + } } #[cfg(test)] diff --git a/src/message.rs b/src/message.rs index 14b8ee0e7..3dd769a1c 100644 --- a/src/message.rs +++ b/src/message.rs @@ -65,6 +65,7 @@ pub(crate) struct ClassifiedMessage { pub sender_encryption_jid: Jid, pub session_payloads: Vec, pub group_payloads: Vec, + pub bot_payloads: Vec, pub max_sender_retry_count: u8, pub decrypt_fail_mode: crate::types::events::DecryptFailMode, } @@ -87,7 +88,7 @@ pub(crate) use wacore::protocol::retry::RetryReason; impl Client { /// Dispatches a successfully parsed message to the event bus and sends a delivery receipt. - fn dispatch_parsed_message(self: &Arc, msg: wa::Message, info: &Arc) { + async fn dispatch_parsed_message(self: &Arc, msg: wa::Message, info: &Arc) { use wacore::proto_helpers::MessageExt; let mut info = Arc::clone(info); @@ -98,6 +99,11 @@ impl Client { msg.get_base_message().get_ephemeral_expiration(); } + // Awaited (not spawned) so the messageSecret is durably stored before + // this chat's worker dequeues the next stanza. A bot reply queued right + // behind its own fanout (offline replay) would otherwise race the write + // and hit MissingMessageSecret. + self.maybe_capture_inbound_msg_secret(&msg, &info).await; self.ack_received_message(&info); self.core @@ -114,6 +120,16 @@ impl Client { if info.id.is_empty() || info.source.chat.is_newsletter() { return; } + // WA Web `sendAggregateReceipts`: for a DELIVERY where the chat is NOT + // a bot but the author IS a bot (a bot reply inside a group), it emits + // a bare `` via `sendBotInvokeResponseAcks`, not a + // ``. A 1:1 bot chat keeps the normal receipt (chat.isBot() → + // the branch's `v` is false). Our transport ack is that bare + // `` (group form carries `participant`). + if info.source.chat.server != wacore_binary::Server::Bot && info.source.sender.is_bot() { + self.spawn_message_ack(info); + return; + } if Self::should_send_delivery_receipt(info) { self.spawn_delivery_receipt(info); } else if !info.source.chat.is_status_broadcast() { @@ -130,6 +146,335 @@ impl Client { }); } + /// Capture an embedded `MessageContextInfo.message_secret` from any + /// bot-targeted message (fanout from us OR reply from the bot) so a + /// future `` referencing this id can decrypt. + /// Mirrors WA Web `processRenderableMessages`: + /// `$ && (P || N || w || A) && !isForwarded → addMsmsgMsgSecretToCache`. + pub(crate) async fn maybe_capture_inbound_msg_secret( + self: &Arc, + msg: &wa::Message, + info: &Arc, + ) { + use wacore::proto_helpers::MessageExt; + const SECRET_LEN: usize = wacore::reporting_token::MESSAGE_SECRET_SIZE; + + let mci = msg.message_context_info.as_ref(); + let chat_is_bot = info.source.chat.server == wacore_binary::Server::Bot; + let mentions_bot = msg.mentions_any_bot(); + // `MessageContextInfo.bot_metadata` is the bot-invocation envelope WA + // Web reads; it's present on bot prompts (incl. our own group prompt) + // even when no JID is mentioned, covering WA Web's `w`/`A` gates. + let has_bot_metadata = mci.is_some_and(|m| m.bot_metadata.is_some()); + + let Some(secret_bytes) = mci.and_then(|m| m.message_secret.as_deref()) else { + return; + }; + let Ok(secret_arr) = <&[u8; SECRET_LEN]>::try_from(secret_bytes) else { + return; + }; + // WA Web `processRenderableMessages`: `$ && (P || N || w || A) && !fwd`. + // P=chat is bot, N=mentions a bot, w/A=bot group participant (we proxy + // those via bot_metadata presence, the bot-invocation envelope). + if !chat_is_bot && !mentions_bot && !has_bot_metadata { + return; + } + if msg.is_forwarded() { + return; + } + // Key the secret under the identity the bot reply will echo in + // `` at GET time: + // * bot DM: `info.source.sender` is our PN device JID, but the reply + // echoes our LID — resolve via dm_sender_identity_for. + // * group / regular: `info.source.sender` is already the author's + // addressing identity (our LID in a LID group, the other + // participant's JID for their prompt) — exactly what the reply + // echoes. Use it directly. + // alternate_msg_secret_lookup still bridges any residual LID↔PN skew. + let sender = if chat_is_bot { + match self.dm_sender_identity_for(&info.source.chat).await { + Some(j) => j, + None => return, + } + } else { + info.source.sender.clone() + }; + log::debug!( + "[msg:{}] cached bot messageSecret under sender={}", + info.id, + sender.to_non_ad_string() + ); + self.persist_outbound_msg_secret(&info.source.chat, &sender, &info.id, secret_arr) + .await; + } + + /// Decrypt and dispatch a `` bot reply. Looks up the + /// outbound `messageSecret` we persisted at send time and runs the + /// dual-HKDF + AES-GCM open from [`wacore::bot_message`]. Failures + /// (missing secret, GCM tag fail, malformed proto) nack with code 495. + pub(crate) async fn handle_msmsg_payload( + self: &Arc, + info: &Arc, + payload: EncPayload, + ) { + use prost::Message as _; + use wa::MessageSecretMessage; + use wacore::bot_message::{BotMessageContext, decrypt_bot_message}; + use wacore::protocol::nack::NackReason; + + let ms_msg = match MessageSecretMessage::decode(&*payload.ciphertext) { + Ok(m) => m, + Err(e) => { + log::warn!( + "[msg:{}] failed to decode MessageSecretMessage: {e:?}", + info.id + ); + self.spawn_nack(info, NackReason::ParsingError, None); + return; + } + }; + let (Some(enc_iv), Some(enc_payload)) = + (ms_msg.enc_iv.as_deref(), ms_msg.enc_payload.as_deref()) + else { + log::warn!( + "[msg:{}] MessageSecretMessage missing enc_iv/enc_payload", + info.id + ); + self.spawn_nack(info, NackReason::ParsingError, None); + return; + }; + + // Target sender (us): meta echoes our LID/PN. Falls back to our LID + // when sender is on the bot server, our PN otherwise (whatsmeow + // `decryptBotMessage`). + let target_sender = match self.resolve_msmsg_target_sender(info).await { + Some(j) => j, + None => { + log::warn!("[msg:{}] msmsg: no target_sender resolvable", info.id); + self.spawn_nack(info, NackReason::MissingMessageSecret, None); + return; + } + }; + + // Chat scope for the secret lookup: prefer ; + // fall back to the stanza's chat (matches WA Web `decryptMsmsgBotMessage`). + let chat_for_lookup = info + .meta_info + .target_chat + .as_ref() + .unwrap_or(&info.source.chat) + .to_non_ad() + .to_string(); + let target_sender_str = target_sender.to_non_ad_string(); + + // The id used for the SECRET LOOKUP is `meta.target_id` (our outbound + // id); the id used as HKDF input is the bot reply id (or + // `bot_info.edit_target_id` when the bot is editing a prior reply). + let target_id = match info.meta_info.target_id.as_deref() { + Some(id) => id, + None => { + log::warn!( + "[msg:{}] msmsg: missing target_id; cannot look up secret", + info.id + ); + self.spawn_nack(info, NackReason::MissingMessageSecret, None); + return; + } + }; + + // Mirror WA Web `C()` in `WAWebBotMessageSecret.js`: primary lookup + // plus an alternate (PN ↔ LID swap via lid_pn_mapping) so a row + // stored under one identity family is still found if `` echoes the other. Covers LID migration windows + // and asymmetric outbound/inbound identities. + let backend = self.persistence_manager.backend(); + let primary = backend + .get_msg_secret(&chat_for_lookup, &target_sender_str, target_id) + .await; + let secret = match primary { + Ok(Some(s)) => s, + Ok(None) => match self + .alternate_msg_secret_lookup(&backend, &chat_for_lookup, &target_sender, target_id) + .await + { + Ok(Some(s)) => s, + Ok(None) => { + // For a group bot invocation initiated by our PRIMARY + // device, the messageSecret lives in the bot-addressed copy + // the primary sent directly to the bot — it is NOT mirrored + // to companions in the group skmsg. So a companion + // legitimately never holds the secret; this miss is expected + // and benign (we nack 495 and the server stops replaying). + // A miss in a 1:1 bot chat is unexpected and worth a warn. + log::log!( + if info.source.is_group { + log::Level::Debug + } else { + log::Level::Warn + }, + "[msg:{}] msmsg: no message_secret stored for target_id={target_id} (primary or alternate)", + info.id + ); + self.spawn_nack(info, NackReason::MissingMessageSecret, None); + return; + } + Err(e) => { + log::warn!( + "[msg:{}] msmsg: alternate lookup failed: {e:?}; nack 495", + info.id + ); + self.spawn_nack(info, NackReason::MissingMessageSecret, None); + return; + } + }, + Err(e) => { + log::warn!( + "[msg:{}] backend error reading message_secret ({e:?}); nack 495 so the server stops replaying", + info.id + ); + self.spawn_nack(info, NackReason::MissingMessageSecret, None); + return; + } + }; + + let bot_user_jid = info.source.sender.to_non_ad_string(); + // WA Web `decryptMsmsgBotMessage` dispatches on `isFbidBot()`: + // * fbid path pre-resolves to `edit_target_id` for INNER/LAST edits, + // `externalId` (info.id) otherwise. Single AES-GCM attempt. + // * regular path tries `externalId` first, falls back to + // `edit_target_id` on AES-GCM failure. + // We don't have `isFbidBot()` detection; instead, we unify the two as + // try-then-fallback with the fbid-style id as primary. That's a strict + // superset: for INNER/LAST it usually succeeds on the first try (fbid + // outcome); for any other case primary is `info.id` so we mirror the + // regular path's first attempt. The fallback is only attempted if + // `bot_info.edit_target_id` is present. + let info_id = info.id.as_str(); + let primary_msg_id = info + .bot_info + .as_ref() + .filter(|bi| { + matches!( + bi.edit_type, + Some( + crate::types::message::BotEditType::Inner + | crate::types::message::BotEditType::Last + ) + ) + }) + .and_then(|bi| bi.edit_target_id.as_deref()) + .unwrap_or(info_id); + let fallback_msg_id = if primary_msg_id == info_id { + info.bot_info + .as_ref() + .and_then(|bi| bi.edit_target_id.as_deref()) + } else { + Some(info_id) + } + .filter(|fb| *fb != primary_msg_id); + + let attempt = |msg_id: &str| { + let ctx = BotMessageContext { + msg_id, + target_sender_user_jid: &target_sender_str, + bot_user_jid: &bot_user_jid, + }; + decrypt_bot_message(&secret, enc_iv, enc_payload, &ctx) + }; + + let plaintext = match attempt(primary_msg_id) { + Ok(p) => p, + Err(primary_err) => match fallback_msg_id { + Some(fb) => match attempt(fb) { + Ok(p) => p, + Err(fallback_err) => { + log::warn!( + "[msg:{}] msmsg AES-GCM open failed both attempts (primary={primary_err:?}, fallback={fallback_err:?})", + info.id + ); + self.spawn_nack(info, NackReason::MissingMessageSecret, None); + return; + } + }, + None => { + log::warn!( + "[msg:{}] msmsg AES-GCM open failed and no fallback msg_id: {primary_err:?}", + info.id + ); + self.spawn_nack(info, NackReason::MissingMessageSecret, None); + return; + } + }, + }; + + let msg = match wa::Message::decode(plaintext.as_slice()) { + Ok(m) => m, + Err(e) => { + log::warn!( + "[msg:{}] msmsg plaintext is not a Message proto: {e:?}", + info.id + ); + self.spawn_nack(info, NackReason::ParsingError, None); + return; + } + }; + + log::info!( + "[msg:{}] Successfully decrypted msmsg bot reply from {}", + info.id, + info.source.sender + ); + self.dispatch_parsed_message(msg, info).await; + } + + /// Resolve `target_sender` for a msmsg stanza: echo from `` when + /// present, else fall back to our LID (sender on bot server) or PN. + async fn resolve_msmsg_target_sender(&self, info: &Arc) -> Option { + if let Some(ts) = info.meta_info.target_sender.as_ref() { + return Some(ts.clone()); + } + if info.source.sender.server == wacore_binary::Server::Bot { + self.get_lid().await + } else { + self.get_pn().await + } + } + + /// Second-chance lookup with the alternate identity family. Mirrors + /// `WAWebLidMigrationUtils.getAlternateMsgKey`: swap PN ↔ LID via the + /// `lid_pn_mapping` store and retry. Returns `Ok(None)` when no mapping + /// is known or the alternate row is absent — the caller treats that as + /// a terminal miss. + async fn alternate_msg_secret_lookup( + &self, + backend: &Arc, + chat_for_lookup: &str, + primary_sender: &Jid, + target_id: &str, + ) -> Result>, crate::store::error::StoreError> { + let alternate_user = match primary_sender.server { + wacore_binary::Server::Lid => backend + .get_lid_mapping(&primary_sender.user) + .await? + .map(|m| (m.phone_number, wacore_binary::Server::Pn)), + wacore_binary::Server::Pn => backend + .get_pn_mapping(&primary_sender.user) + .await? + .map(|m| (m.lid, wacore_binary::Server::Lid)), + _ => None, + }; + let Some((user, server)) = alternate_user else { + return Ok(None); + }; + let mut alternate_str = String::with_capacity(user.len() + 1 + server.as_str().len()); + alternate_str.push_str(&user); + alternate_str.push('@'); + alternate_str.push_str(server.as_str()); + backend + .get_msg_secret(chat_for_lookup, &alternate_str, target_id) + .await + } + /// Handles a newsletter plaintext message. /// Newsletters are not E2E encrypted and use the tag directly. async fn handle_newsletter_message( @@ -154,7 +499,7 @@ impl Client { info.id, info.source.chat ); - self.dispatch_parsed_message(msg, info); + self.dispatch_parsed_message(msg, info).await; } Err(e) => { log::warn!( @@ -502,6 +847,7 @@ impl Client { let mut session_payloads = Vec::with_capacity(all_enc_nodes.len()); let mut group_payloads = Vec::with_capacity(all_enc_nodes.len()); + let mut bot_payloads = Vec::with_capacity(all_enc_nodes.len()); let mut max_sender_retry_count: u8 = 0; let mut has_hide_fail = false; let mut had_unknown_enc = false; @@ -567,7 +913,7 @@ impl Client { } // `had_unknown_enc` means "produced no usable payload": either the - // type is unrecognized (msmsg) or it's known but the body is empty. + // type is unrecognized or it's known but the body is empty. // Either way the stanza needs the fallback ack or the server replays. if EncType::from_wire(enc_type.as_ref()).is_none() { log::warn!("Enc node has unknown type: {enc_type}"); @@ -584,7 +930,9 @@ impl Client { } }; - if payload.enc_type.is_session() { + if payload.enc_type.is_bot_secret() { + bot_payloads.push(payload); + } else if payload.enc_type.is_session() { session_payloads.push(payload); } else { group_payloads.push(payload); @@ -608,12 +956,14 @@ impl Client { ); } - // Unknown-only stanzas (e.g. msmsg from the Meta AI bot) would loop in - // the offline queue until <stream:error>. Custom handlers ack on their - // own; status is covered by should_ack. Ack from `nr` so `recipient` - // survives (parse_message_info drops it on non-self branches). + // Unknown-only stanzas would loop in the offline queue until + // <stream:error>. Custom handlers ack on their own; status is covered + // by should_ack. Ack from `nr` so `recipient` survives. Skip when any + // bucket has usable payloads (including msmsg) so the regular dispatch + // path runs and the valid enc still decrypts. if session_payloads.is_empty() && group_payloads.is_empty() + && bot_payloads.is_empty() && had_unknown_enc && !had_custom_handler { @@ -632,6 +982,7 @@ impl Client { sender_encryption_jid, session_payloads, group_payloads, + bot_payloads, max_sender_retry_count, decrypt_fail_mode: if has_hide_fail { crate::types::events::DecryptFailMode::Hide @@ -648,6 +999,7 @@ impl Client { sender_encryption_jid, session_payloads, group_payloads, + bot_payloads, max_sender_retry_count, decrypt_fail_mode, } = msg; @@ -844,6 +1196,13 @@ impl Client { self.ack_received_message(&info); } + // Bot-secret (msmsg) payloads run inline here so they're serialised + // with the session/group decrypt batches under the same global + // permit + per-chat enqueue lock acquired upstream. + for payload in bot_payloads { + self.handle_msmsg_payload(&info, payload).await; + } + // Flush cached Signal state to DB (matches WA Web's flushBufferToDiskIfNotMemOnlyMode) self.flush_signal_cache_logged("message", Some(&info.id)) .await; @@ -1579,7 +1938,7 @@ impl Client { info.id ); } else { - self.dispatch_parsed_message(msg, info); + self.dispatch_parsed_message(msg, info).await; } Ok(()) } @@ -7167,6 +7526,7 @@ mod tests { sender_encryption_jid: sender.clone(), session_payloads: vec![payload], group_payloads: vec![], + bot_payloads: vec![], max_sender_retry_count: 0, decrypt_fail_mode: crate::types::events::DecryptFailMode::Show, }) @@ -7295,7 +7655,7 @@ mod tests { .attr("id", "MSMSG1") .attr("type", "text") .children([NodeBuilder::new("enc") - .attr("type", "msmsg") + .attr("type", "frskmsg") .bytes(vec![0u8; 8]) .build()]) .build(); @@ -7328,7 +7688,7 @@ mod tests { .attr("id", "MSMSG_LID") .attr("type", "text") .children([NodeBuilder::new("enc") - .attr("type", "msmsg") + .attr("type", "frskmsg") .bytes(vec![0u8; 8]) .build()]) .build(); @@ -7389,7 +7749,7 @@ mod tests { .attr("type", "text") .attr("participant", "5511777776666@s.whatsapp.net") .children([NodeBuilder::new("enc") - .attr("type", "msmsg") + .attr("type", "frskmsg") .bytes(vec![0u8; 8]) .build()]) .build(); @@ -7424,7 +7784,7 @@ mod tests { .bytes(vec![0u8; 8]) .build(), NodeBuilder::new("enc") - .attr("type", "msmsg") + .attr("type", "frskmsg") .bytes(vec![0u8; 8]) .build(), ]) @@ -7470,14 +7830,14 @@ mod tests { .custom_enc_handlers .write() .await - .insert("msmsg".to_string(), handler as Arc<dyn EncHandler>); + .insert("frskmsg".to_string(), handler as Arc<dyn EncHandler>); let node = NodeBuilder::new("message") .attr("from", "5511777776666@s.whatsapp.net") .attr("id", "CUSTOM1") .attr("type", "text") .children([NodeBuilder::new("enc") - .attr("type", "msmsg") + .attr("type", "frskmsg") .bytes(vec![0u8; 8]) .build()]) .build(); @@ -7568,4 +7928,1642 @@ mod tests { "app-state sync key from self must be stored" ); } + + // ---- msmsg inbound dispatch ----------------------------------------- + + fn find_message_nack_error(frames: &[bytes::Bytes], id: &str) -> Option<u32> { + for (i, frame) in frames.iter().enumerate() { + let Some(buf) = decode_frame(i, frame) else { + continue; + }; + let Ok(node) = wacore_binary::marshal::unmarshal_ref(&buf[1..]) else { + continue; + }; + if node.tag.as_ref() == "ack" + && node + .get_attr("class") + .is_some_and(|v| v.as_str() == "message") + && node.get_attr("id").is_some_and(|v| v.as_str() == id) + && let Some(err) = node.get_attr("error") + && let Ok(code) = err.as_str().parse::<u32>() + { + return Some(code); + } + } + None + } + + fn encode_message_secret_message(iv: &[u8], payload: &[u8]) -> Vec<u8> { + use prost::Message as _; + let ms = wa::MessageSecretMessage { + version: Some(1), + enc_iv: Some(iv.to_vec()), + enc_payload: Some(payload.to_vec()), + }; + let mut out = Vec::with_capacity(ms.encoded_len()); + ms.encode(&mut out).expect("encode MessageSecretMessage"); + out + } + + async fn collect_event<F>( + client: &Arc<Client>, + collector: Arc<crate::test_utils::TestEventCollector>, + pred: F, + timeout_ms: u64, + ) -> Option<Arc<wacore::types::events::Event>> + where + F: Fn(&wacore::types::events::Event) -> bool, + { + let _ = client; + let mut waited = 0u64; + while waited <= timeout_ms { + for ev in collector.events() { + if pred(&ev) { + return Some(ev); + } + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + waited += 25; + } + None + } + + /// Round-trip: store an outbound messageSecret, build a fake bot reply + /// whose payload we encrypt with the symmetric helper, route it through + /// classify, and assert the decrypted `wa::Message` lands on the bus. + #[tokio::test] + async fn msmsg_decrypts_when_secret_is_stored() { + use wacore::bot_message::{BotMessageContext, encrypt_bot_message}; + let (client, _transport) = capturing_client("msmsg_ok").await; + let collector = Arc::new(crate::test_utils::TestEventCollector::default()); + client.register_handler(collector.clone()); + + let chat = "867051314767696@bot"; + let our_pn = "5511000000001@s.whatsapp.net"; + let bot_jid = "867051314767696@bot"; + let outbound_id = "OUTBOUND_1"; + let bot_reply_id = "BOT_REPLY_1"; + let secret = [0x42u8; 32]; + + client + .persistence_manager + .backend() + .put_msg_secret(chat, our_pn, outbound_id, &secret) + .await + .unwrap(); + + let plaintext_msg = wa::Message { + conversation: Some("hi from bot".to_string()), + ..Default::default() + }; + let pt_bytes = { + use prost::Message as _; + let mut v = Vec::with_capacity(plaintext_msg.encoded_len()); + plaintext_msg.encode(&mut v).unwrap(); + v + }; + let ctx = BotMessageContext { + msg_id: bot_reply_id, + target_sender_user_jid: our_pn, + bot_user_jid: chat, + }; + let (cipher, iv) = encrypt_bot_message(&pt_bytes, &secret, &ctx).unwrap(); + let ms_msg_proto = encode_message_secret_message(&iv, &cipher); + + let node = NodeBuilder::new("message") + .attr("from", bot_jid) + .attr("id", bot_reply_id) + .attr("type", "text") + .children([ + NodeBuilder::new("meta") + .attr("target_id", outbound_id) + .attr("target_sender_jid", our_pn) + .build(), + NodeBuilder::new("enc") + .attr("type", "msmsg") + .attr("v", "2") + .bytes(ms_msg_proto) + .build(), + ]) + .build(); + let owned = node_to_arc(node); + client.clone().handle_incoming_message(owned).await; + + let got = collect_event( + &client, + collector, + |e| { + matches!(e, wacore::types::events::Event::Message(msg, info) + if info.id == bot_reply_id + && msg.conversation.as_deref() == Some("hi from bot")) + }, + 1500, + ) + .await; + assert!( + got.is_some(), + "msmsg decryption + dispatch must surface Event::Message" + ); + } + + /// No secret stored for `target_id` → nack `error=495`, no Message event. + #[tokio::test] + async fn msmsg_without_stored_secret_nacks_495() { + let (client, transport) = capturing_client("msmsg_nosecret").await; + let collector = Arc::new(crate::test_utils::TestEventCollector::default()); + client.register_handler(collector.clone()); + + let bot_reply_id = "BOT_REPLY_NS"; + let outbound_id = "OUTBOUND_NS"; + let our_pn = "5511000000001@s.whatsapp.net"; + + let ms_msg_proto = encode_message_secret_message(&[0u8; 12], &[0u8; 32]); + let node = NodeBuilder::new("message") + .attr("from", "867051314767696@bot") + .attr("id", bot_reply_id) + .attr("type", "text") + .children([ + NodeBuilder::new("meta") + .attr("target_id", outbound_id) + .attr("target_sender_jid", our_pn) + .build(), + NodeBuilder::new("enc") + .attr("type", "msmsg") + .attr("v", "2") + .bytes(ms_msg_proto) + .build(), + ]) + .build(); + let owned = node_to_arc(node); + client.clone().handle_incoming_message(owned).await; + + let mut code = None; + for _ in 0..80 { + if let Some(c) = find_message_nack_error(&transport.sent(), bot_reply_id) { + code = Some(c); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + assert_eq!( + code, + Some(495), + "missing messageSecret must nack with code 495" + ); + assert!( + collector + .events() + .iter() + .all(|e| !matches!(e.as_ref(), wacore::types::events::Event::Message(_, info) if info.id == bot_reply_id)), + "no Message event must be dispatched when decryption failed" + ); + } + + /// Tampered ciphertext → GCM tag fails → nack 495. + #[tokio::test] + async fn msmsg_with_bad_tag_nacks_495() { + use wacore::bot_message::{BotMessageContext, encrypt_bot_message}; + let (client, transport) = capturing_client("msmsg_bad_tag").await; + + let chat = "867051314767696@bot"; + let our_pn = "5511000000001@s.whatsapp.net"; + let outbound_id = "OUTBOUND_BAD"; + let bot_reply_id = "BOT_REPLY_BAD"; + let secret = [0x77u8; 32]; + + client + .persistence_manager + .backend() + .put_msg_secret(chat, our_pn, outbound_id, &secret) + .await + .unwrap(); + + let ctx = BotMessageContext { + msg_id: bot_reply_id, + target_sender_user_jid: our_pn, + bot_user_jid: chat, + }; + let (mut cipher, iv) = encrypt_bot_message(b"hello", &secret, &ctx).unwrap(); + let last = cipher.len() - 1; + cipher[last] ^= 0x01; + let ms_msg_proto = encode_message_secret_message(&iv, &cipher); + + let node = NodeBuilder::new("message") + .attr("from", "867051314767696@bot") + .attr("id", bot_reply_id) + .attr("type", "text") + .children([ + NodeBuilder::new("meta") + .attr("target_id", outbound_id) + .attr("target_sender_jid", our_pn) + .build(), + NodeBuilder::new("enc") + .attr("type", "msmsg") + .attr("v", "2") + .bytes(ms_msg_proto) + .build(), + ]) + .build(); + let owned = node_to_arc(node); + client.clone().handle_incoming_message(owned).await; + + let mut code = None; + for _ in 0..80 { + if let Some(c) = find_message_nack_error(&transport.sent(), bot_reply_id) { + code = Some(c); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + assert_eq!(code, Some(495)); + } + + /// Bot edit chain: when `<bot edit="inner">` is set, the HKDF msg_id used + /// for the per-message key swaps to `edit_target_id` so the edited reply + /// decrypts under the same key as the original (whatsmeow / WA Web + /// `decryptMsmsgFbidBotMessage`). + #[tokio::test] + async fn msmsg_bot_edit_uses_edit_target_id_for_hkdf() { + use wacore::bot_message::{BotMessageContext, encrypt_bot_message}; + let (client, _transport) = capturing_client("msmsg_edit").await; + let collector = Arc::new(crate::test_utils::TestEventCollector::default()); + client.register_handler(collector.clone()); + + let chat = "867051314767696@bot"; + let our_pn = "5511000000001@s.whatsapp.net"; + let outbound_id = "OUTBOUND_EDIT"; + let original_reply_id = "BOT_REPLY_FIRST"; + let edit_reply_id = "BOT_REPLY_EDIT"; + let secret = [0xAAu8; 32]; + + client + .persistence_manager + .backend() + .put_msg_secret(chat, our_pn, outbound_id, &secret) + .await + .unwrap(); + + // Encrypt as if it's the ORIGINAL reply (msg_id = original_reply_id). + let plaintext_msg = wa::Message { + conversation: Some("edited content".to_string()), + ..Default::default() + }; + let pt_bytes = { + use prost::Message as _; + let mut v = Vec::with_capacity(plaintext_msg.encoded_len()); + plaintext_msg.encode(&mut v).unwrap(); + v + }; + let ctx = BotMessageContext { + msg_id: original_reply_id, + target_sender_user_jid: our_pn, + bot_user_jid: chat, + }; + let (cipher, iv) = encrypt_bot_message(&pt_bytes, &secret, &ctx).unwrap(); + let ms_msg_proto = encode_message_secret_message(&iv, &cipher); + + // Inbound stanza has id=edit_reply_id but <bot edit="inner" edit_target_id=original> + // so the HKDF must derive against original_reply_id. + let node = NodeBuilder::new("message") + .attr("from", "867051314767696@bot") + .attr("id", edit_reply_id) + .attr("type", "text") + .children([ + NodeBuilder::new("meta") + .attr("target_id", outbound_id) + .attr("target_sender_jid", our_pn) + .build(), + NodeBuilder::new("bot") + .attr("edit", "inner") + .attr("edit_target_id", original_reply_id) + .build(), + NodeBuilder::new("enc") + .attr("type", "msmsg") + .attr("v", "2") + .bytes(ms_msg_proto) + .build(), + ]) + .build(); + let owned = node_to_arc(node); + client.clone().handle_incoming_message(owned).await; + + let got = collect_event( + &client, + collector, + |e| { + matches!(e, wacore::types::events::Event::Message(msg, info) + if info.id == edit_reply_id + && msg.conversation.as_deref() == Some("edited content")) + }, + 1500, + ) + .await; + assert!( + got.is_some(), + "bot edit must use edit_target_id for HKDF msg_id" + ); + } + + /// Same setup as the edit test but WITHOUT `<bot edit>`: the HKDF must + /// fall back to `info.id`, and ciphertext encrypted with the edit-target + /// id must fail to decrypt. + #[tokio::test] + async fn msmsg_without_bot_edit_does_not_swap_msg_id() { + use wacore::bot_message::{BotMessageContext, encrypt_bot_message}; + let (client, transport) = capturing_client("msmsg_noedit").await; + let chat = "867051314767696@bot"; + let our_pn = "5511000000001@s.whatsapp.net"; + let outbound_id = "OUTBOUND_NOEDIT"; + let stanza_id = "BOT_REPLY_NOEDIT"; + let other_id = "OTHER_ID"; + let secret = [0xBBu8; 32]; + + client + .persistence_manager + .backend() + .put_msg_secret(chat, our_pn, outbound_id, &secret) + .await + .unwrap(); + + // Encrypt with `other_id` to simulate the wrong key derivation if the + // edit branch were taken without `<bot edit>`. + let ctx = BotMessageContext { + msg_id: other_id, + target_sender_user_jid: our_pn, + bot_user_jid: chat, + }; + let (cipher, iv) = encrypt_bot_message(b"x", &secret, &ctx).unwrap(); + let ms_msg_proto = encode_message_secret_message(&iv, &cipher); + let node = NodeBuilder::new("message") + .attr("from", "867051314767696@bot") + .attr("id", stanza_id) + .attr("type", "text") + .children([ + NodeBuilder::new("meta") + .attr("target_id", outbound_id) + .attr("target_sender_jid", our_pn) + .build(), + NodeBuilder::new("enc") + .attr("type", "msmsg") + .attr("v", "2") + .bytes(ms_msg_proto) + .build(), + ]) + .build(); + let owned = node_to_arc(node); + client.clone().handle_incoming_message(owned).await; + + let mut code = None; + for _ in 0..80 { + if let Some(c) = find_message_nack_error(&transport.sent(), stanza_id) { + code = Some(c); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + assert_eq!( + code, + Some(495), + "without <bot edit>, HKDF must use stanza id (not OTHER_ID) → tag fails" + ); + } + + /// `<bot edit="first">` is NOT one of {INNER, LAST}, so the HKDF msg_id + /// must remain `info.id`. + #[tokio::test] + async fn msmsg_bot_edit_first_keeps_info_id() { + use wacore::bot_message::{BotMessageContext, encrypt_bot_message}; + let (client, _transport) = capturing_client("msmsg_first").await; + let collector = Arc::new(crate::test_utils::TestEventCollector::default()); + client.register_handler(collector.clone()); + + let chat = "867051314767696@bot"; + let our_pn = "5511000000001@s.whatsapp.net"; + let outbound_id = "OUTBOUND_FIRST"; + let stanza_id = "BOT_REPLY_FIRST"; + let secret = [0xCCu8; 32]; + + client + .persistence_manager + .backend() + .put_msg_secret(chat, our_pn, outbound_id, &secret) + .await + .unwrap(); + + // Encrypt with stanza_id; "first" edit must NOT swap. + let plaintext_msg = wa::Message { + conversation: Some("first reply".to_string()), + ..Default::default() + }; + let pt_bytes = { + use prost::Message as _; + let mut v = Vec::with_capacity(plaintext_msg.encoded_len()); + plaintext_msg.encode(&mut v).unwrap(); + v + }; + let ctx = BotMessageContext { + msg_id: stanza_id, + target_sender_user_jid: our_pn, + bot_user_jid: chat, + }; + let (cipher, iv) = encrypt_bot_message(&pt_bytes, &secret, &ctx).unwrap(); + let ms_msg_proto = encode_message_secret_message(&iv, &cipher); + let node = NodeBuilder::new("message") + .attr("from", "867051314767696@bot") + .attr("id", stanza_id) + .attr("type", "text") + .children([ + NodeBuilder::new("meta") + .attr("target_id", outbound_id) + .attr("target_sender_jid", our_pn) + .build(), + NodeBuilder::new("bot").attr("edit", "first").build(), + NodeBuilder::new("enc") + .attr("type", "msmsg") + .attr("v", "2") + .bytes(ms_msg_proto) + .build(), + ]) + .build(); + let owned = node_to_arc(node); + client.clone().handle_incoming_message(owned).await; + + let got = collect_event( + &client, + collector, + |e| matches!(e, wacore::types::events::Event::Message(_, info) if info.id == stanza_id), + 1500, + ) + .await; + assert!(got.is_some(), "edit=first must keep info.id as HKDF msg_id"); + } + + /// Regular bot path (`f()` in WA Web `BotMessageSecret.js`): when the + /// fbid pre-resolve picks the WRONG id (e.g. edit_target_id) but the + /// real ciphertext was minted under `info.id`, the fallback attempt + /// must succeed. Validates the try-then-fallback unification. + #[tokio::test] + async fn msmsg_falls_back_to_info_id_when_primary_uses_edit_target() { + use wacore::bot_message::{BotMessageContext, encrypt_bot_message}; + let (client, _transport) = capturing_client("msmsg_fb_to_info").await; + let collector = Arc::new(crate::test_utils::TestEventCollector::default()); + client.register_handler(collector.clone()); + + let chat = "867051314767696@bot"; + let our_pn = "5511000000001@s.whatsapp.net"; + let outbound_id = "OUT_FB1"; + let stanza_id = "REPLY_FB1"; + let edit_target_id = "WRONG_EDIT_TARGET"; + let secret = [0xDDu8; 32]; + + client + .persistence_manager + .backend() + .put_msg_secret(chat, our_pn, outbound_id, &secret) + .await + .unwrap(); + + // Encrypt under `stanza_id` even though the stanza will declare + // edit=inner with edit_target_id (forces a primary-attempt mismatch). + let plaintext_msg = wa::Message { + conversation: Some("fallback ok".to_string()), + ..Default::default() + }; + let pt_bytes = { + use prost::Message as _; + let mut v = Vec::with_capacity(plaintext_msg.encoded_len()); + plaintext_msg.encode(&mut v).unwrap(); + v + }; + let ctx = BotMessageContext { + msg_id: stanza_id, + target_sender_user_jid: our_pn, + bot_user_jid: chat, + }; + let (cipher, iv) = encrypt_bot_message(&pt_bytes, &secret, &ctx).unwrap(); + let ms_msg_proto = encode_message_secret_message(&iv, &cipher); + + let node = NodeBuilder::new("message") + .attr("from", "867051314767696@bot") + .attr("id", stanza_id) + .attr("type", "text") + .children([ + NodeBuilder::new("meta") + .attr("target_id", outbound_id) + .attr("target_sender_jid", our_pn) + .build(), + NodeBuilder::new("bot") + .attr("edit", "inner") + .attr("edit_target_id", edit_target_id) + .build(), + NodeBuilder::new("enc") + .attr("type", "msmsg") + .attr("v", "2") + .bytes(ms_msg_proto) + .build(), + ]) + .build(); + let owned = node_to_arc(node); + client.clone().handle_incoming_message(owned).await; + + let got = collect_event( + &client, + collector, + |e| { + matches!(e, wacore::types::events::Event::Message(msg, info) + if info.id == stanza_id + && msg.conversation.as_deref() == Some("fallback ok")) + }, + 1500, + ) + .await; + assert!( + got.is_some(), + "primary attempt with edit_target_id must fall back to info.id" + ); + } + + /// Inverse of the `falls_back_to_info_id` test: primary is `info.id` + /// (edit_type isn't INNER/LAST so the fbid pre-resolve picks the stanza + /// id), but the bot encrypted under `edit_target_id`. The fallback must + /// rescue. + #[tokio::test] + async fn msmsg_falls_back_to_edit_target_when_primary_uses_info_id() { + use wacore::bot_message::{BotMessageContext, encrypt_bot_message}; + let (client, _transport) = capturing_client("msmsg_fb_to_edit").await; + let collector = Arc::new(crate::test_utils::TestEventCollector::default()); + client.register_handler(collector.clone()); + + let chat = "867051314767696@bot"; + let our_pn = "5511000000001@s.whatsapp.net"; + let outbound_id = "OUT_INV"; + let stanza_id = "REPLY_INV"; + let edit_target_id = "EDIT_INV"; + let secret = [0xBEu8; 32]; + + client + .persistence_manager + .backend() + .put_msg_secret(chat, our_pn, outbound_id, &secret) + .await + .unwrap(); + + let plaintext_msg = wa::Message { + conversation: Some("inverse fallback".to_string()), + ..Default::default() + }; + let pt_bytes = { + use prost::Message as _; + let mut v = Vec::with_capacity(plaintext_msg.encoded_len()); + plaintext_msg.encode(&mut v).unwrap(); + v + }; + // Encrypt under edit_target_id even though edit=first → primary + // will pick info.id (stanza id), fail, and the fallback should try + // edit_target_id and succeed (WA Web regular bot path `f()`). + let ctx = BotMessageContext { + msg_id: edit_target_id, + target_sender_user_jid: our_pn, + bot_user_jid: chat, + }; + let (cipher, iv) = encrypt_bot_message(&pt_bytes, &secret, &ctx).unwrap(); + let ms_msg_proto = encode_message_secret_message(&iv, &cipher); + + let node = NodeBuilder::new("message") + .attr("from", "867051314767696@bot") + .attr("id", stanza_id) + .attr("type", "text") + .children([ + NodeBuilder::new("meta") + .attr("target_id", outbound_id) + .attr("target_sender_jid", our_pn) + .build(), + NodeBuilder::new("bot") + .attr("edit", "first") + .attr("edit_target_id", edit_target_id) + .build(), + NodeBuilder::new("enc") + .attr("type", "msmsg") + .attr("v", "2") + .bytes(ms_msg_proto) + .build(), + ]) + .build(); + let owned = node_to_arc(node); + client.clone().handle_incoming_message(owned).await; + + let got = collect_event( + &client, + collector, + |e| { + matches!(e, wacore::types::events::Event::Message(msg, info) + if info.id == stanza_id + && msg.conversation.as_deref() == Some("inverse fallback")) + }, + 1500, + ) + .await; + assert!( + got.is_some(), + "primary attempt with info.id must fall back to edit_target_id (WA Web f())" + ); + } + + /// Mirror scenario: no `<bot edit>`, so the parser doesn't populate + /// `edit_target_id`. Primary uses `info.id`; with no fallback id + /// available, a deliberately-wrong-key payload must nack 495 (no second + /// attempt to silently mask the failure). + #[tokio::test] + async fn msmsg_no_fallback_when_no_edit_target_present() { + use wacore::bot_message::{BotMessageContext, encrypt_bot_message}; + let (client, transport) = capturing_client("msmsg_nofb").await; + let chat = "867051314767696@bot"; + let our_pn = "5511000000001@s.whatsapp.net"; + let outbound_id = "OUT_NOFB"; + let stanza_id = "REPLY_NOFB"; + let secret = [0xCCu8; 32]; + + client + .persistence_manager + .backend() + .put_msg_secret(chat, our_pn, outbound_id, &secret) + .await + .unwrap(); + + // Encrypt under a DIFFERENT id; no <bot> node so parser leaves + // edit_target_id = None and there's nothing to fall back to. + let ctx = BotMessageContext { + msg_id: "MISMATCHED_ID", + target_sender_user_jid: our_pn, + bot_user_jid: chat, + }; + let (cipher, iv) = encrypt_bot_message(b"x", &secret, &ctx).unwrap(); + let ms_msg_proto = encode_message_secret_message(&iv, &cipher); + let node = NodeBuilder::new("message") + .attr("from", "867051314767696@bot") + .attr("id", stanza_id) + .attr("type", "text") + .children([ + NodeBuilder::new("meta") + .attr("target_id", outbound_id) + .attr("target_sender_jid", our_pn) + .build(), + NodeBuilder::new("enc") + .attr("type", "msmsg") + .attr("v", "2") + .bytes(ms_msg_proto) + .build(), + ]) + .build(); + let owned = node_to_arc(node); + client.clone().handle_incoming_message(owned).await; + + let mut code = None; + for _ in 0..80 { + if let Some(c) = find_message_nack_error(&transport.sent(), stanza_id) { + code = Some(c); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + assert_eq!( + code, + Some(495), + "no fallback id available → single AES-GCM failure must nack 495" + ); + } + + /// WA Web `processRenderableMessages` captures the embedded + /// `messageSecret` from any bot-targeted msg (fanout from us OR reply + /// from the bot). Verify the helper persists it under + /// (bot_chat, our_lid, info.id). + #[tokio::test] + async fn maybe_capture_inbound_msg_secret_persists_for_bot_chats() { + use crate::store::commands::DeviceCommand; + let (client, _transport) = capturing_client("capture_bot").await; + client + .persistence_manager + .process_command(DeviceCommand::SetLid(Some( + "999888777666555:0@lid".parse().unwrap(), + ))) + .await; + + let info = Arc::new(MessageInfo { + id: "FANOUT_1".into(), + source: crate::types::message::MessageSource { + chat: "867051314767696@bot".parse().unwrap(), + sender: "5511000000001:0@s.whatsapp.net".parse().unwrap(), + is_from_me: true, + ..Default::default() + }, + ..Default::default() + }); + let msg = wa::Message { + conversation: Some("hi bot".into()), + message_context_info: Some(wa::MessageContextInfo { + message_secret: Some(vec![0xAB; 32]), + ..Default::default() + }), + ..Default::default() + }; + client.maybe_capture_inbound_msg_secret(&msg, &info).await; + + let mut got = None; + for _ in 0..40 { + got = client + .persistence_manager + .backend() + .get_msg_secret("867051314767696@bot", "999888777666555@lid", "FANOUT_1") + .await + .unwrap(); + if got.is_some() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + assert_eq!(got.as_deref(), Some(&[0xABu8; 32][..])); + } + + #[tokio::test] + async fn maybe_capture_inbound_msg_secret_skips_non_bot_chats() { + let (client, _transport) = capturing_client("capture_skip_dm").await; + let info = Arc::new(MessageInfo { + id: "DM_1".into(), + source: crate::types::message::MessageSource { + chat: "5511777776666@s.whatsapp.net".parse().unwrap(), + sender: "5511000000001:0@s.whatsapp.net".parse().unwrap(), + is_from_me: true, + ..Default::default() + }, + ..Default::default() + }); + let msg = wa::Message { + conversation: Some("hi".into()), + message_context_info: Some(wa::MessageContextInfo { + message_secret: Some(vec![0xCD; 32]), + ..Default::default() + }), + ..Default::default() + }; + client.maybe_capture_inbound_msg_secret(&msg, &info).await; + + for _ in 0..16 { + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + let got = client + .persistence_manager + .backend() + .get_msg_secret( + "5511777776666@s.whatsapp.net", + "5511000000001@s.whatsapp.net", + "DM_1", + ) + .await + .unwrap(); + assert!( + got.is_none(), + "non-bot chats must not persist embedded msg_secrets" + ); + } + + /// Group invocation: user mentions @MetaAI in a group → chat is the + /// GROUP (not bot), but mentioned_jid contains the bot. WA Web's + /// `processRenderableMessages` keys off `N` (invokedBotWid derived from + /// `mentionedJidList.find(isBot)`); we must persist too. + #[tokio::test] + async fn maybe_capture_inbound_msg_secret_persists_for_group_with_bot_mention() { + use crate::store::commands::DeviceCommand; + let (client, _transport) = capturing_client("capture_group_mention").await; + client + .persistence_manager + .process_command(DeviceCommand::SetLid(Some( + "999888777666555:0@lid".parse().unwrap(), + ))) + .await; + + let info = Arc::new(MessageInfo { + id: "GRP_MENTION".into(), + source: crate::types::message::MessageSource { + chat: "120363021033254949@g.us".parse().unwrap(), + sender: "5511000000001:0@s.whatsapp.net".parse().unwrap(), + is_from_me: true, + is_group: true, + ..Default::default() + }, + ..Default::default() + }); + let msg = wa::Message { + extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + text: Some("hey @MetaAI tell me a joke".into()), + context_info: Some(Box::new(wa::ContextInfo { + mentioned_jid: vec!["867051314767696@bot".into()], + ..Default::default() + })), + ..Default::default() + })), + message_context_info: Some(wa::MessageContextInfo { + message_secret: Some(vec![0xEE; 32]), + ..Default::default() + }), + ..Default::default() + }; + client.maybe_capture_inbound_msg_secret(&msg, &info).await; + + let mut got = None; + for _ in 0..40 { + got = client + .persistence_manager + .backend() + .get_msg_secret( + "120363021033254949@g.us", + "5511000000001@s.whatsapp.net", + "GRP_MENTION", + ) + .await + .unwrap(); + if got.is_some() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + assert_eq!( + got.as_deref(), + Some(&[0xEEu8; 32][..]), + "group invocation via @bot mention must still cache the secret" + ); + } + + /// Forwarded message with a secret must NOT be cached — matches WA Web's + /// `x.isForwarded !== true` guard. A planted forward shouldn't poison + /// the cache. + #[tokio::test] + async fn maybe_capture_inbound_msg_secret_skips_forwarded() { + let (client, _transport) = capturing_client("capture_skip_forwarded").await; + let info = Arc::new(MessageInfo { + id: "FWD_1".into(), + source: crate::types::message::MessageSource { + chat: "867051314767696@bot".parse().unwrap(), + sender: "5511000000001:0@s.whatsapp.net".parse().unwrap(), + is_from_me: false, + ..Default::default() + }, + ..Default::default() + }); + let msg = wa::Message { + extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + text: Some("forwarded".into()), + context_info: Some(Box::new(wa::ContextInfo { + is_forwarded: Some(true), + ..Default::default() + })), + ..Default::default() + })), + message_context_info: Some(wa::MessageContextInfo { + message_secret: Some(vec![0xFF; 32]), + ..Default::default() + }), + ..Default::default() + }; + client.maybe_capture_inbound_msg_secret(&msg, &info).await; + + for _ in 0..16 { + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + let got = client + .persistence_manager + .backend() + .get_msg_secret( + "867051314767696@bot", + "5511000000001@s.whatsapp.net", + "FWD_1", + ) + .await + .unwrap(); + assert!(got.is_none(), "forwarded messages must not seed the cache"); + } + + /// Our own group bot prompt carries the secret but NO mentioned_jid + /// (observed in prod: `mentions_bot=false mentioned_jids=[]`). The bot + /// invocation is signalled by `message_context_info.bot_metadata`, which + /// must let the capture fire (WA Web's `w`/`A` group-participant gate). + #[tokio::test] + async fn maybe_capture_inbound_msg_secret_via_bot_metadata_without_mention() { + let (client, _transport) = capturing_client("capture_bot_meta").await; + client + .persistence_manager + .process_command(crate::store::commands::DeviceCommand::SetLid(Some( + "999888777666555:0@lid".parse().unwrap(), + ))) + .await; + let info = Arc::new(MessageInfo { + id: "GRP_OWN_BOT".into(), + source: crate::types::message::MessageSource { + chat: "120363021033254949@g.us".parse().unwrap(), + sender: "236395184570386:0@lid".parse().unwrap(), + is_from_me: true, + is_group: true, + ..Default::default() + }, + ..Default::default() + }); + let msg = wa::Message { + extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + text: Some("continue".into()), + // No mention at all — just bot_metadata signals the invocation. + ..Default::default() + })), + message_context_info: Some(wa::MessageContextInfo { + message_secret: Some(vec![0x7B; 32]), + bot_metadata: Some(wa::BotMetadata { + persona_id: Some("867051314767696".into()), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }; + client.maybe_capture_inbound_msg_secret(&msg, &info).await; + + // Group (non-bot chat) → keyed under info.source.sender (our LID in a + // LID group), which is what the bot reply's target_sender_jid echoes. + let got = client + .persistence_manager + .backend() + .get_msg_secret( + "120363021033254949@g.us", + "236395184570386@lid", + "GRP_OWN_BOT", + ) + .await + .unwrap(); + assert_eq!( + got.as_deref(), + Some(&[0x7Bu8; 32][..]), + "bot_metadata presence must let our own group prompt cache without a mention" + ); + } + + /// Group flow: another participant invokes the bot, their decrypted prompt + /// carries the secret. We must key it under THE PARTICIPANT (the future + /// reply's `<meta target_sender_jid>`), not our own identity. + #[tokio::test] + async fn maybe_capture_inbound_msg_secret_keys_under_other_participant() { + let (client, _transport) = capturing_client("capture_participant").await; + let participant = "5599111112222:7@s.whatsapp.net"; + let info = Arc::new(MessageInfo { + id: "GRP_OTHER".into(), + source: crate::types::message::MessageSource { + chat: "120363021033254949@g.us".parse().unwrap(), + sender: participant.parse().unwrap(), + is_from_me: false, + is_group: true, + ..Default::default() + }, + ..Default::default() + }); + let msg = wa::Message { + extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + text: Some("@MetaAI question".into()), + context_info: Some(Box::new(wa::ContextInfo { + mentioned_jid: vec!["867051314767696@bot".into()], + ..Default::default() + })), + ..Default::default() + })), + message_context_info: Some(wa::MessageContextInfo { + message_secret: Some(vec![0x5A; 32]), + ..Default::default() + }), + ..Default::default() + }; + client.maybe_capture_inbound_msg_secret(&msg, &info).await; + + // Keyed under the participant (non-AD), NOT under our own PN/LID. + let under_participant = client + .persistence_manager + .backend() + .get_msg_secret( + "120363021033254949@g.us", + "5599111112222@s.whatsapp.net", + "GRP_OTHER", + ) + .await + .unwrap(); + assert_eq!( + under_participant.as_deref(), + Some(&[0x5Au8; 32][..]), + "another participant's prompt must key under their sender JID" + ); + } + + /// WA Web `sendAggregateReceipts`: a bot reply in a GROUP (chat not bot, + /// author is bot) must ack with a bare `<ack class="message">` + /// (sendBotInvokeResponseAcks), NOT a `<receipt>`. + #[tokio::test] + async fn bot_reply_in_group_acks_with_bare_ack_not_receipt() { + let (client, transport) = capturing_client("bot_group_ack").await; + let info = Arc::new(MessageInfo { + id: "BOT_GRP_ACK".into(), + source: crate::types::message::MessageSource { + chat: "120363021033254949@g.us".parse().unwrap(), + sender: "867051314767696@bot".parse().unwrap(), + is_from_me: false, + is_group: true, + ..Default::default() + }, + ..Default::default() + }); + client.ack_received_message(&info); + + let mut found = None; + for _ in 0..80 { + if let Some(a) = find_message_ack(&transport.sent()) { + found = Some(a); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + assert!( + found.is_some(), + "group bot reply must emit a bare <ack class=\"message\">" + ); + assert_eq!( + delivery_receipts_for(&transport.sent(), "BOT_GRP_ACK"), + 0, + "group bot reply must NOT emit a <receipt>" + ); + } + + /// Regression: a 1:1 bot chat (chat IS the bot) keeps the normal delivery + /// `<receipt>` — WA Web's `v` gate is false when chat.isBot(). + #[tokio::test] + async fn bot_dm_reply_keeps_delivery_receipt() { + let (client, transport) = capturing_client("bot_dm_receipt").await; + let info = Arc::new(MessageInfo { + id: "BOT_DM_RCPT".into(), + source: crate::types::message::MessageSource { + chat: "867051314767696@bot".parse().unwrap(), + sender: "867051314767696@bot".parse().unwrap(), + is_from_me: false, + ..Default::default() + }, + ..Default::default() + }); + client.ack_received_message(&info); + + let mut count = 0; + for _ in 0..80 { + count = delivery_receipts_for(&transport.sent(), "BOT_DM_RCPT"); + if count > 0 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + assert_eq!( + count, 1, + "1:1 bot chat must keep the normal delivery receipt" + ); + } + + #[tokio::test] + async fn maybe_capture_inbound_msg_secret_skips_when_secret_absent() { + let (client, _transport) = capturing_client("capture_no_secret").await; + let info = Arc::new(MessageInfo { + id: "NO_SECRET".into(), + source: crate::types::message::MessageSource { + chat: "867051314767696@bot".parse().unwrap(), + sender: "5511000000001:0@s.whatsapp.net".parse().unwrap(), + is_from_me: true, + ..Default::default() + }, + ..Default::default() + }); + let msg = wa::Message { + conversation: Some("hi".into()), + ..Default::default() + }; + client.maybe_capture_inbound_msg_secret(&msg, &info).await; + + for _ in 0..16 { + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + let got = client + .persistence_manager + .backend() + .get_msg_secret( + "867051314767696@bot", + "5511000000001@s.whatsapp.net", + "NO_SECRET", + ) + .await + .unwrap(); + assert!(got.is_none()); + } + + /// A stanza carrying BOTH a valid msmsg AND an unknown sibling enc must + /// still dispatch the msmsg — the unknown-only fallback ack must not + /// short-circuit when `bot_payloads` is non-empty. + #[tokio::test] + async fn mixed_msmsg_and_unknown_enc_still_decrypts_msmsg() { + use crate::store::commands::DeviceCommand; + use wacore::bot_message::{BotMessageContext, encrypt_bot_message}; + + let (client, _transport) = capturing_client("msmsg_mixed_unknown").await; + client + .persistence_manager + .process_command(DeviceCommand::SetLid(Some( + "999888777666555:0@lid".parse().unwrap(), + ))) + .await; + let collector = Arc::new(crate::test_utils::TestEventCollector::default()); + client.register_handler(collector.clone()); + + let chat = "867051314767696@bot"; + let our_lid = "999888777666555@lid"; + let outbound_id = "OUT_MIX"; + let bot_reply_id = "REPLY_MIX"; + let secret = [0x55u8; 32]; + + client + .persistence_manager + .backend() + .put_msg_secret(chat, our_lid, outbound_id, &secret) + .await + .unwrap(); + + let plaintext_msg = wa::Message { + conversation: Some("mixed ok".into()), + ..Default::default() + }; + let pt_bytes = { + use prost::Message as _; + let mut v = Vec::with_capacity(plaintext_msg.encoded_len()); + plaintext_msg.encode(&mut v).unwrap(); + v + }; + let ctx = BotMessageContext { + msg_id: bot_reply_id, + target_sender_user_jid: our_lid, + bot_user_jid: chat, + }; + let (cipher, iv) = encrypt_bot_message(&pt_bytes, &secret, &ctx).unwrap(); + let ms_msg_proto = encode_message_secret_message(&iv, &cipher); + + // Stanza has a valid msmsg PLUS an unrecognised "frskmsg" sibling. + // The fallback transport-ack must NOT fire (would drop the msmsg). + let node = NodeBuilder::new("message") + .attr("from", "867051314767696@bot") + .attr("id", bot_reply_id) + .attr("type", "text") + .children([ + NodeBuilder::new("meta") + .attr("target_id", outbound_id) + .attr("target_sender_jid", our_lid) + .build(), + NodeBuilder::new("enc") + .attr("type", "frskmsg") + .bytes(vec![0u8; 8]) + .build(), + NodeBuilder::new("enc") + .attr("type", "msmsg") + .attr("v", "2") + .bytes(ms_msg_proto) + .build(), + ]) + .build(); + let owned = node_to_arc(node); + client.clone().handle_incoming_message(owned).await; + + let got = collect_event( + &client, + collector, + |e| { + matches!(e, wacore::types::events::Event::Message(msg, info) + if info.id == bot_reply_id + && msg.conversation.as_deref() == Some("mixed ok")) + }, + 1500, + ) + .await; + assert!( + got.is_some(), + "msmsg sibling of an unknown enc must still decrypt and dispatch" + ); + } + + /// LID↔PN migration window: the secret was stored under our PN, but the + /// bot reply's `<meta target_sender_jid>` echoes our LID. The primary + /// lookup misses; `alternate_msg_secret_lookup` resolves PN via + /// `lid_pn_mapping` and hits. Mirrors WA Web `C()`'s `getAlternateMsgKey`. + #[tokio::test] + async fn msmsg_alternate_lookup_resolves_lid_to_stored_pn() { + use wacore::bot_message::{BotMessageContext, encrypt_bot_message}; + use wacore::store::traits::LidPnMappingEntry; + + let (client, _transport) = capturing_client("msmsg_alt_lookup").await; + let collector = Arc::new(crate::test_utils::TestEventCollector::default()); + client.register_handler(collector.clone()); + + let chat = "867051314767696@bot"; + let our_lid_user = "999888777666555"; + let our_pn_user = "5511000000001"; + let our_lid = "999888777666555@lid"; + let our_pn = "5511000000001@s.whatsapp.net"; + let outbound_id = "OUT_ALT"; + let bot_reply_id = "REPLY_ALT"; + let secret = [0x3Cu8; 32]; + + // Seed the LID→PN mapping so the alternate lookup can swap. + client + .persistence_manager + .backend() + .put_lid_mapping(&LidPnMappingEntry { + lid: our_lid_user.into(), + phone_number: our_pn_user.into(), + created_at: 0, + updated_at: 0, + learning_source: "test".into(), + }) + .await + .unwrap(); + // Secret stored under PN (as if the outbound went out PN-addressed). + client + .persistence_manager + .backend() + .put_msg_secret(chat, our_pn, outbound_id, &secret) + .await + .unwrap(); + + // Bot reply encrypts with target = our LID (what <meta> declares). + let plaintext_msg = wa::Message { + conversation: Some("alt ok".into()), + ..Default::default() + }; + let pt_bytes = { + use prost::Message as _; + let mut v = Vec::with_capacity(plaintext_msg.encoded_len()); + plaintext_msg.encode(&mut v).unwrap(); + v + }; + let ctx = BotMessageContext { + msg_id: bot_reply_id, + target_sender_user_jid: our_lid, + bot_user_jid: chat, + }; + let (cipher, iv) = encrypt_bot_message(&pt_bytes, &secret, &ctx).unwrap(); + let ms_msg_proto = encode_message_secret_message(&iv, &cipher); + + let node = NodeBuilder::new("message") + .attr("from", "867051314767696@bot") + .attr("id", bot_reply_id) + .attr("type", "text") + .children([ + NodeBuilder::new("meta") + .attr("target_id", outbound_id) + .attr("target_sender_jid", our_lid) + .build(), + NodeBuilder::new("enc") + .attr("type", "msmsg") + .attr("v", "2") + .bytes(ms_msg_proto) + .build(), + ]) + .build(); + let owned = node_to_arc(node); + client.clone().handle_incoming_message(owned).await; + + let got = collect_event( + &client, + collector, + |e| { + matches!(e, wacore::types::events::Event::Message(msg, info) + if info.id == bot_reply_id + && msg.conversation.as_deref() == Some("alt ok")) + }, + 1500, + ) + .await; + assert!( + got.is_some(), + "LID-declared reply must resolve the PN-stored secret via lid_pn_mapping" + ); + } + + /// End-to-end: phone fanout dispatches a wa::Message carrying the + /// outbound `messageSecret`; later the Meta AI bot replies via msmsg + /// referencing the same id. The captured secret must let the reply + /// decrypt and surface `Event::Message`. + #[tokio::test] + async fn fanout_capture_lets_subsequent_msmsg_decrypt() { + use crate::store::commands::DeviceCommand; + use wacore::bot_message::{BotMessageContext, encrypt_bot_message}; + + let (client, _transport) = capturing_client("fanout_to_msmsg").await; + client + .persistence_manager + .process_command(DeviceCommand::SetLid(Some( + "999888777666555:0@lid".parse().unwrap(), + ))) + .await; + let collector = Arc::new(crate::test_utils::TestEventCollector::default()); + client.register_handler(collector.clone()); + + let bot_chat: Jid = "867051314767696@bot".parse().unwrap(); + let our_lid_str = "999888777666555@lid"; + let outbound_id = "FANOUT_OUT"; + let bot_reply_id = "BOT_REPLY_PHONE"; + let secret = [0x99u8; 32]; + + // Step 1: simulate the fanout dispatch (what dispatch_parsed_message + // would call when the phone's outbound stanza is mirrored to us). + let fanout_info = Arc::new(MessageInfo { + id: outbound_id.into(), + source: crate::types::message::MessageSource { + chat: bot_chat.clone(), + sender: "5511000000001:0@s.whatsapp.net".parse().unwrap(), + is_from_me: true, + ..Default::default() + }, + ..Default::default() + }); + let fanout_msg = wa::Message { + conversation: Some("hi bot".into()), + message_context_info: Some(wa::MessageContextInfo { + message_secret: Some(secret.to_vec()), + ..Default::default() + }), + ..Default::default() + }; + client + .maybe_capture_inbound_msg_secret(&fanout_msg, &fanout_info) + .await; + // Write is awaited inline now, so the secret is already durable here. + for _ in 0..40 { + if client + .persistence_manager + .backend() + .get_msg_secret("867051314767696@bot", our_lid_str, outbound_id) + .await + .unwrap() + .is_some() + { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + + // Step 2: the bot reply arrives as <enc type="msmsg"> referencing + // outbound_id via <meta target_id>. With the secret captured above, + // it must decrypt cleanly. + let plaintext_msg = wa::Message { + conversation: Some("bot reply".into()), + ..Default::default() + }; + let pt_bytes = { + use prost::Message as _; + let mut v = Vec::with_capacity(plaintext_msg.encoded_len()); + plaintext_msg.encode(&mut v).unwrap(); + v + }; + let ctx = BotMessageContext { + msg_id: bot_reply_id, + target_sender_user_jid: our_lid_str, + bot_user_jid: "867051314767696@bot", + }; + let (cipher, iv) = encrypt_bot_message(&pt_bytes, &secret, &ctx).unwrap(); + let ms_msg_proto = encode_message_secret_message(&iv, &cipher); + + let node = NodeBuilder::new("message") + .attr("from", "867051314767696@bot") + .attr("id", bot_reply_id) + .attr("type", "text") + .children([ + NodeBuilder::new("meta") + .attr("target_id", outbound_id) + .attr("target_sender_jid", our_lid_str) + .build(), + NodeBuilder::new("enc") + .attr("type", "msmsg") + .attr("v", "2") + .bytes(ms_msg_proto) + .build(), + ]) + .build(); + let owned = node_to_arc(node); + client.clone().handle_incoming_message(owned).await; + + let got = collect_event( + &client, + collector, + |e| { + matches!(e, wacore::types::events::Event::Message(msg, info) + if info.id == bot_reply_id + && msg.conversation.as_deref() == Some("bot reply")) + }, + 1500, + ) + .await; + assert!( + got.is_some(), + "secret captured from fanout must enable msmsg reply decryption" + ); + } + + /// Coherence: the identity `persist_outbound_msg_secret` writes under + /// (LID for bot chats) must match what `handle_msmsg_payload` reads via + /// `<meta target_sender_jid>`. End-to-end without bypassing the helper. + #[tokio::test] + async fn msmsg_outbound_put_and_inbound_get_match_for_lid_bot() { + use crate::store::commands::DeviceCommand; + use wacore::bot_message::{BotMessageContext, encrypt_bot_message}; + + let (client, _transport) = capturing_client("msmsg_lid_match").await; + // Seed both PN (already seeded by capturing_client) and LID. + client + .persistence_manager + .process_command(DeviceCommand::SetLid(Some( + "999888777666555:0@lid".parse().unwrap(), + ))) + .await; + let collector = Arc::new(crate::test_utils::TestEventCollector::default()); + client.register_handler(collector.clone()); + + let bot_chat: Jid = "867051314767696@bot".parse().unwrap(); + let outbound_id = "OUT_LID"; + let bot_reply_id = "REPLY_LID"; + let our_lid = "999888777666555@lid"; + let secret = [0x71u8; 32]; + + // Real outbound path: caller resolves the bot identity to our LID. + let sender_identity = client + .dm_sender_identity_for(&bot_chat) + .await + .expect("LID seeded"); + client + .persist_outbound_msg_secret(&bot_chat, &sender_identity, outbound_id, &secret) + .await; + + // Inbound msmsg payload encrypted under the same (msg_id, target, bot) + // tuple the meta will declare on the wire. + let plaintext_msg = wa::Message { + conversation: Some("lid coherent".to_string()), + ..Default::default() + }; + let pt_bytes = { + use prost::Message as _; + let mut v = Vec::with_capacity(plaintext_msg.encoded_len()); + plaintext_msg.encode(&mut v).unwrap(); + v + }; + let ctx = BotMessageContext { + msg_id: bot_reply_id, + target_sender_user_jid: our_lid, + bot_user_jid: "867051314767696@bot", + }; + let (cipher, iv) = encrypt_bot_message(&pt_bytes, &secret, &ctx).unwrap(); + let ms_msg_proto = encode_message_secret_message(&iv, &cipher); + + let node = NodeBuilder::new("message") + .attr("from", "867051314767696@bot") + .attr("id", bot_reply_id) + .attr("type", "text") + .children([ + NodeBuilder::new("meta") + .attr("target_id", outbound_id) + .attr("target_sender_jid", our_lid) + .build(), + NodeBuilder::new("enc") + .attr("type", "msmsg") + .attr("v", "2") + .bytes(ms_msg_proto) + .build(), + ]) + .build(); + let owned = node_to_arc(node); + client.clone().handle_incoming_message(owned).await; + + let got = collect_event( + &client, + collector, + |e| { + matches!(e, wacore::types::events::Event::Message(msg, info) + if info.id == bot_reply_id + && msg.conversation.as_deref() == Some("lid coherent")) + }, + 1500, + ) + .await; + assert!( + got.is_some(), + "outbound PUT and inbound GET must converge on LID for bot chats" + ); + } + + /// Regression for the AD_JID encoder bug: a `from="USER:0@bot"` stanza must + /// survive the marshal/unmarshal round-trip with `server=Bot`, so the + /// secret lookup keys hit and the reply decrypts. + #[tokio::test] + async fn msmsg_with_bot_device_suffix_round_trips() { + use wacore::bot_message::{BotMessageContext, encrypt_bot_message}; + let (client, _transport) = capturing_client("msmsg_bot_device").await; + let collector = Arc::new(crate::test_utils::TestEventCollector::default()); + client.register_handler(collector.clone()); + + let chat = "867051314767696@bot"; + let our_pn = "5511000000001@s.whatsapp.net"; + let outbound_id = "OUTBOUND_DEV"; + let bot_reply_id = "BOT_REPLY_DEV"; + let secret = [0x33u8; 32]; + + client + .persistence_manager + .backend() + .put_msg_secret(chat, our_pn, outbound_id, &secret) + .await + .unwrap(); + + let plaintext_msg = wa::Message { + conversation: Some("with device".to_string()), + ..Default::default() + }; + let pt_bytes = { + use prost::Message as _; + let mut v = Vec::with_capacity(plaintext_msg.encoded_len()); + plaintext_msg.encode(&mut v).unwrap(); + v + }; + let ctx = BotMessageContext { + msg_id: bot_reply_id, + target_sender_user_jid: our_pn, + bot_user_jid: chat, + }; + let (cipher, iv) = encrypt_bot_message(&pt_bytes, &secret, &ctx).unwrap(); + let ms_msg_proto = encode_message_secret_message(&iv, &cipher); + + let node = NodeBuilder::new("message") + .attr("from", "867051314767696:0@bot") + .attr("id", bot_reply_id) + .attr("type", "text") + .children([ + NodeBuilder::new("meta") + .attr("target_id", outbound_id) + .attr("target_sender_jid", our_pn) + .build(), + NodeBuilder::new("enc") + .attr("type", "msmsg") + .attr("v", "2") + .bytes(ms_msg_proto) + .build(), + ]) + .build(); + let owned = node_to_arc(node); + client.clone().handle_incoming_message(owned).await; + + let got = collect_event( + &client, + collector, + |e| { + matches!(e, wacore::types::events::Event::Message(msg, info) + if info.id == bot_reply_id + && msg.conversation.as_deref() == Some("with device")) + }, + 1500, + ) + .await; + assert!( + got.is_some(), + "msmsg with `:0@bot` from must round-trip (encoder must not strip the bot server)" + ); + } + + /// `<meta>` without `target_id` → cannot identify the parent message, + /// nack 495 and no dispatch. + #[tokio::test] + async fn msmsg_without_meta_target_id_nacks_495() { + let (client, transport) = capturing_client("msmsg_no_target").await; + let bot_reply_id = "BOT_REPLY_NT"; + let ms_msg_proto = encode_message_secret_message(&[0u8; 12], &[0u8; 32]); + let node = NodeBuilder::new("message") + .attr("from", "867051314767696@bot") + .attr("id", bot_reply_id) + .attr("type", "text") + .children([NodeBuilder::new("enc") + .attr("type", "msmsg") + .attr("v", "2") + .bytes(ms_msg_proto) + .build()]) + .build(); + let owned = node_to_arc(node); + client.clone().handle_incoming_message(owned).await; + + let mut code = None; + for _ in 0..80 { + if let Some(c) = find_message_nack_error(&transport.sent(), bot_reply_id) { + code = Some(c); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + assert_eq!(code, Some(495)); + } } diff --git a/src/send.rs b/src/send.rs index 5d767bd5c..5c575d0bd 100644 --- a/src/send.rs +++ b/src/send.rs @@ -858,7 +858,7 @@ impl Client { } // The protocolMessageKey.participant should match the original message's key exactly // Do NOT convert LID to PN - pass through unchanged like WhatsApp Web does - let participant_str = original_sender.to_non_ad().to_string(); + let participant_str = original_sender.to_non_ad_string(); log::debug!( "Admin revoke: using participant {} for MessageKey", participant_str @@ -989,6 +989,17 @@ impl Client { Some(id) => id, None => self.generate_message_id().await, }; + // `request_id` is moved into the branch-specific stanza builders below; + // keep a copy for the post-send messageSecret persistence (the secret + // itself is generated inside prepare_dm/group_stanza, not on `message`, + // so it's threaded back out via PreparedStanza.message_secret below). + let outbound_id_clone = request_id.clone(); + let mut outbound_msg_secret: Option<[u8; 32]> = None; + // Group prepares pick LID or PN based on group addressing_mode; + // capture it so the persisted secret keys match what + // `<meta target_sender_jid>` echoes back. For DMs we resolve from + // chat.server (LID for bot, PN otherwise) after send_node succeeds. + let mut outbound_group_sender_identity: Option<Jid> = None; // SKDM update data — only populated for group sends, deferred until after send_node(). // This matches WhatsApp Web which only calls markHasSenderKey() after server ACK. @@ -1141,6 +1152,8 @@ impl Client { devices: prepared.skdm_devices, stale_users: prepared.stale_device_users, }); + outbound_msg_secret = prepared.message_secret; + outbound_group_sender_identity = Some(prepared.sender_identity); prepared.node } Err(e) => { @@ -1185,6 +1198,8 @@ impl Client { devices: retry_prepared.skdm_devices, stale_users: retry_prepared.stale_device_users, }); + outbound_msg_secret = retry_prepared.message_secret; + outbound_group_sender_identity = Some(retry_prepared.sender_identity); retry_prepared.node } else { return Err(e); @@ -1362,6 +1377,7 @@ impl Client { ) .await?; dm_phash = prepared.phash; + outbound_msg_secret = prepared.message_secret; prepared.node }; @@ -1391,6 +1407,22 @@ impl Client { return Err(e.into()); } + if let Some(secret) = outbound_msg_secret.as_ref() { + let sender = match outbound_group_sender_identity { + Some(s) => Some(s), + None => self.dm_sender_identity_for(&tc_issue_target).await, + }; + if let Some(sender) = sender { + self.persist_outbound_msg_secret( + &tc_issue_target, + &sender, + &outbound_id_clone, + secret, + ) + .await; + } + } + if let Some((rx, phash, msg_id)) = ack { // Group sends also invalidate group cache on mismatch — server's // participant set diverged, the next send needs a fresh query. @@ -1438,6 +1470,40 @@ impl Client { Ok(()) } + /// Persist a generated `MessageContextInfo.message_secret` keyed by + /// `(chat_non_ad, sender_non_ad, msg_id)`. The sender identity must + /// match what `<meta target_sender_jid>` echoes back at GET time — + /// LID for bot chats and LID-mode groups, PN otherwise. + pub(crate) async fn persist_outbound_msg_secret( + &self, + chat: &Jid, + sender: &Jid, + msg_id: &str, + secret: &[u8; wacore::reporting_token::MESSAGE_SECRET_SIZE], + ) { + let chat_str = chat.to_non_ad_string(); + let sender_str = sender.to_non_ad_string(); + if let Err(e) = self + .persistence_manager + .backend() + .put_msg_secret(&chat_str, &sender_str, msg_id, secret) + .await + { + log::warn!("Failed to persist outbound messageSecret for {msg_id}: {e:?}"); + } + } + + /// Decide the identity (LID vs PN) under which an outbound DM's + /// `messageSecret` should be persisted. Group sends should use + /// `PreparedGroupStanza.sender_identity` directly instead of this. + pub(crate) async fn dm_sender_identity_for(&self, to: &Jid) -> Option<Jid> { + if to.server == wacore_binary::Server::Bot { + self.get_lid().await + } else { + self.get_pn().await + } + } + /// Look up and include a privacy token in outgoing 1:1 message stanza nodes. /// /// Follows WA Web's fallback chain (MsgCreateFanoutStanza.js): @@ -1671,7 +1737,7 @@ impl Client { use wacore::iq::tctoken::{IssuePrivacyTokensSpec, is_sender_tc_token_expired}; // Dedup via session_locks — bare JID won't collide with protocol addresses ("user:device") - let bare = sender.to_non_ad().to_string(); + let bare = sender.to_non_ad_string(); let mutex = self.session_lock_for(&bare).await; let Some(_guard) = mutex.try_lock() else { return; @@ -2003,7 +2069,7 @@ mod tests { ), RevokeType::Admin { original_sender } => ( false, - Some(original_sender.to_non_ad().to_string()), + Some(original_sender.to_non_ad_string()), crate::types::message::EditAttribute::AdminRevoke, ), }; @@ -2043,7 +2109,7 @@ mod tests { ), RevokeType::Admin { original_sender } => ( false, - Some(original_sender.to_non_ad().to_string()), + Some(original_sender.to_non_ad_string()), crate::types::message::EditAttribute::AdminRevoke, ), }; @@ -2244,7 +2310,7 @@ mod tests { // This was a bug that caused error 479 - the participant field must // preserve the original JID format exactly (with device stripped). let lid_sender = Jid::from_str("236395184570386:22@lid").unwrap(); - let participant_str = lid_sender.to_non_ad().to_string(); + let participant_str = lid_sender.to_non_ad_string(); // Must preserve @lid suffix, device number stripped assert_eq!(participant_str, "236395184570386@lid"); @@ -3358,4 +3424,195 @@ mod tests { ); } } + + // ---- outbound messageSecret capture --------------------------------- + + use crate::store::commands::DeviceCommand; + use std::sync::Arc; + + async fn seed_pn(client: &Arc<Client>, pn: &str) { + client + .persistence_manager + .process_command(DeviceCommand::SetId(Some(pn.parse().expect("pn")))) + .await; + } + + async fn seed_pn_and_lid(client: &Arc<Client>, pn: &str, lid: &str) { + client + .persistence_manager + .process_command(DeviceCommand::SetId(Some(pn.parse().expect("pn")))) + .await; + client + .persistence_manager + .process_command(DeviceCommand::SetLid(Some(lid.parse().expect("lid")))) + .await; + } + + #[tokio::test] + async fn persist_outbound_msg_secret_writes_under_chat_sender_id() { + let client = crate::test_utils::create_test_client_with_name("secret_chat_id").await; + seed_pn(&client, "5511000000001:0@s.whatsapp.net").await; + let chat: Jid = "5511777776666@s.whatsapp.net".parse().unwrap(); + let sender: Jid = "5511000000001:0@s.whatsapp.net".parse().unwrap(); + let secret = [0x55u8; 32]; + client + .persist_outbound_msg_secret(&chat, &sender, "MID_1", &secret) + .await; + let got = client + .persistence_manager + .backend() + .get_msg_secret( + "5511777776666@s.whatsapp.net", + "5511000000001@s.whatsapp.net", + "MID_1", + ) + .await + .expect("get"); + assert_eq!(got.as_deref(), Some(&secret[..])); + } + + #[tokio::test] + async fn persist_outbound_msg_secret_strips_devices_in_key() { + let client = crate::test_utils::create_test_client_with_name("secret_strip").await; + let chat_with_dev: Jid = "5511777776666:7@s.whatsapp.net".parse().unwrap(); + let sender_with_dev: Jid = "5511000000001:3@s.whatsapp.net".parse().unwrap(); + client + .persist_outbound_msg_secret(&chat_with_dev, &sender_with_dev, "MID_4", &[2u8; 32]) + .await; + let got = client + .persistence_manager + .backend() + .get_msg_secret( + "5511777776666@s.whatsapp.net", + "5511000000001@s.whatsapp.net", + "MID_4", + ) + .await + .unwrap(); + assert_eq!( + got.as_deref(), + Some(&[2u8; 32][..]), + "chat and sender must be stored non-AD" + ); + } + + #[tokio::test] + async fn dm_sender_identity_picks_lid_for_bot_else_pn() { + let client = crate::test_utils::create_test_client_with_name("dm_id_pick").await; + seed_pn_and_lid( + &client, + "5511000000001:0@s.whatsapp.net", + "999888777666555:0@lid", + ) + .await; + let bot_chat: Jid = "867051314767696@bot".parse().unwrap(); + let pn_chat: Jid = "5511777776666@s.whatsapp.net".parse().unwrap(); + let lid_chat: Jid = "111222333444555@lid".parse().unwrap(); + assert_eq!( + client + .dm_sender_identity_for(&bot_chat) + .await + .map(|j| j.to_non_ad_string()), + Some("999888777666555@lid".to_string()), + "bot chats must resolve to our LID" + ); + assert_eq!( + client + .dm_sender_identity_for(&pn_chat) + .await + .map(|j| j.to_non_ad_string()), + Some("5511000000001@s.whatsapp.net".to_string()), + "PN chats must resolve to our PN" + ); + // LID-DM is presently routed under PN; flagged as a follow-up only + // because production hasn't surfaced it. Documented behaviour. + assert_eq!( + client + .dm_sender_identity_for(&lid_chat) + .await + .map(|j| j.to_non_ad_string()), + Some("5511000000001@s.whatsapp.net".to_string()), + ); + } + + /// Regression for Codex P2 (LID-mode group bot replies): the persisted + /// sender must match whatever `prepare_group_stanza` picked for the + /// group's addressing_mode, surfaced via `PreparedGroupStanza.sender_identity`. + #[tokio::test] + async fn persist_uses_group_sender_identity_for_lid_mode_groups() { + let client = crate::test_utils::create_test_client_with_name("secret_lid_group").await; + seed_pn_and_lid( + &client, + "5511000000001:0@s.whatsapp.net", + "999888777666555:0@lid", + ) + .await; + // Simulate a LID-mode group: addressing identity is our LID, not PN. + let group_chat: Jid = "120363021033254949@g.us".parse().unwrap(); + let lid_sender: Jid = "999888777666555:0@lid".parse().unwrap(); + let secret = [0x4Du8; 32]; + client + .persist_outbound_msg_secret(&group_chat, &lid_sender, "GROUP_MID", &secret) + .await; + let got = client + .persistence_manager + .backend() + .get_msg_secret( + "120363021033254949@g.us", + "999888777666555@lid", + "GROUP_MID", + ) + .await + .unwrap(); + assert_eq!( + got.as_deref(), + Some(&secret[..]), + "LID-mode group secrets must key under our LID, not PN" + ); + let under_pn = client + .persistence_manager + .backend() + .get_msg_secret( + "120363021033254949@g.us", + "5511000000001@s.whatsapp.net", + "GROUP_MID", + ) + .await + .unwrap(); + assert!( + under_pn.is_none(), + "LID-mode group must NOT key under our PN" + ); + } + + /// Regression: `wacore::send::prepare_dm_stanza` mints the + /// `message_secret` on a CLONE of the caller's message. Verify the secret + /// is surfaced via `PreparedDmStanza.message_secret` so the post-send hook + /// can persist it -- without this an original-message-based check would + /// miss every ordinary outbound bot prompt. + #[test] + fn prepared_dm_stanza_exposes_generated_message_secret() { + use wacore::reporting_token::generate_reporting_token; + + let msg = wa::Message { + conversation: Some("hi bot".into()), + ..Default::default() + }; + let to: Jid = "867051314767696@bot".parse().unwrap(); + let result = generate_reporting_token(&msg, "MID_X", &to, &to, None); + assert!( + result.is_some(), + "ordinary text messages must produce a reporting token + secret" + ); + let result = result.unwrap(); + assert_eq!(result.message_secret.len(), 32); + // PreparedDmStanza/PreparedGroupStanza now carry this exact array + // through to send_message_impl which calls persist_outbound_msg_secret. + let prepared = wacore::send::PreparedDmStanza { + node: wacore_binary::builder::NodeBuilder::new("message").build(), + phash: None, + message_secret: Some(result.message_secret), + }; + assert_eq!(prepared.message_secret.as_ref().unwrap().len(), 32); + } } diff --git a/storages/sqlite-storage/migrations/2026-05-28-000000_add_msg_secrets/down.sql b/storages/sqlite-storage/migrations/2026-05-28-000000_add_msg_secrets/down.sql new file mode 100644 index 000000000..531908261 --- /dev/null +++ b/storages/sqlite-storage/migrations/2026-05-28-000000_add_msg_secrets/down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_msg_secrets_created; +DROP TABLE IF EXISTS msg_secrets; diff --git a/storages/sqlite-storage/migrations/2026-05-28-000000_add_msg_secrets/up.sql b/storages/sqlite-storage/migrations/2026-05-28-000000_add_msg_secrets/up.sql new file mode 100644 index 000000000..77fe571a8 --- /dev/null +++ b/storages/sqlite-storage/migrations/2026-05-28-000000_add_msg_secrets/up.sql @@ -0,0 +1,16 @@ +-- MessageContextInfo.messageSecret persistence keyed by the outbound message. +-- Required to decrypt msmsg replies from Meta AI / bot fbid: WAWebBotMessageSecret +-- looks up the secret by (chat, target_sender, target_id) where target_id is the +-- id of our original outbound message. + +CREATE TABLE msg_secrets ( + chat TEXT NOT NULL, + sender TEXT NOT NULL, + msg_id TEXT NOT NULL, + secret BLOB NOT NULL, + device_id INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + PRIMARY KEY (chat, sender, msg_id, device_id) +); + +CREATE INDEX idx_msg_secrets_created ON msg_secrets (created_at, device_id); diff --git a/storages/sqlite-storage/src/schema.rs b/storages/sqlite-storage/src/schema.rs index 787fb9a38..59ac9fa56 100644 --- a/storages/sqlite-storage/src/schema.rs +++ b/storages/sqlite-storage/src/schema.rs @@ -159,6 +159,17 @@ diesel::table! { } } +diesel::table! { + msg_secrets (chat, sender, msg_id, device_id) { + chat -> Text, + sender -> Text, + msg_id -> Text, + secret -> Binary, + device_id -> Integer, + created_at -> BigInt, + } +} + diesel::allow_tables_to_appear_in_same_query!( app_state_keys, app_state_mutation_macs, @@ -168,6 +179,7 @@ diesel::allow_tables_to_appear_in_same_query!( device_registry, identities, lid_pn_mapping, + msg_secrets, prekeys, sender_key_devices, sender_keys, diff --git a/storages/sqlite-storage/src/sqlite_store.rs b/storages/sqlite-storage/src/sqlite_store.rs index 533588de9..698d15c63 100644 --- a/storages/sqlite-storage/src/sqlite_store.rs +++ b/storages/sqlite-storage/src/sqlite_store.rs @@ -2503,6 +2503,102 @@ impl ProtocolStore for SqliteStore { } } +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl MsgSecretStore for SqliteStore { + async fn put_msg_secret( + &self, + chat: &str, + sender: &str, + msg_id: &str, + secret: &[u8], + ) -> Result<()> { + let device_id = self.device_id; + let chat: Arc<str> = Arc::from(chat); + let sender: Arc<str> = Arc::from(sender); + let msg_id: Arc<str> = Arc::from(msg_id); + let secret: Arc<[u8]> = Arc::from(secret); + let now = wacore::time::now_secs(); + self.with_retry("put_msg_secret", || { + let chat = Arc::clone(&chat); + let sender = Arc::clone(&sender); + let msg_id = Arc::clone(&msg_id); + let secret = Arc::clone(&secret); + Box::new(move |conn: &mut SqliteConnection| { + diesel::insert_into(msg_secrets::table) + .values(( + msg_secrets::chat.eq(chat.as_ref()), + msg_secrets::sender.eq(sender.as_ref()), + msg_secrets::msg_id.eq(msg_id.as_ref()), + msg_secrets::secret.eq(secret.as_ref()), + msg_secrets::device_id.eq(device_id), + msg_secrets::created_at.eq(now), + )) + .on_conflict(( + msg_secrets::chat, + msg_secrets::sender, + msg_secrets::msg_id, + msg_secrets::device_id, + )) + .do_update() + .set(( + msg_secrets::secret.eq(secret.as_ref()), + msg_secrets::created_at.eq(now), + )) + .execute(conn)?; + Ok(()) + }) + }) + .await + } + + async fn get_msg_secret( + &self, + chat: &str, + sender: &str, + msg_id: &str, + ) -> Result<Option<Vec<u8>>> { + let pool = self.pool.clone(); + let device_id = self.device_id; + let chat = chat.to_string(); + let sender = sender.to_string(); + let msg_id = msg_id.to_string(); + tokio::task::spawn_blocking(move || -> Result<Option<Vec<u8>>> { + let mut conn = pool + .get() + .map_err(|e| StoreError::Connection(Box::new(e)))?; + let row: Option<Vec<u8>> = msg_secrets::table + .select(msg_secrets::secret) + .filter(msg_secrets::chat.eq(&chat)) + .filter(msg_secrets::sender.eq(&sender)) + .filter(msg_secrets::msg_id.eq(&msg_id)) + .filter(msg_secrets::device_id.eq(device_id)) + .first(&mut conn) + .optional() + .map_err(|e| StoreError::Database(Box::new(e)))?; + Ok(row) + }) + .await + .map_err(|e| StoreError::Database(Box::new(e)))? + } + + async fn delete_expired_msg_secrets(&self, cutoff_timestamp: i64) -> Result<u32> { + let device_id = self.device_id; + self.with_retry("delete_expired_msg_secrets", || { + Box::new(move |conn: &mut SqliteConnection| { + let deleted = diesel::delete( + msg_secrets::table + .filter(msg_secrets::created_at.lt(cutoff_timestamp)) + .filter(msg_secrets::device_id.eq(device_id)), + ) + .execute(conn)?; + Ok(deleted as u32) + }) + }) + .await + } +} + #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] #[cfg_attr(not(target_arch = "wasm32"), async_trait)] impl DeviceStore for SqliteStore { @@ -3094,4 +3190,152 @@ mod tests { "cleared chain must round-trip as None" ); } + + #[tokio::test] + async fn msg_secret_round_trip_sqlite() { + let store = create_test_store().await; + let secret = [0xABu8; 32]; + store + .put_msg_secret("12345@s.whatsapp.net", "9999@lid", "MID1", &secret) + .await + .expect("put"); + let got = store + .get_msg_secret("12345@s.whatsapp.net", "9999@lid", "MID1") + .await + .expect("get") + .expect("must exist"); + assert_eq!(got, secret.to_vec()); + } + + #[tokio::test] + async fn msg_secret_miss_returns_none_sqlite() { + let store = create_test_store().await; + assert!( + store + .get_msg_secret("any@s.whatsapp.net", "any@lid", "NOPE") + .await + .expect("get") + .is_none() + ); + } + + #[tokio::test] + async fn msg_secret_upsert_replaces_secret() { + let store = create_test_store().await; + store + .put_msg_secret("c", "s", "M", &[1u8; 32]) + .await + .expect("put 1"); + store + .put_msg_secret("c", "s", "M", &[9u8; 32]) + .await + .expect("put 2"); + let got = store.get_msg_secret("c", "s", "M").await.unwrap().unwrap(); + assert_eq!(got, vec![9u8; 32], "ON CONFLICT must overwrite"); + } + + #[tokio::test] + async fn msg_secret_scoped_by_three_columns() { + let store = create_test_store().await; + store + .put_msg_secret("c1", "s1", "M1", &[1u8; 32]) + .await + .unwrap(); + store + .put_msg_secret("c1", "s1", "M2", &[2u8; 32]) + .await + .unwrap(); + store + .put_msg_secret("c1", "s2", "M1", &[3u8; 32]) + .await + .unwrap(); + store + .put_msg_secret("c2", "s1", "M1", &[4u8; 32]) + .await + .unwrap(); + + for (chat, sender, msg_id, expected) in [ + ("c1", "s1", "M1", 1u8), + ("c1", "s1", "M2", 2), + ("c1", "s2", "M1", 3), + ("c2", "s1", "M1", 4), + ] { + let got = store + .get_msg_secret(chat, sender, msg_id) + .await + .unwrap() + .unwrap_or_else(|| panic!("missing ({chat},{sender},{msg_id})")); + assert_eq!(got, vec![expected; 32]); + } + } + + #[tokio::test] + async fn delete_expired_msg_secrets_deletes_only_below_cutoff() { + let store = create_test_store().await; + store + .put_msg_secret("c", "s", "M", &[7u8; 32]) + .await + .unwrap(); + let now = wacore::time::now_secs(); + // cutoff well before insert → nothing deleted + let removed = store + .delete_expired_msg_secrets(now - 86_400) + .await + .unwrap(); + assert_eq!(removed, 0); + assert!( + store.get_msg_secret("c", "s", "M").await.unwrap().is_some(), + "row newer than cutoff must survive" + ); + // cutoff after insert → deleted + let removed = store + .delete_expired_msg_secrets(now + 86_400) + .await + .unwrap(); + assert_eq!(removed, 1); + assert!(store.get_msg_secret("c", "s", "M").await.unwrap().is_none()); + } + + /// Multi-account isolation: same DB, different device_id rows must not + /// collide on the same logical key. + #[tokio::test] + async fn msg_secret_isolated_per_device_id() { + use portable_atomic::AtomicU64; + use std::sync::atomic::Ordering; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let id = COUNTER.fetch_add(1, Ordering::Relaxed); + let shared_url = format!( + "file:memdb_msgsecret_iso_{}_{}?mode=memory&cache=shared", + std::process::id(), + id + ); + let store_a = SqliteStore::new_for_device(&shared_url, 1) + .await + .expect("store_a"); + let store_b = SqliteStore::new_for_device(&shared_url, 2) + .await + .expect("store_b"); + + store_a + .put_msg_secret("c", "s", "M", &[7u8; 32]) + .await + .unwrap(); + assert!( + store_b + .get_msg_secret("c", "s", "M") + .await + .unwrap() + .is_none(), + "same DB, different device_id must not see each other's secrets" + ); + assert_eq!( + store_a + .get_msg_secret("c", "s", "M") + .await + .unwrap() + .unwrap(), + vec![7u8; 32], + "device_a still sees its own write" + ); + } } diff --git a/wacore/binary/src/encoder.rs b/wacore/binary/src/encoder.rs index 52ff696bb..756bc039f 100644 --- a/wacore/binary/src/encoder.rs +++ b/wacore/binary/src/encoder.rs @@ -332,6 +332,14 @@ fn parse_jid_meta(input: &str) -> Option<ParsedJidMeta> { agent_byte }; + // Single source of truth: only servers whose `domain_type` the decoder + // round-trips back can use AD_JID. For everyone else drop the device + // and fall through to JID_PAIR (which preserves the server name). + let device = jid::Server::try_from(server) + .ok() + .filter(|s| server_supports_ad_jid(*s)) + .and(device); + Some(ParsedJidMeta { user_end, server_start, @@ -371,6 +379,20 @@ fn server_to_domain_type(server: jid::Server, agent: u8) -> u8 { } } +/// AD_JID round-trips back to a server via `domain_type` only for the four +/// servers the decoder explicitly maps. For everything else (bot, group, +/// broadcast, newsletter, call, interop, msgr, legacy) the decoder collapses +/// the byte to Pn and the original server string is lost. Writers must check +/// this and emit JID_PAIR for non-AD-capable servers even when `device > 0`. +/// Matches whatsmeow `writeJID` and WA Web `WAWap.De`. +#[inline] +fn server_supports_ad_jid(server: jid::Server) -> bool { + matches!( + server, + jid::Server::Pn | jid::Server::Lid | jid::Server::Hosted | jid::Server::HostedLid + ) +} + #[inline] fn classify_string_hint(s: &str) -> StringHint { if s.is_empty() { @@ -554,7 +576,7 @@ fn parsed_jid_encoded_size_with_cache( #[inline] fn owned_jid_encoded_size_with_cache(jid: &Jid, hints: &mut StringHintCache) -> usize { - if jid.device > 0 { + if jid.device > 0 && server_supports_ad_jid(jid.server) { 3 + string_encoded_size_with_cache(&jid.user, hints) } else { let user_size = if jid.user.is_empty() { @@ -568,7 +590,7 @@ fn owned_jid_encoded_size_with_cache(jid: &Jid, hints: &mut StringHintCache) -> #[inline] fn jid_ref_encoded_size_with_cache(jid: &JidRef<'_>, hints: &mut StringHintCache) -> usize { - if jid.device > 0 { + if jid.device > 0 && server_supports_ad_jid(jid.server) { 3 + string_encoded_size_with_cache(&jid.user, hints) } else { let user_size = if jid.user.is_empty() { @@ -759,7 +781,7 @@ impl<'a, W: ByteWriter> Encoder<'a, W> { /// Write a JidRef directly without converting to string first. /// This avoids the allocation that would occur with `jid.to_string()`. pub fn write_jid_ref(&mut self, jid: &JidRef<'_>) -> Result<()> { - if jid.device > 0 { + if jid.device > 0 && server_supports_ad_jid(jid.server) { // AD_JID format: domain_type, device, user let device = u8::try_from(jid.device).map_err(|_| { BinaryError::AttrParse(format!("AD_JID device id out of range: {}", jid.device)) @@ -784,7 +806,7 @@ impl<'a, W: ByteWriter> Encoder<'a, W> { /// Write an owned Jid directly without converting to string first. /// This avoids the allocation that would occur with `jid.to_string()`. pub fn write_jid_owned(&mut self, jid: &Jid) -> Result<()> { - if jid.device > 0 { + if jid.device > 0 && server_supports_ad_jid(jid.server) { // AD_JID format: domain_type, device, user let device = u8::try_from(jid.device).map_err(|_| { BinaryError::AttrParse(format!("AD_JID device id out of range: {}", jid.device)) @@ -1532,4 +1554,104 @@ mod tests { Ok(()) } + + /// Regression: AD_JID only round-trips for the 4 servers whose domain_type + /// the decoder maps back (Pn/Lid/Hosted/HostedLid). Anything else + /// (bot/group/broadcast/newsletter/...) must go through JID_PAIR so the + /// server string survives. Matches whatsmeow `writeJID` and WA Web + /// `WAWap.De` (`WapJid.create` for non-AD-capable servers). + #[test] + fn test_bot_jid_with_device_round_trips_via_jid_pair() -> TestResult { + use crate::decoder::Decoder; + + for value in [ + "867051314767696@bot", + "867051314767696:0@bot", + "120363021033254949@g.us", + "12345@broadcast", + "12345@newsletter", + ] { + let node = NodeBuilder::new("msg").attr("from", value).build(); + + let mut buffer = Vec::new(); + let mut encoder = Encoder::new(Cursor::new(&mut buffer))?; + encoder.write_node(&node)?; + + // AD_JID (0xF7) must NOT appear for any of these — they use JID_PAIR + // (0xF8) or raw bytes. + assert!( + !buffer.contains(&token::AD_JID), + "AD_JID must not be emitted for {value} (would lose the server)" + ); + + let decoded = Decoder::new(&buffer[1..]).read_node_ref()?.to_owned(); + let from_attr = decoded + .attrs + .get("from") + .expect("from attr must survive the round-trip"); + let got = from_attr.to_string(); + // device :0 is equivalent to no device for these servers; either + // form is acceptable as long as the server is preserved. + let expected_user_server = value.split(':').next().unwrap_or(value); + let expected_server = value.split('@').nth(1).unwrap(); + assert!( + got.ends_with(&format!("@{expected_server}")), + "round-trip lost the server for {value}: got {got}", + ); + assert!( + got.starts_with(expected_user_server.split('@').next().unwrap()) + || got.starts_with(value.split('@').next().unwrap()), + "round-trip lost the user for {value}: got {got}", + ); + } + Ok(()) + } + + /// Same invariant as above but exercised through the typed + /// `NodeValue::Jid` path (write_jid_owned + size estimators), which + /// previously ignored the server check and emitted AD_JID for any + /// device > 0 — silently mapping the server back to Pn on decode. + #[test] + fn test_typed_non_ad_jid_with_device_round_trips_via_jid_pair() -> TestResult { + use crate::decoder::Decoder; + use std::str::FromStr; + + for value in [ + // Bot devices, broadcast/newsletter with explicit device — all + // non-AD-capable servers. The decoder cannot recover the server + // from the AD_JID domain_type, so the encoder must avoid AD_JID. + "867051314767696:0@bot", + "12345:5@broadcast", + "67890:9@newsletter", + ] { + let jid = Jid::from_str(value)?; + let node = NodeBuilder::new("msg").attr("from", jid.clone()).build(); + + let mut buffer = Vec::new(); + let mut encoder = Encoder::new(Cursor::new(&mut buffer))?; + encoder.write_node(&node)?; + + assert!( + !buffer.contains(&token::AD_JID), + "typed JID {value} must NOT emit AD_JID (decoder would drop the server)" + ); + + let decoded = Decoder::new(&buffer[1..]).read_node_ref()?.to_owned(); + let from = decoded + .attrs + .get("from") + .expect("from attr must survive round-trip") + .to_jid() + .expect("from attr decodes back to a Jid"); + assert_eq!( + from.server, jid.server, + "round-trip lost the server for typed {value}" + ); + assert_eq!( + from.user, jid.user, + "round-trip lost the user for typed {value}" + ); + } + Ok(()) + } } diff --git a/wacore/binary/src/jid.rs b/wacore/binary/src/jid.rs index 446baf11c..865356a02 100644 --- a/wacore/binary/src/jid.rs +++ b/wacore/binary/src/jid.rs @@ -527,6 +527,15 @@ impl Jid { } } + /// Canonical non-AD string form (`user@server`, device + agent stripped) + /// in a single allocation. Equivalent to `to_non_ad().to_string()` but + /// skips the throwaway intermediate `Jid` and its `CompactString` clone. + pub fn to_non_ad_string(&self) -> String { + let mut buf = String::with_capacity(self.user.len() + 1 + self.server.as_str().len()); + push_jid_to_string(&self.user, self.server, 0, 0, &mut buf); + buf + } + /// Check if this JID matches the user or their LID. /// Useful for checking if a participant is "us" in group messages. #[inline] @@ -1284,6 +1293,30 @@ mod tests { assert_eq!(status_non_ad.to_string(), "status@broadcast"); } + #[test] + fn test_to_non_ad_string_matches_to_non_ad_to_string() { + // to_non_ad_string() must be byte-identical to to_non_ad().to_string() + // across PN/LID/bot/group/status, with and without device + agent. + for s in [ + "1234567890:33@s.whatsapp.net", + "1234567890@s.whatsapp.net", + "100000012345678:25@lid", + "100000012345678@lid", + "867051314767696:0@bot", + "867051314767696@bot", + "120363021033254949@g.us", + "status@broadcast", + "12-34@g.us", + ] { + let jid: Jid = s.parse().expect("parse"); + assert_eq!( + jid.to_non_ad_string(), + jid.to_non_ad().to_string(), + "mismatch for {s}" + ); + } + } + #[test] fn test_jid_factories_with_string_types() { // Test with &str diff --git a/wacore/src/bot_message.rs b/wacore/src/bot_message.rs new file mode 100644 index 000000000..1a40853ff --- /dev/null +++ b/wacore/src/bot_message.rs @@ -0,0 +1,327 @@ +//! Meta AI / fbid bot `<enc type="msmsg">` decryption. +//! +//! Mirrors `WAWebBotMessageSecret.decryptMsmsgBotMessage` / whatsmeow +//! `decryptBotMessage`. Two-pass HKDF over the outbound `messageSecret`, +//! AES-256-GCM open with `msgID || 0x00 || bot_author_user` as AAD. +//! +//! ```text +//! k1 = HKDF-SHA256(messageSecret, salt = ∅, info = "Bot Message", L = 32) +//! k2 = HKDF-SHA256(k1, salt = ∅, info = msgID || target_user_jid || bot_user_jid, L = 32) +//! AAD = msgID || 0x00 || bot_user_jid +//! plain = AES-256-GCM.Decrypt(k2, enc_iv, enc_payload_with_tag, AAD) +//! ``` +//! +//! `target_user_jid` / `bot_user_jid` are non-AD JID strings (no device). +//! `msgID` is the bot reply id, or `bot_info.edit_target_id` when the bot is +//! editing a prior reply. + +use anyhow::{Result, anyhow}; +use hkdf::Hkdf; +use sha2::Sha256; + +use crate::libsignal::crypto::{aes_256_gcm_decrypt, aes_256_gcm_encrypt}; + +const GCM_IV_SIZE: usize = 12; +const GCM_TAG_SIZE: usize = 16; +const KEY_SIZE: usize = 32; +const BOT_MESSAGE_INFO: &[u8] = b"Bot Message"; + +/// Inputs needed to derive the per-message bot key + AAD. +/// +/// `msg_id` is the wire `id` of the bot reply, OR `bot_info.edit_target_id` +/// when the bot is editing an earlier reply (WA Web `f()` falls back to +/// `botEditTargetId` on AES-GCM failure; `h()` pre-applies it when the bot +/// edit type is INNER or LAST). +#[derive(Debug, Clone, Copy)] +pub struct BotMessageContext<'a> { + pub msg_id: &'a str, + /// Target sender JID in non-AD (user) form. For our PN-bound conversations + /// with the bot this is our PN; for LID bots it's our LID. + pub target_sender_user_jid: &'a str, + /// The bot's JID in non-AD (user) form, e.g. `867051314767696@bot`. + pub bot_user_jid: &'a str, +} + +/// Pass 1: base bot key. +fn derive_base_bot_key(message_secret: &[u8]) -> Result<[u8; KEY_SIZE]> { + if message_secret.len() != KEY_SIZE { + return Err(anyhow!( + "invalid messageSecret length: expected {KEY_SIZE}, got {}", + message_secret.len() + )); + } + let hk = Hkdf::<Sha256>::new(None, message_secret); + let mut out = [0u8; KEY_SIZE]; + hk.expand(BOT_MESSAGE_INFO, &mut out) + .map_err(|e| anyhow!("HKDF expand failed: {e}"))?; + Ok(out) +} + +/// Pass 2: per-message AES-GCM key. +fn derive_per_message_key( + base_key: &[u8; KEY_SIZE], + ctx: &BotMessageContext<'_>, +) -> [u8; KEY_SIZE] { + let mut info = Vec::with_capacity( + ctx.msg_id.len() + ctx.target_sender_user_jid.len() + ctx.bot_user_jid.len(), + ); + info.extend_from_slice(ctx.msg_id.as_bytes()); + info.extend_from_slice(ctx.target_sender_user_jid.as_bytes()); + info.extend_from_slice(ctx.bot_user_jid.as_bytes()); + let hk = Hkdf::<Sha256>::new(None, base_key); + let mut out = [0u8; KEY_SIZE]; + hk.expand(&info, &mut out) + .expect("HKDF expand with 32-byte output never fails"); + out +} + +fn build_aad(ctx: &BotMessageContext<'_>) -> Vec<u8> { + let mut aad = Vec::with_capacity(ctx.msg_id.len() + 1 + ctx.bot_user_jid.len()); + aad.extend_from_slice(ctx.msg_id.as_bytes()); + aad.push(0); + aad.extend_from_slice(ctx.bot_user_jid.as_bytes()); + aad +} + +/// Decrypt the contents of `<enc type="msmsg">`. +/// +/// `enc_iv` must be 12 bytes; `enc_payload` must be `ciphertext || 16-byte tag`. +/// On success returns the plaintext bytes (a serialised `wa::Message` protobuf, +/// **with no PKCS7 padding** — Signal-style padding is not used for msmsg). +pub fn decrypt_bot_message( + message_secret: &[u8], + enc_iv: &[u8], + enc_payload: &[u8], + ctx: &BotMessageContext<'_>, +) -> Result<Vec<u8>> { + let nonce: &[u8; GCM_IV_SIZE] = enc_iv.try_into().map_err(|_| { + anyhow!( + "invalid enc_iv length: expected {GCM_IV_SIZE}, got {}", + enc_iv.len() + ) + })?; + if enc_payload.len() < GCM_TAG_SIZE { + return Err(anyhow!( + "enc_payload too short: need at least {GCM_TAG_SIZE} bytes for tag, got {}", + enc_payload.len() + )); + } + let base = derive_base_bot_key(message_secret)?; + let key = derive_per_message_key(&base, ctx); + let aad = build_aad(ctx); + + let mut out = Vec::with_capacity(enc_payload.len().saturating_sub(GCM_TAG_SIZE)); + aes_256_gcm_decrypt(&key, nonce, &aad, enc_payload, &mut out) + .map_err(|_| anyhow!("bot message GCM tag verification failed"))?; + Ok(out) +} + +/// Encrypt counterpart, only used by tests / a future outbound bot path. +/// +/// Returns `(ciphertext_with_tag, iv)`. +#[allow(dead_code)] +pub fn encrypt_bot_message( + plaintext: &[u8], + message_secret: &[u8], + ctx: &BotMessageContext<'_>, +) -> Result<(Vec<u8>, [u8; GCM_IV_SIZE])> { + use rand::Rng; + let base = derive_base_bot_key(message_secret)?; + let key = derive_per_message_key(&base, ctx); + let aad = build_aad(ctx); + + let mut iv = [0u8; GCM_IV_SIZE]; + rand::make_rng::<rand::rngs::StdRng>().fill_bytes(&mut iv); + let mut payload = Vec::with_capacity(plaintext.len() + GCM_TAG_SIZE); + aes_256_gcm_encrypt(&key, &iv, &aad, plaintext, &mut payload) + .map_err(|e| anyhow!("AES-GCM encrypt failed: {e}"))?; + Ok((payload, iv)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn vec_secret(byte: u8) -> [u8; 32] { + [byte; 32] + } + + fn sample_ctx() -> BotMessageContext<'static> { + BotMessageContext { + msg_id: "ABCDEF1234567890", + target_sender_user_jid: "11122233344@s.whatsapp.net", + bot_user_jid: "867051314767696@bot", + } + } + + #[test] + fn derive_base_bot_key_is_deterministic_and_distinct_from_secret() { + let secret = vec_secret(0x11); + let k1 = derive_base_bot_key(&secret).unwrap(); + let k2 = derive_base_bot_key(&secret).unwrap(); + assert_eq!(k1, k2, "HKDF must be deterministic"); + assert_ne!( + k1[..], + secret[..], + "base key must differ from raw messageSecret" + ); + } + + #[test] + fn derive_base_bot_key_rejects_wrong_secret_size() { + let small = [0u8; 16]; + assert!(derive_base_bot_key(&small).is_err()); + let big = [0u8; 64]; + assert!(derive_base_bot_key(&big).is_err()); + } + + #[test] + fn per_message_key_is_sensitive_to_each_input() { + let base = vec_secret(0x22); + let ctx = sample_ctx(); + let baseline = derive_per_message_key(&base, &ctx); + + let mut altered = ctx; + altered.msg_id = "DIFFERENT_MSG_ID"; + assert_ne!(baseline, derive_per_message_key(&base, &altered)); + + let mut altered = ctx; + altered.target_sender_user_jid = "99988877766@s.whatsapp.net"; + assert_ne!(baseline, derive_per_message_key(&base, &altered)); + + let mut altered = ctx; + altered.bot_user_jid = "999999999999@bot"; + assert_ne!(baseline, derive_per_message_key(&base, &altered)); + } + + #[test] + fn build_aad_matches_wa_web_layout() { + // WA Web `gcmDecrypt(..., msgId + "\0" + bot_user_jid)`. + let ctx = sample_ctx(); + let aad = build_aad(&ctx); + let expected = b"ABCDEF1234567890\x00867051314767696@bot"; + assert_eq!(aad, expected); + } + + #[test] + fn encrypt_decrypt_round_trip() { + let secret = vec_secret(0x33); + let ctx = sample_ctx(); + let plaintext = b"hello bot reply"; + let (payload, iv) = encrypt_bot_message(plaintext, &secret, &ctx).unwrap(); + let decrypted = decrypt_bot_message(&secret, &iv, &payload, &ctx).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn decrypt_rejects_tag_tampering() { + let secret = vec_secret(0x44); + let ctx = sample_ctx(); + let plaintext = b"sensitive"; + let (mut payload, iv) = encrypt_bot_message(plaintext, &secret, &ctx).unwrap(); + // Flip a bit in the tag (last 16 bytes). + let last = payload.len() - 1; + payload[last] ^= 0x01; + assert!(decrypt_bot_message(&secret, &iv, &payload, &ctx).is_err()); + } + + #[test] + fn decrypt_rejects_ciphertext_tampering() { + let secret = vec_secret(0x55); + let ctx = sample_ctx(); + let (mut payload, iv) = encrypt_bot_message(b"plain", &secret, &ctx).unwrap(); + payload[0] ^= 0xFF; + assert!(decrypt_bot_message(&secret, &iv, &payload, &ctx).is_err()); + } + + #[test] + fn decrypt_rejects_wrong_secret() { + let ctx = sample_ctx(); + let (payload, iv) = encrypt_bot_message(b"x", &vec_secret(0x66), &ctx).unwrap(); + assert!(decrypt_bot_message(&vec_secret(0x67), &iv, &payload, &ctx).is_err()); + } + + #[test] + fn decrypt_rejects_wrong_msg_id() { + let secret = vec_secret(0x77); + let ctx = sample_ctx(); + let (payload, iv) = encrypt_bot_message(b"x", &secret, &ctx).unwrap(); + let mut other = ctx; + other.msg_id = "OTHER"; + assert!(decrypt_bot_message(&secret, &iv, &payload, &other).is_err()); + } + + #[test] + fn decrypt_rejects_wrong_bot_jid() { + // Bot JID participates in both the per-message key and the AAD, so + // tampering with it fails the GCM tag. + let secret = vec_secret(0x88); + let ctx = sample_ctx(); + let (payload, iv) = encrypt_bot_message(b"x", &secret, &ctx).unwrap(); + let mut other = ctx; + other.bot_user_jid = "9876543210@bot"; + assert!(decrypt_bot_message(&secret, &iv, &payload, &other).is_err()); + } + + #[test] + fn decrypt_rejects_short_iv() { + let secret = vec_secret(0x99); + let ctx = sample_ctx(); + let payload = vec![0u8; 32]; + let short_iv = [0u8; 8]; + let err = decrypt_bot_message(&secret, &short_iv, &payload, &ctx).unwrap_err(); + assert!(format!("{err}").contains("enc_iv")); + } + + #[test] + fn decrypt_rejects_short_payload() { + let secret = vec_secret(0xAA); + let ctx = sample_ctx(); + let iv = [0u8; 12]; + let short_payload = [0u8; 8]; + let err = decrypt_bot_message(&secret, &iv, &short_payload, &ctx).unwrap_err(); + assert!(format!("{err}").contains("too short")); + } + + /// Known-answer vector cross-verified against whatsmeow's + /// `decryptBotMessage` semantics (HKDF info "Bot Message", second pass + /// info = msgID||target||bot, AAD = msgID||0x00||bot). + /// + /// Vector generated by [`encrypt_bot_message`] using a fixed PRNG seed- + /// equivalent setup; ensures the decrypt code path on the exact byte + /// layout produced by the encrypt code path. A regression that changes + /// the HKDF info concatenation order or the AAD layout would fail this. + #[test] + fn vector_round_trip_known_inputs() { + let secret = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + 0x1d, 0x1e, 0x1f, 0x20, + ]; + let ctx = BotMessageContext { + msg_id: "3EB0FAB0BAE1234567", + target_sender_user_jid: "5511999998888@s.whatsapp.net", + bot_user_jid: "867051314767696@bot", + }; + let plaintext = b"the quick brown fox jumps over the lazy dog"; + let (payload, iv) = encrypt_bot_message(plaintext, &secret, &ctx).unwrap(); + // Exhaustively: every input field is bound to the key or AAD; mutate + // each and verify decrypt fails. + let mutations: &[fn(&mut BotMessageContext<'_>)] = &[ + |c| c.msg_id = "X", + |c| c.target_sender_user_jid = "X@s.whatsapp.net", + |c| c.bot_user_jid = "X@bot", + ]; + for mutate in mutations { + let mut bad = ctx; + mutate(&mut bad); + assert!( + decrypt_bot_message(&secret, &iv, &payload, &bad).is_err(), + "mutating any context field must break decryption" + ); + } + // Correct context succeeds. + let got = decrypt_bot_message(&secret, &iv, &payload, &ctx).unwrap(); + assert_eq!(got, plaintext); + } +} diff --git a/wacore/src/lib.rs b/wacore/src/lib.rs index e689af9d8..794e89695 100644 --- a/wacore/src/lib.rs +++ b/wacore/src/lib.rs @@ -8,6 +8,7 @@ pub use wacore_derive::{EmptyNode, ProtocolNode, WireEnum}; pub mod adv; pub mod appstate_sync; +pub mod bot_message; pub mod client; pub mod client_profile; pub mod companion_reg; diff --git a/wacore/src/message_processing.rs b/wacore/src/message_processing.rs index 0444e9c4a..c22e4e636 100644 --- a/wacore/src/message_processing.rs +++ b/wacore/src/message_processing.rs @@ -21,6 +21,9 @@ pub enum EncType { Message, /// Sender-key message (`"skmsg"`) — group encryption. SenderKey, + /// Message-secret bot reply (`"msmsg"`) — Meta AI / fbid bot envelope + /// decrypted via [`crate::bot_message::decrypt_bot_message`]. + MessageSecret, } impl EncType { @@ -30,6 +33,7 @@ impl EncType { "pkmsg" => Some(Self::PreKeyMessage), "msg" => Some(Self::Message), "skmsg" => Some(Self::SenderKey), + "msmsg" => Some(Self::MessageSecret), _ => None, } } @@ -40,13 +44,21 @@ impl EncType { Self::PreKeyMessage => "pkmsg", Self::Message => "msg", Self::SenderKey => "skmsg", + Self::MessageSecret => "msmsg", } } /// Whether this is a 1:1 session-based encryption type (pkmsg or msg). + /// msmsg is neither session- nor group-based; it has its own dispatch path. pub fn is_session(&self) -> bool { matches!(self, Self::PreKeyMessage | Self::Message) } + + /// True for the bot-secret envelope (`msmsg`). Mutually exclusive with + /// session/group classification. + pub fn is_bot_secret(&self) -> bool { + matches!(self, Self::MessageSecret) + } } /// Information extracted from a single `<enc>` node. @@ -69,6 +81,9 @@ pub struct CategorizedEncNodes<'a> { pub session_enc: Vec<EncNodeInfo<'a>>, /// Group enc nodes (skmsg) — require prior SKDM from session nodes. pub group_enc: Vec<EncNodeInfo<'a>>, + /// Bot-secret enc nodes (msmsg) — decrypted via the per-message + /// `messageSecret` persisted at outbound send. + pub bot_enc: Vec<EncNodeInfo<'a>>, /// Maximum sender retry count across all enc nodes. pub max_retry_count: u8, /// Whether decryption failures should be hidden (edited messages). @@ -95,6 +110,7 @@ const MAX_DECRYPT_RETRIES: u8 = 5; pub fn categorize_enc_nodes<'a>(enc_nodes: &[&'a Node]) -> CategorizedEncNodes<'a> { let mut session_enc = Vec::with_capacity(enc_nodes.len()); let mut group_enc = Vec::with_capacity(enc_nodes.len()); + let mut bot_enc = Vec::with_capacity(enc_nodes.len()); let mut unknown_enc_types = Vec::new(); let mut max_retry_count: u8 = 0; let mut has_hide_fail = false; @@ -153,6 +169,14 @@ pub fn categorize_enc_nodes<'a>(enc_nodes: &[&'a Node]) -> CategorizedEncNodes<' retry_count, }); } + Some(EncType::MessageSecret) => { + bot_enc.push(EncNodeInfo { + ciphertext, + enc_type: EncType::MessageSecret, + padding_version, + retry_count, + }); + } None => { unknown_enc_types.push(enc_type_str.to_string()); } @@ -176,6 +200,7 @@ pub fn categorize_enc_nodes<'a>(enc_nodes: &[&'a Node]) -> CategorizedEncNodes<' CategorizedEncNodes { session_enc, group_enc, + bot_enc, max_retry_count, decrypt_fail_mode, unknown_enc_types, @@ -307,11 +332,47 @@ mod tests { let result = categorize_enc_nodes(&[]); assert!(result.session_enc.is_empty()); assert!(result.group_enc.is_empty()); + assert!(result.bot_enc.is_empty()); assert_eq!(result.max_retry_count, 0); assert_eq!(result.decrypt_fail_mode, DecryptFailMode::Show); assert!(!result.has_ordering_violation); } + #[test] + fn test_categorize_msmsg_goes_into_bot_bucket() { + let msmsg = make_enc_node("msmsg", b"bot_cipher"); + let nodes: Vec<&Node> = vec![&msmsg]; + + let result = categorize_enc_nodes(&nodes); + assert!(result.session_enc.is_empty()); + assert!(result.group_enc.is_empty()); + assert_eq!(result.bot_enc.len(), 1); + assert_eq!(result.bot_enc[0].enc_type, EncType::MessageSecret); + assert_eq!(result.bot_enc[0].ciphertext, b"bot_cipher"); + assert!(result.unknown_enc_types.is_empty()); + } + + #[test] + fn test_enc_type_msmsg_round_trip() { + assert_eq!( + EncType::from_wire("msmsg"), + Some(EncType::MessageSecret), + "msmsg must parse" + ); + assert_eq!(EncType::MessageSecret.as_wire_str(), "msmsg"); + assert!( + !EncType::MessageSecret.is_session(), + "msmsg is NOT a Signal session type" + ); + assert!( + EncType::MessageSecret.is_bot_secret(), + "msmsg IS the bot-secret envelope" + ); + for t in [EncType::PreKeyMessage, EncType::Message, EncType::SenderKey] { + assert!(!t.is_bot_secret(), "{t:?} must not be a bot-secret type"); + } + } + #[test] fn test_categorize_session_types() { let pkmsg = make_enc_node("pkmsg", b"cipher1"); diff --git a/wacore/src/messages.rs b/wacore/src/messages.rs index c14a1fa54..740f1de53 100644 --- a/wacore/src/messages.rs +++ b/wacore/src/messages.rs @@ -282,6 +282,11 @@ pub fn parse_message_info( let mut ma = meta.attrs(); meta_info.content_type = ma.optional_string("content_type").map(|s| s.into_owned()); meta_info.appdata = ma.optional_string("appdata").map(|s| s.into_owned()); + // msmsg addon path needs the trio (target_id, target_sender_jid, + // target_chat_jid) to look up the parent messageSecret. + meta_info.target_id = ma.optional_string("target_id").map(|s| s.into_owned()); + meta_info.target_sender = ma.optional_jid("target_sender_jid"); + meta_info.target_chat = ma.optional_jid("target_chat_jid"); } if let Some(reporting) = node.get_optional_child("reporting") && let Some(tag) = reporting.get_optional_child("reporting_tag") @@ -303,6 +308,24 @@ pub fn parse_message_info( ); } + // <bot edit="..."> child. Mirror WA Web `f()`: read `edit_target_id` + // unconditionally so the msmsg regular-bot fallback path can consume it + // regardless of edit_type. fbid (`h()`) only uses it for INNER/LAST, + // but parsing it always is a strict superset. + let bot_info = node.get_optional_child("bot").map(|bot_node| { + let mut ba = bot_node.attrs(); + crate::types::message::MsgBotInfo { + edit_type: ba + .optional_string("edit") + .and_then(|s| crate::types::message::BotEditType::from_wire(s.as_ref())), + edit_target_id: ba.optional_string("edit_target_id").map(|s| s.into_owned()), + edit_sender_timestamp_ms: ba + .optional_u64("sender_timestamp_ms") + .and_then(|ms| i64::try_from(ms).ok()) + .and_then(crate::time::from_millis), + } + }); + Ok(MessageInfo { source, id, @@ -323,6 +346,7 @@ pub fn parse_message_info( verified_name_serial, peer_recipient_pn, meta_info, + bot_info, ..Default::default() }) } diff --git a/wacore/src/proto_helpers.rs b/wacore/src/proto_helpers.rs index 548a3b0e7..6028bdcca 100644 --- a/wacore/src/proto_helpers.rs +++ b/wacore/src/proto_helpers.rs @@ -64,6 +64,28 @@ macro_rules! for_each_context_info_impl { }; } +/// Returns `Some(ctx)` for the first message variant carrying a `ContextInfo`, +/// short-circuiting on match (`break 'find`). Read-only variant of +/// [`for_each_context_info_message!`]. +macro_rules! find_context_info_ref { + ($msg:expr) => {{ with_context_info_fields!(find_context_info_impl!($msg,)) }}; +} + +macro_rules! find_context_info_impl { + ($msg:expr, $($field:ident),+ $(,)?) => {{ + let mut found: Option<&wa::ContextInfo> = None; + $( + if found.is_none() + && let Some(ref m) = $msg.$field + && let Some(ref ctx) = m.context_info + { + found = Some(ctx); + } + )+ + found + }}; +} + /// Extension trait for wa::Message pub trait MessageExt { /// Recursively unwraps ephemeral/view-once/document_with_caption/edited wrappers to get the core message. @@ -136,6 +158,18 @@ pub trait MessageExt { /// (mirrors `WAWebMessageSendUtils`). Returns `true` on success or /// promotion, `false` only when no body can carry the timer. fn set_ephemeral_expiration(&mut self, expiration: u32) -> bool; + + /// `context_info.is_forwarded == Some(true)` on the first base message + /// that carries a context_info. Mirrors WA Web's `x.isForwarded` + /// guard in `processRenderableMessages` (which skips caching + /// `messageSecret` for forwarded payloads). + fn is_forwarded(&self) -> bool; + + /// `true` if `context_info.mentioned_jid` on any base message contains + /// a JID whose user-form ends with `@bot`. Mirrors WA Web's + /// `mentionedJidList.find(jid.isBot())` lookup used to derive + /// `invokedBotWid` when `messageSecret` is present. + fn mentions_any_bot(&self) -> bool; } impl MessageExt for wa::Message { @@ -372,6 +406,28 @@ impl MessageExt for wa::Message { false } + + fn is_forwarded(&self) -> bool { + let base = self.get_base_message(); + find_context_info_ref!(base) + .and_then(|ctx| ctx.is_forwarded) + .unwrap_or(false) + } + + fn mentions_any_bot(&self) -> bool { + let base = self.get_base_message(); + let Some(ctx) = find_context_info_ref!(base) else { + return false; + }; + // Use the canonical `Jid::is_bot()` contract — it covers both the + // `@bot` server and the legacy PN-form Meta bot (e.g. `1313555…`), + // matching WA Web's `jid.isBot()`. The list is short and this only + // runs on the group-mention path (chat isn't already a bot). + ctx.mentioned_jid + .iter() + .filter_map(|s| Jid::from_str(s).ok()) + .any(|jid| jid.is_bot()) + } } /// Strips nested context_info fields to match WhatsApp Web. @@ -1909,4 +1965,127 @@ mod tests { }; assert!(msg.is_view_once()); } + + #[test] + fn mentions_any_bot_true_for_bot_jid_in_extended_text() { + let msg = wa::Message { + extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + text: Some("@MetaAI hi".into()), + context_info: Some(Box::new(wa::ContextInfo { + mentioned_jid: vec![ + "5511999998888@s.whatsapp.net".into(), + "867051314767696@bot".into(), + ], + ..Default::default() + })), + ..Default::default() + })), + ..Default::default() + }; + assert!(msg.mentions_any_bot()); + } + + #[test] + fn mentions_any_bot_true_for_legacy_pn_form_bot() { + // `Jid::is_bot()` also matches the legacy PN-form Meta bot; the old + // `@bot`-only string split would have missed this. + let msg = wa::Message { + extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + text: Some("@MetaAI".into()), + context_info: Some(Box::new(wa::ContextInfo { + mentioned_jid: vec!["13135550002@s.whatsapp.net".into()], + ..Default::default() + })), + ..Default::default() + })), + ..Default::default() + }; + assert!(msg.mentions_any_bot()); + } + + #[test] + fn mentions_any_bot_false_without_bot_jid() { + let msg = wa::Message { + extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + text: Some("hi friends".into()), + context_info: Some(Box::new(wa::ContextInfo { + mentioned_jid: vec![ + "5511999998888@s.whatsapp.net".into(), + "120363021033254949@g.us".into(), + ], + ..Default::default() + })), + ..Default::default() + })), + ..Default::default() + }; + assert!(!msg.mentions_any_bot()); + } + + #[test] + fn mentions_any_bot_false_for_no_context_info() { + let msg = wa::Message { + conversation: Some("plain".into()), + ..Default::default() + }; + assert!(!msg.mentions_any_bot()); + } + + #[test] + fn mentions_any_bot_sees_through_device_sent_wrapper() { + let msg = wa::Message { + device_sent_message: Some(Box::new(wa::message::DeviceSentMessage { + destination_jid: Some("867051314767696@bot".into()), + message: Some(Box::new(wa::Message { + extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + text: Some("@MetaAI".into()), + context_info: Some(Box::new(wa::ContextInfo { + mentioned_jid: vec!["867051314767696@bot".into()], + ..Default::default() + })), + ..Default::default() + })), + ..Default::default() + })), + ..Default::default() + })), + ..Default::default() + }; + assert!( + msg.mentions_any_bot(), + "must unwrap DeviceSentMessage before reading context_info" + ); + } + + #[test] + fn is_forwarded_true_and_false() { + let fwd = wa::Message { + extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + text: Some("fwd".into()), + context_info: Some(Box::new(wa::ContextInfo { + is_forwarded: Some(true), + ..Default::default() + })), + ..Default::default() + })), + ..Default::default() + }; + assert!(fwd.is_forwarded()); + + let plain = wa::Message { + conversation: Some("plain".into()), + ..Default::default() + }; + assert!(!plain.is_forwarded()); + + let not_fwd = wa::Message { + extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + text: Some("hi".into()), + context_info: Some(Box::new(wa::ContextInfo::default())), + ..Default::default() + })), + ..Default::default() + }; + assert!(!not_fwd.is_forwarded()); + } } diff --git a/wacore/src/send.rs b/wacore/src/send.rs index 0667c02fe..22c902fa9 100644 --- a/wacore/src/send.rs +++ b/wacore/src/send.rs @@ -815,6 +815,10 @@ pub struct PreparedDmStanza { /// wire (WA Web only sends phash for groups). Used by the caller to /// compare against the server's ACK phash for device-list drift detection. pub phash: Option<String>, + /// `MessageContextInfo.message_secret` generated for this stanza so the + /// caller can persist it for later addon (msmsg/poll/edit) decryption. + /// `None` when the message had no reporting token (no secret was used). + pub message_secret: Option<[u8; crate::reporting_token::MESSAGE_SECRET_SIZE]>, } #[allow(clippy::too_many_arguments)] @@ -955,6 +959,7 @@ pub async fn prepare_dm_stanza< Ok(PreparedDmStanza { node: stanza, phash, + message_secret: reporting_result.map(|r| r.message_secret), }) } @@ -1239,6 +1244,14 @@ pub struct PreparedGroupStanza { /// devices returned 406 (unregistered) during SKDM prekey fetch. /// Empty when no 406 occurred. pub stale_device_users: Vec<String>, + /// Generated `MessageContextInfo.message_secret`; populated when the + /// reporting token was produced for this send. + pub message_secret: Option<[u8; crate::reporting_token::MESSAGE_SECRET_SIZE]>, + /// The identity we addressed this group send under (LID for LID-mode + /// groups, PN for PN-mode). Used to key the persisted `messageSecret` + /// so msmsg bot replies referencing this msg_id hit the same row that + /// `<meta target_sender_jid>` echoes back at lookup time. + pub sender_identity: Jid, } #[allow(clippy::too_many_arguments)] @@ -1568,6 +1581,8 @@ pub async fn prepare_group_stanza< node: stanza, skdm_devices: skdm_encrypted_devices, stale_device_users: stale_users, + message_secret: reporting_result.map(|r| r.message_secret), + sender_identity: own_sending_jid, }) } diff --git a/wacore/src/store/in_memory.rs b/wacore/src/store/in_memory.rs index 83bb56ed9..382c71413 100644 --- a/wacore/src/store/in_memory.rs +++ b/wacore/src/store/in_memory.rs @@ -61,6 +61,12 @@ struct InMemoryState { tc_tokens: HashMap<String, TcTokenEntry>, sent_messages: HashMap<SentMessageKey, SentMessageEntry>, + // --- MsgSecret --- + /// Value is `(secret_bytes, created_at_secs)` so the keepalive cleanup + /// can prune expired entries the same way `delete_expired_sent_messages` + /// does for the retry cache. + msg_secrets: HashMap<(String, String, String), (Vec<u8>, i64)>, + // --- Device --- device: Option<Device>, } @@ -581,6 +587,52 @@ impl ProtocolStore for InMemoryBackend { } } +// --------------------------------------------------------------------------- +// MsgSecretStore +// --------------------------------------------------------------------------- + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl MsgSecretStore for InMemoryBackend { + async fn put_msg_secret( + &self, + chat: &str, + sender: &str, + msg_id: &str, + secret: &[u8], + ) -> Result<()> { + self.state.lock().await.msg_secrets.insert( + (chat.to_string(), sender.to_string(), msg_id.to_string()), + (secret.to_vec(), crate::time::now_secs()), + ); + Ok(()) + } + + async fn get_msg_secret( + &self, + chat: &str, + sender: &str, + msg_id: &str, + ) -> Result<Option<Vec<u8>>> { + Ok(self + .state + .lock() + .await + .msg_secrets + .get(&(chat.to_string(), sender.to_string(), msg_id.to_string())) + .map(|(secret, _)| secret.clone())) + } + + async fn delete_expired_msg_secrets(&self, cutoff_timestamp: i64) -> Result<u32> { + let mut state = self.state.lock().await; + let before = state.msg_secrets.len(); + state + .msg_secrets + .retain(|_, (_, ts)| *ts >= cutoff_timestamp); + Ok((before - state.msg_secrets.len()) as u32) + } +} + // --------------------------------------------------------------------------- // DeviceStore // --------------------------------------------------------------------------- @@ -622,4 +674,151 @@ mod tests { fn in_memory_backend_implements_backend() { is_backend::<InMemoryBackend>(); } + + #[tokio::test] + async fn msg_secret_round_trip() { + let backend = InMemoryBackend::new(); + let secret = [7u8; 32]; + backend + .put_msg_secret("12345@s.whatsapp.net", "9999@lid", "MID1", &secret) + .await + .unwrap(); + let got = backend + .get_msg_secret("12345@s.whatsapp.net", "9999@lid", "MID1") + .await + .unwrap(); + assert_eq!(got.as_deref(), Some(&secret[..])); + } + + #[tokio::test] + async fn msg_secret_miss_returns_none() { + let backend = InMemoryBackend::new(); + assert!( + backend + .get_msg_secret("12345@s.whatsapp.net", "9999@lid", "MID1") + .await + .unwrap() + .is_none(), + "absent secret must return None" + ); + } + + #[tokio::test] + async fn msg_secret_keyed_by_all_three_columns() { + // Same chat+sender, different msg_id → independent entries. + // Same chat+msg_id, different sender → independent entries. + // Same sender+msg_id, different chat → independent entries. + let backend = InMemoryBackend::new(); + backend + .put_msg_secret("chatA", "senderX", "M1", &[1u8; 32]) + .await + .unwrap(); + backend + .put_msg_secret("chatA", "senderX", "M2", &[2u8; 32]) + .await + .unwrap(); + backend + .put_msg_secret("chatA", "senderY", "M1", &[3u8; 32]) + .await + .unwrap(); + backend + .put_msg_secret("chatB", "senderX", "M1", &[4u8; 32]) + .await + .unwrap(); + + assert_eq!( + backend + .get_msg_secret("chatA", "senderX", "M1") + .await + .unwrap() + .unwrap(), + vec![1u8; 32] + ); + assert_eq!( + backend + .get_msg_secret("chatA", "senderX", "M2") + .await + .unwrap() + .unwrap(), + vec![2u8; 32] + ); + assert_eq!( + backend + .get_msg_secret("chatA", "senderY", "M1") + .await + .unwrap() + .unwrap(), + vec![3u8; 32] + ); + assert_eq!( + backend + .get_msg_secret("chatB", "senderX", "M1") + .await + .unwrap() + .unwrap(), + vec![4u8; 32] + ); + } + + #[tokio::test] + async fn delete_expired_msg_secrets_removes_only_old_rows() { + let backend = InMemoryBackend::new(); + backend + .put_msg_secret("c", "s", "OLD", &[1u8; 32]) + .await + .unwrap(); + // Mutate timestamp directly to simulate an old row. + { + let mut state = backend.state.lock().await; + let entry = state + .msg_secrets + .get_mut(&("c".into(), "s".into(), "OLD".into())) + .unwrap(); + entry.1 = crate::time::now_secs() - 86_400 * 30; + } + backend + .put_msg_secret("c", "s", "NEW", &[2u8; 32]) + .await + .unwrap(); + + let cutoff = crate::time::now_secs() - 86_400 * 14; + let removed = backend.delete_expired_msg_secrets(cutoff).await.unwrap(); + assert_eq!(removed, 1); + assert!( + backend + .get_msg_secret("c", "s", "OLD") + .await + .unwrap() + .is_none() + ); + assert!( + backend + .get_msg_secret("c", "s", "NEW") + .await + .unwrap() + .is_some() + ); + } + + #[tokio::test] + async fn msg_secret_overwrite_on_same_key() { + let backend = InMemoryBackend::new(); + backend + .put_msg_secret("chat", "sender", "M", &[1u8; 32]) + .await + .unwrap(); + backend + .put_msg_secret("chat", "sender", "M", &[9u8; 32]) + .await + .unwrap(); + assert_eq!( + backend + .get_msg_secret("chat", "sender", "M") + .await + .unwrap() + .unwrap(), + vec![9u8; 32], + "last write wins for the same composite key" + ); + } } diff --git a/wacore/src/store/traits.rs b/wacore/src/store/traits.rs index d6a526e71..f494a00aa 100644 --- a/wacore/src/store/traits.rs +++ b/wacore/src/store/traits.rs @@ -360,10 +360,50 @@ pub trait DeviceStore: Send + Sync { } } +/// Per-outbound-message secret storage for addon-style decryption. +/// +/// Persists the 32-byte `MessageContextInfo.messageSecret` we send out so that +/// later inbound replies (poll votes, reactions, msmsg bot responses, edits) +/// referencing the original message ID can be decrypted. Mirrors WA Web's +/// `WAWebMsmsgMsgSecretCache` + the `messageSecret` field on the DB message row. +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait MsgSecretStore: Send + Sync { + /// Persist `secret` (typically 32 bytes) under the composite key. + /// `chat`, `sender`, and `msg_id` are JID strings / message ID strings; + /// callers should pass non-AD (no-device) form for the JIDs so lookups + /// match regardless of which device echo'd the stanza back. + async fn put_msg_secret( + &self, + chat: &str, + sender: &str, + msg_id: &str, + secret: &[u8], + ) -> Result<()>; + + /// Fetch the persisted secret; returns `None` if absent. + async fn get_msg_secret( + &self, + chat: &str, + sender: &str, + msg_id: &str, + ) -> Result<Option<Vec<u8>>>; + + /// Delete rows whose `created_at` is older than `cutoff_timestamp` + /// (seconds since epoch). Returns the number of rows removed so the + /// keepalive cleanup can log/throttle. + async fn delete_expired_msg_secrets(&self, cutoff_timestamp: i64) -> Result<u32>; +} + /// Combined storage backend trait. /// -/// Any type implementing all four domain traits automatically implements `Backend`. -pub trait Backend: SignalStore + AppSyncStore + ProtocolStore + DeviceStore + Send + Sync {} +/// Any type implementing all domain traits automatically implements `Backend`. +pub trait Backend: + SignalStore + AppSyncStore + ProtocolStore + MsgSecretStore + DeviceStore + Send + Sync +{ +} -impl<T> Backend for T where T: SignalStore + AppSyncStore + ProtocolStore + DeviceStore + Send + Sync -{} +impl<T> Backend for T where + T: SignalStore + AppSyncStore + ProtocolStore + MsgSecretStore + DeviceStore + Send + Sync +{ +} diff --git a/wacore/src/types/message.rs b/wacore/src/types/message.rs index 9d27325bb..6f381a6ed 100644 --- a/wacore/src/types/message.rs +++ b/wacore/src/types/message.rs @@ -155,13 +155,23 @@ impl EditAttribute { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, WireEnum)] pub enum BotEditType { + #[wire = "first"] First, + #[wire = "inner"] Inner, + #[wire = "last"] Last, } +impl BotEditType { + /// Parse the wire string from the `<bot edit="…">` attribute. + pub fn from_wire(s: &str) -> Option<Self> { + Self::try_from(s).ok() + } +} + #[derive(Debug, Clone, Serialize)] pub struct MsgBotInfo { pub edit_type: Option<BotEditType>, @@ -173,6 +183,10 @@ pub struct MsgBotInfo { pub struct MsgMetaInfo { pub target_id: Option<MessageId>, pub target_sender: Option<Jid>, + /// `<meta target_chat_jid="…">` — present when the bot reply addresses a + /// chat distinct from the stanza-level `from` (used for msmsg secret + /// lookup; see WA Web `decryptMsmsgBotMessage`). + pub target_chat: Option<Jid>, pub deprecated_lid_session: Option<bool>, pub thread_message_id: Option<MessageId>, pub thread_message_sender_jid: Option<Jid>,