diff --git a/src/features/comments.rs b/src/features/comments.rs new file mode 100644 index 000000000..cc979c04b --- /dev/null +++ b/src/features/comments.rs @@ -0,0 +1,136 @@ +//! Encrypted channel comments (threaded replies under a Community +//! Announcement Group post). +//! +//! Mirrors WA Web `WAWebSendCommentMessageAction`: the comment body is a +//! regular `Message` (extended text), encrypted with the parent post's +//! `messageSecret` under the `"Enc Comment"` use-case, and shipped as a +//! top-level `enc_comment_message` envelope. The comment carries its own +//! fresh `messageSecret` so it can itself receive reactions. +//! +//! Incoming comments are decrypted transparently on the receive path and +//! dispatched as their inner body `Message`; the parent post key surfaces on +//! `MessageInfo::comment_target`. + +use anyhow::{Result, anyhow}; +use wacore_binary::Jid; +use waproto::whatsapp as wa; + +use crate::client::Client; +use crate::send::SendResult; + +pub struct Comments<'a> { + client: &'a Client, +} + +impl<'a> Comments<'a> { + pub(crate) fn new(client: &'a Client) -> Self { + Self { client } + } + + /// Comment on a channel post with a text body. + /// + /// `parent_key` references the post being commented on and must carry + /// `participant` (the post author) so receivers can key the decryption. + /// Requires the parent's `messageSecret` (captured when the post was + /// received). + pub async fn send_text( + &self, + chat: &Jid, + parent_key: wa::MessageKey, + text: &str, + ) -> Result { + // WA Web encryptExtendedTextComment: the body is an extendedTextMessage. + let body = wa::Message { + extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + text: Some(text.to_string()), + ..Default::default() + })), + ..Default::default() + }; + self.send_message(chat, parent_key, body).await + } + + /// Comment on a channel post with an arbitrary body `Message`. + pub async fn send_message( + &self, + chat: &Jid, + mut parent_key: wa::MessageKey, + body: wa::Message, + ) -> Result { + let client = self.client; + let (author, secret) = client + .resolve_outgoing_addon_parent(chat, &parent_key) + .await?; + let parent_id = parent_key + .id + .clone() + .ok_or_else(|| anyhow!("parent message key missing id"))?; + // WA Web comments are authored under the LID identity + // (getMeLidUserOrThrow); fall back to PN only when no LID is known. + let commenter = client + .get_lid() + .or_else(|| client.get_pn()) + .map(|j| j.to_non_ad()) + .ok_or_else(|| anyhow!("not logged in"))?; + + let (enc_payload, iv) = wacore::comment::encrypt_comment_with_secret( + &body, + &secret, + &parent_id, + &author.to_non_ad_string(), + &commenter.to_non_ad_string(), + )?; + + // Receivers resolve the parent author from the envelope key, so it + // must carry the same identity the HKDF was derived with. + if parent_key.participant.is_none() { + parent_key.participant = Some(author.to_non_ad_string()); + } + + // Fresh secret so the comment can itself receive encrypted add-ons. + let comment_secret: Vec = { + use rand::Rng; + let mut secret = vec![0u8; 32]; + rand::make_rng::().fill_bytes(&mut secret); + secret + }; + + let message = wa::Message { + enc_comment_message: Some(wa::message::EncCommentMessage { + target_message_key: Some(parent_key), + enc_payload: Some(enc_payload), + enc_iv: Some(iv.to_vec()), + }), + message_context_info: Some(wa::MessageContextInfo { + message_secret: Some(comment_secret.clone()), + ..Default::default() + }), + ..Default::default() + }; + let result = client.send_message(chat.clone(), message).await?; + + // The send path only persists reporting-token secrets, so store the + // comment's own secret here or we could never decrypt add-ons + // targeting our own comment. + let secret: [u8; 32] = comment_secret + .as_slice() + .try_into() + .expect("comment secret is 32 bytes"); + client + .persist_outbound_msg_secret( + chat, + &commenter, + &result.message_id, + &secret, + wacore::msg_secret::RetentionClass::Text, + ) + .await; + Ok(result) + } +} + +impl Client { + pub fn comments(&self) -> Comments<'_> { + Comments::new(self) + } +} diff --git a/src/features/groups.rs b/src/features/groups.rs index aa4457d1f..cee43f174 100644 --- a/src/features/groups.rs +++ b/src/features/groups.rs @@ -297,6 +297,7 @@ impl<'a> Groups<'a> { } let mut info = GroupInfo::new(participants, group.addressing_mode); + info.is_community_announce = Some(group.is_default_sub_group); if !lid_to_pn_map.is_empty() { info.set_lid_to_pn_map(lid_to_pn_map); } diff --git a/src/features/message_edit.rs b/src/features/message_edit.rs index cc99873fc..21da0de9f 100644 --- a/src/features/message_edit.rs +++ b/src/features/message_edit.rs @@ -230,6 +230,12 @@ pub enum SecretEncKind { MessageEdit, PollEdit, PollAddOption, + /// `enc_reaction_message` (CAG reaction): a distinct top-level field, not a + /// `SecretEncType`; the inner plaintext is a `ReactionMessage`, not a `Message`. + EncReaction, + /// `enc_comment_message` (CAG channel comment): distinct top-level field; + /// the inner plaintext is the comment body `Message`. + EncComment, } impl SecretEncKind { @@ -250,6 +256,8 @@ impl SecretEncKind { Self::MessageEdit => ModificationType::MessageEdit, Self::PollEdit => ModificationType::PollEdit, Self::PollAddOption => ModificationType::PollAddOption, + Self::EncReaction => ModificationType::EncReaction, + Self::EncComment => ModificationType::EncComment, } } } @@ -315,13 +323,43 @@ impl<'a> SecretEncrypted<'a> { /// Returns `None` when the message is not secret-encrypted, carries an /// unsupported type, or is malformed (missing fields, IV not 12 bytes). pub fn extract_secret_encrypted(msg: &wa::Message) -> Option> { - let sec = msg.secret_encrypted_message.as_ref()?; - let kind = SecretEncKind::from_proto(sec.secret_enc_type())?; - match ( - sec.target_message_key.as_ref(), - sec.enc_payload.as_deref(), - sec.enc_iv.as_deref(), - ) { + if let Some(sec) = msg.secret_encrypted_message.as_ref() { + let kind = SecretEncKind::from_proto(sec.secret_enc_type())?; + return secret_envelope( + kind, + sec.target_message_key.as_ref(), + sec.enc_payload.as_deref(), + sec.enc_iv.as_deref(), + ); + } + if let Some(enc) = msg.enc_reaction_message.as_ref() { + return secret_envelope( + SecretEncKind::EncReaction, + enc.target_message_key.as_ref(), + enc.enc_payload.as_deref(), + enc.enc_iv.as_deref(), + ); + } + if let Some(enc) = msg.enc_comment_message.as_ref() { + return secret_envelope( + SecretEncKind::EncComment, + enc.target_message_key.as_ref(), + enc.enc_payload.as_deref(), + enc.enc_iv.as_deref(), + ); + } + None +} + +/// Validate the shared `{target_message_key, enc_payload, enc_iv}` envelope +/// shape (all three present, 12-byte IV) for any addon kind. +fn secret_envelope<'a>( + kind: SecretEncKind, + target_message_key: Option<&'a wa::MessageKey>, + enc_payload: Option<&'a [u8]>, + enc_iv: Option<&'a [u8]>, +) -> Option> { + match (target_message_key, enc_payload, enc_iv) { (Some(tk), Some(payload), Some(iv)) if iv.len() == 12 => Some(SecretEncrypted { kind, enc_payload: payload, @@ -353,18 +391,47 @@ pub fn decrypt_secret_encrypted( ) -> Result { 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, - editor_jid: &sender, - }; - message_edit::decrypt_secret_encrypted( - enc_payload, - enc_iv, - message_secret, - kind.modification_type(), - &ctx, - ) + match kind { + // The reaction plaintext is a ReactionMessage, not a Message; surface + // it in the plaintext-reaction shape (key filled by the caller from + // the envelope's target_message_key). + SecretEncKind::EncReaction => { + let reaction = wacore::reaction::decrypt_reaction_with_secret( + enc_payload, + enc_iv, + message_secret, + original_msg_id, + &orig, + &sender, + )?; + Ok(wa::Message { + reaction_message: Some(reaction), + ..Default::default() + }) + } + SecretEncKind::EncComment => wacore::comment::decrypt_comment_with_secret( + enc_payload, + enc_iv, + message_secret, + original_msg_id, + &orig, + &sender, + ), + _ => { + let ctx = MessageEditContext { + original_msg_id, + original_sender_jid: &orig, + editor_jid: &sender, + }; + message_edit::decrypt_secret_encrypted( + enc_payload, + enc_iv, + message_secret, + kind.modification_type(), + &ctx, + ) + } + } } /// [`decrypt_secret_encrypted`] with a LID↔PN fallback addressing, mirroring @@ -381,6 +448,58 @@ pub fn decrypt_secret_encrypted_with_fallback( fallback_original_sender: Option<&Jid>, fallback_modification_sender: Option<&Jid>, ) -> Result { + // The reaction/comment kinds decode a different inner proto, so they go + // through the per-kind dispatch instead of the wacore Message-only helper. + // Like the receive path, every distinct LID/PN combination is attempted: + // a migration case can need the alternate on only ONE side of the HKDF. + if matches!(kind, SecretEncKind::EncReaction | SecretEncKind::EncComment) { + let mut last_err = match decrypt_secret_encrypted( + enc_payload, + enc_iv, + message_secret, + kind, + original_msg_id, + original_sender_jid, + modification_sender_jid, + ) { + Ok(inner) => return Ok(inner), + Err(e) => e, + }; + + let combos = [ + (fallback_original_sender, Some(modification_sender_jid)), + (Some(original_sender_jid), fallback_modification_sender), + (fallback_original_sender, fallback_modification_sender), + ]; + let mut tried: Vec<(Jid, Jid)> = vec![( + original_sender_jid.to_non_ad(), + modification_sender_jid.to_non_ad(), + )]; + for (orig, sender) in combos { + let (Some(orig), Some(sender)) = (orig, sender) else { + continue; + }; + let pair = (orig.to_non_ad(), sender.to_non_ad()); + if tried.contains(&pair) { + continue; + } + match decrypt_secret_encrypted( + enc_payload, + enc_iv, + message_secret, + kind, + original_msg_id, + orig, + sender, + ) { + Ok(inner) => return Ok(inner), + Err(e) => last_err = anyhow!("{last_err}; fallback: {e}"), + } + tried.push(pair); + } + return Err(last_err); + } + let orig = original_sender_jid.to_non_ad_string(); let sender = modification_sender_jid.to_non_ad_string(); let primary = MessageEditContext { @@ -899,3 +1018,186 @@ mod tests { assert_eq!(out.conversation.as_deref(), Some("poll edited")); } } + +#[cfg(test)] +mod enc_addon_tests { + use super::*; + + fn key(id: &str) -> wa::MessageKey { + wa::MessageKey { + id: Some(id.to_string()), + ..Default::default() + } + } + + #[test] + fn extract_recognises_enc_reaction_and_comment_envelopes() { + let reaction = wa::Message { + enc_reaction_message: Some(wa::message::EncReactionMessage { + target_message_key: Some(key("PARENT1")), + enc_payload: Some(vec![0; 32]), + enc_iv: Some(vec![0; 12]), + }), + ..Default::default() + }; + let env = extract_secret_encrypted(&reaction).expect("reaction recognised"); + assert_eq!(env.kind, SecretEncKind::EncReaction); + assert_eq!(env.target_id(), Some("PARENT1")); + + let comment = wa::Message { + enc_comment_message: Some(wa::message::EncCommentMessage { + target_message_key: Some(key("PARENT2")), + enc_payload: Some(vec![0; 32]), + enc_iv: Some(vec![0; 12]), + }), + ..Default::default() + }; + let env = extract_secret_encrypted(&comment).expect("comment recognised"); + assert_eq!(env.kind, SecretEncKind::EncComment); + assert_eq!(env.target_id(), Some("PARENT2")); + } + + #[test] + fn extract_rejects_malformed_enc_reaction_envelope() { + let bad_iv = wa::Message { + enc_reaction_message: Some(wa::message::EncReactionMessage { + target_message_key: Some(key("PARENT1")), + enc_payload: Some(vec![0; 32]), + enc_iv: Some(vec![0; 8]), + }), + ..Default::default() + }; + assert!(extract_secret_encrypted(&bad_iv).is_none()); + + let no_key = wa::Message { + enc_reaction_message: Some(wa::message::EncReactionMessage { + target_message_key: None, + enc_payload: Some(vec![0; 32]), + enc_iv: Some(vec![0; 12]), + }), + ..Default::default() + }; + assert!(extract_secret_encrypted(&no_key).is_none()); + } + + #[test] + fn enc_reaction_decrypts_via_kind_dispatch_with_fallback() { + let secret = [0x21u8; 32]; + let author: Jid = "5511000000001@s.whatsapp.net".parse().unwrap(); + let author_lid: Jid = "111111111111111@lid".parse().unwrap(); + let reactor: Jid = "5511000000002@s.whatsapp.net".parse().unwrap(); + + // Encrypted under the author's LID identity; the primary (PN) attempt + // must fail and the LID fallback succeed. + let (enc, iv) = wacore::reaction::encrypt_reaction_with_secret( + "\u{2764}", + 42, + &secret, + "PARENT1", + &author_lid.to_non_ad_string(), + &reactor.to_non_ad_string(), + ) + .unwrap(); + + let out = decrypt_secret_encrypted_with_fallback( + &enc, + &iv, + &secret, + SecretEncKind::EncReaction, + "PARENT1", + &author, + &reactor, + Some(&author_lid), + None, + ) + .expect("fallback identity must decrypt"); + let rm = out.reaction_message.expect("reaction shape"); + assert_eq!(rm.text.as_deref(), Some("\u{2764}")); + + // Without a distinct fallback the primary error surfaces. + assert!( + decrypt_secret_encrypted_with_fallback( + &enc, + &iv, + &secret, + SecretEncKind::EncReaction, + "PARENT1", + &author, + &reactor, + None, + None, + ) + .is_err() + ); + + // Mixed combo on the OTHER side: encrypted under the modifier's LID + // while the parent author is already in the right namespace. + let reactor_lid: Jid = "222222222222222@lid".parse().unwrap(); + let (enc2, iv2) = wacore::reaction::encrypt_reaction_with_secret( + "\u{1F44D}", + 43, + &secret, + "PARENT1", + &author.to_non_ad_string(), + &reactor_lid.to_non_ad_string(), + ) + .unwrap(); + let out = decrypt_secret_encrypted_with_fallback( + &enc2, + &iv2, + &secret, + SecretEncKind::EncReaction, + "PARENT1", + &author, + &reactor, + Some(&author_lid), + Some(&reactor_lid), + ) + .expect("primary-author + fallback-modifier combination must decrypt"); + assert_eq!( + out.reaction_message + .as_ref() + .and_then(|r| r.text.as_deref()), + Some("\u{1F44D}") + ); + } + + #[test] + fn enc_comment_decrypts_to_inner_body() { + let secret = [0x22u8; 32]; + let author: Jid = "5511000000001@s.whatsapp.net".parse().unwrap(); + let commenter: Jid = "5511000000002@s.whatsapp.net".parse().unwrap(); + let body = wa::Message { + extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + text: Some("hi".to_string()), + ..Default::default() + })), + ..Default::default() + }; + let (enc, iv) = wacore::comment::encrypt_comment_with_secret( + &body, + &secret, + "PARENT1", + &author.to_non_ad_string(), + &commenter.to_non_ad_string(), + ) + .unwrap(); + + let out = decrypt_secret_encrypted( + &enc, + &iv, + &secret, + SecretEncKind::EncComment, + "PARENT1", + &author, + &commenter, + ) + .expect("comment decrypts"); + assert_eq!( + out.extended_text_message + .as_ref() + .and_then(|m| m.text.as_deref()), + Some("hi") + ); + } +} diff --git a/src/features/mod.rs b/src/features/mod.rs index 9564d22d8..4e586d316 100644 --- a/src/features/mod.rs +++ b/src/features/mod.rs @@ -1,6 +1,7 @@ mod blocking; pub(crate) mod chat_actions; mod chatstate; +mod comments; mod community; mod contacts; mod events; @@ -29,6 +30,8 @@ pub use community::{ pub use chatstate::{ChatStateType, Chatstate}; +pub use comments::Comments; + pub use contacts::{ Contacts, IsOnWhatsAppResult, ProfilePicture, UserInfo, UsyncSubprotocolError, VerifiedName, }; diff --git a/src/features/reaction.rs b/src/features/reaction.rs index ae83b389f..0f072c1a0 100644 --- a/src/features/reaction.rs +++ b/src/features/reaction.rs @@ -2,8 +2,14 @@ //! //! Newsletter reactions go through a different (plaintext) wire path; use //! [`Client::newsletter`]'s `send_reaction` for channels. +//! +//! Community Announcement Groups never accept plaintext reactions: WA Web +//! (`WAWebReactionEncryptMsgData`) encrypts the reaction with the target +//! message's `messageSecret` and emits an `enc_reaction_message` envelope. +//! [`Client::send_reaction`] applies the same gate transparently. -use wacore_binary::Jid; +use anyhow::anyhow; +use wacore_binary::{Jid, JidExt}; use waproto::whatsapp as wa; use crate::client::Client; @@ -18,6 +24,12 @@ impl Client { /// from the incoming message. An empty `emoji` removes a previous reaction /// (WA Web's empty-text reaction == sender-revoke). /// + /// For a Community Announcement Group the reaction is encrypted with the + /// target's `messageSecret` (captured when the message was received) and + /// sent as `enc_reaction_message`; reacting to a message whose secret was + /// never captured fails rather than emitting a plaintext reaction the + /// channel would reject. + /// /// status@broadcast reactions fan out to the status author's devices; the /// author is read from `target_key.participant` by the send path. pub async fn send_reaction( @@ -26,6 +38,9 @@ impl Client { target_key: wa::MessageKey, emoji: &str, ) -> Result { + if chat.is_group() && self.is_community_announce_group(chat).await? { + return self.send_enc_reaction(chat, target_key, emoji).await; + } let reaction = wacore::proto_helpers::build_reaction_message( target_key, emoji, @@ -33,4 +48,67 @@ impl Client { ); self.send_message(chat.clone(), reaction).await } + + /// Whether `chat` is a Community Announcement Group (WA Web `isCag`). + /// + /// Served from the cached/persisted group metadata; a blob persisted + /// before the flag existed answers `None` and falls back to one full + /// metadata query. + pub(crate) async fn is_community_announce_group( + &self, + chat: &Jid, + ) -> Result { + if let Some(flag) = self.groups().query_info(chat).await?.is_community_announce { + return Ok(flag); + } + Ok(self.groups().get_metadata(chat).await?.is_default_sub_group) + } + + async fn send_enc_reaction( + &self, + chat: &Jid, + mut target_key: wa::MessageKey, + emoji: &str, + ) -> Result { + let (author, secret) = self + .resolve_outgoing_addon_parent(chat, &target_key) + .await?; + let target_id = target_key + .id + .clone() + .ok_or_else(|| anyhow!("target message key missing id"))?; + // Receivers derive the addon key with the STANZA sender, which in a + // CAG is our LID identity regardless of the parent author's namespace; + // mirror the comment path (WA Web authors CAG addons under LID). + let reactor = self + .get_lid() + .or_else(|| self.get_pn()) + .map(|j| j.to_non_ad()) + .ok_or_else(|| anyhow!("not logged in"))?; + + let (enc_payload, iv) = wacore::reaction::encrypt_reaction_with_secret( + emoji, + wacore::time::now_millis(), + &secret, + &target_id, + &author.to_non_ad_string(), + &reactor.to_non_ad_string(), + )?; + + // Receivers resolve the parent author from the envelope key, so it + // must carry the same identity the HKDF was derived with. + if target_key.participant.is_none() { + target_key.participant = Some(author.to_non_ad_string()); + } + + let message = wa::Message { + enc_reaction_message: Some(wa::message::EncReactionMessage { + target_message_key: Some(target_key), + enc_payload: Some(enc_payload), + enc_iv: Some(iv.to_vec()), + }), + ..Default::default() + }; + self.send_message(chat.clone(), message).await + } } diff --git a/src/lib.rs b/src/lib.rs index def7b05dd..8d148eb63 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -79,21 +79,21 @@ pub mod usync; pub mod features; pub use features::{ - BatchGroupResult, Blocking, BlocklistEntry, ChatActions, ChatStateType, Chatstate, Community, - CommunitySubgroup, Contacts, CreateCommunityOptions, CreateCommunityResult, CreateGroupResult, - EncryptedEdit, EventCreationParams, EventResponseType, Events, GroupCreateOptions, - GroupDescription, GroupJoinError, GroupMetadata, GroupParticipant, GroupParticipantOptions, - GroupProfilePicture, GroupSubject, GroupType, Groups, GrowthLockInfo, InviteInfoError, - IsOnWhatsAppResult, JoinGroupResult, Labels, LinkSubgroupsResult, MediaRetryResult, - MediaReupload, MediaReuploadRequest, MemberAddMode, MemberLinkMode, MemberShareHistoryMode, - MembershipApprovalMode, MembershipRequest, Mex, MexError, MexErrorExtensions, MexRequest, - MexResponse, Newsletter, NewsletterMessage, NewsletterMessageType, NewsletterMetadata, - NewsletterReactionCount, NewsletterRole, NewsletterState, NewsletterVerification, - ParticipantChangeResponse, ParticipantType, PictureType, Presence, PresenceError, - PresenceStatus, Profile, ProfilePicture, SecretEncKind, SecretEncrypted, - SetProfilePictureResponse, Signal, Status, StatusPrivacySetting, StatusSendOptions, - SyncActionMessageRange, TcToken, UnlinkSubgroupsResult, UserInfo, UsyncSubprotocolError, - VerifiedName, group_type, message_key, message_range, + BatchGroupResult, Blocking, BlocklistEntry, ChatActions, ChatStateType, Chatstate, Comments, + Community, CommunitySubgroup, Contacts, CreateCommunityOptions, CreateCommunityResult, + CreateGroupResult, EncryptedEdit, EventCreationParams, EventResponseType, Events, + GroupCreateOptions, GroupDescription, GroupJoinError, GroupMetadata, GroupParticipant, + GroupParticipantOptions, GroupProfilePicture, GroupSubject, GroupType, Groups, GrowthLockInfo, + InviteInfoError, IsOnWhatsAppResult, JoinGroupResult, Labels, LinkSubgroupsResult, + MediaRetryResult, MediaReupload, MediaReuploadRequest, MemberAddMode, MemberLinkMode, + MemberShareHistoryMode, MembershipApprovalMode, MembershipRequest, Mex, MexError, + MexErrorExtensions, MexRequest, MexResponse, Newsletter, NewsletterMessage, + NewsletterMessageType, NewsletterMetadata, NewsletterReactionCount, NewsletterRole, + NewsletterState, NewsletterVerification, ParticipantChangeResponse, ParticipantType, + PictureType, Presence, PresenceError, PresenceStatus, Profile, ProfilePicture, SecretEncKind, + SecretEncrypted, SetProfilePictureResponse, Signal, Status, StatusPrivacySetting, + StatusSendOptions, SyncActionMessageRange, TcToken, UnlinkSubgroupsResult, UserInfo, + UsyncSubprotocolError, VerifiedName, group_type, message_key, message_range, }; pub mod bot; diff --git a/src/message/dispatch.rs b/src/message/dispatch.rs index eb117d9d5..af2ba8d7c 100644 --- a/src/message/dispatch.rs +++ b/src/message/dispatch.rs @@ -24,10 +24,20 @@ impl Client { // Keep this ordered with dispatch; add-on messages can immediately // reference the secret from the stanza just processed. self.maybe_capture_inbound_msg_secret(&msg, &info).await; - let dispatch_msg = self + let decrypted = self .maybe_decrypt_secret_encrypted_message(&msg, &info) - .await - .unwrap_or(msg); + .await; + // A decrypted comment surfaces as its inner body Message, which has no + // slot for the parent post key; carry the threading link on the info. + if decrypted.is_some() + && let Some(target) = msg + .enc_comment_message + .as_ref() + .and_then(|c| c.target_message_key.clone()) + { + Arc::make_mut(&mut info).comment_target = Some(target); + } + let dispatch_msg = decrypted.unwrap_or(msg); self.ack_received_message(&info); self.core diff --git a/src/message/msg_secret.rs b/src/message/msg_secret.rs index 9d6f1e783..eb5d65f75 100644 --- a/src/message/msg_secret.rs +++ b/src/message/msg_secret.rs @@ -249,7 +249,7 @@ impl Client { .unwrap_or_default(), }; - let inner = match message_edit::decrypt_secret_encrypted( + let mut inner = match message_edit::decrypt_secret_encrypted( env.enc_payload, env.enc_iv, &secret, @@ -327,6 +327,35 @@ impl Client { } }; + // The reaction plaintext carries only text + timestamp; the target key + // lives in the envelope, so the surfaced plaintext-shape reaction gets + // it from there (parity with a plaintext reaction_message). + if env.kind == SecretEncKind::EncReaction + && let Some(rm) = inner.reaction_message.as_mut() + && rm.key.is_none() + { + rm.key = Some(env.target_message_key.clone()); + } + + // A comment's own messageSecret rides the OUTER envelope (WA Web puts + // it on the comment msgData), which substitution would drop. Carry it + // onto the dispatched body (merging into an existing secret-less inner + // context) so app-managed secret storage (Disabled policy) can still + // learn it for add-ons targeting the comment. + if env.kind == SecretEncKind::EncComment + && let Some(outer_secret) = msg + .message_context_info + .as_ref() + .and_then(|m| m.message_secret.as_ref()) + { + let inner_mci = inner + .message_context_info + .get_or_insert_with(Default::default); + if inner_mci.message_secret.is_none() { + inner_mci.message_secret = Some(outer_secret.clone()); + } + } + // Mirror WA Web `ProcessEditProtocolMsgs`: drop a MESSAGE_EDIT authored // outside the parent's edit-processing window (editTs >= parentTs + 20m). // The check is on authored time, not "now", so a validly-authored edit @@ -350,14 +379,32 @@ impl Client { .as_ref() .and_then(|m| m.message_secret.as_deref()) { - // The re-persisted secret keys the NEXT add-on on the same parent, - // so its retention class follows the parent kind and the parent's own - // event time (when known) rather than this edit's arrival time. - let class = match env.kind { - SecretEncKind::MessageEdit => wacore::msg_secret::RetentionClass::Text, - _ => wacore::msg_secret::RetentionClass::PollEvent, + // The re-persisted secret keys the NEXT add-on. For the edit/poll + // kinds the inner message IS the parent (same id, same author), so + // it re-keys the parent. A comment is a NEW message: its secret + // keys add-ons on the COMMENT itself, so it is stored under the + // envelope's own id and sender, never the parent's. + let (persist_id, persist_sender, persist_alt, class) = match env.kind { + SecretEncKind::MessageEdit => ( + target_id, + &original_sender, + fallback_original_sender.as_ref(), + wacore::msg_secret::RetentionClass::Text, + ), + SecretEncKind::EncComment => ( + info.id.as_str(), + &info.source.sender, + fallback_editor.as_ref(), + wacore::msg_secret::RetentionClass::Text, + ), + _ => ( + target_id, + &original_sender, + fallback_original_sender.as_ref(), + wacore::msg_secret::RetentionClass::PollEvent, + ), }; - let message_ts = if parent_ts > 0 { + let message_ts = if parent_ts > 0 && env.kind != SecretEncKind::EncComment { u64::try_from(parent_ts).ok() } else { u64::try_from(info.timestamp.timestamp()).ok() @@ -366,19 +413,19 @@ impl Client { let mut entries = Vec::with_capacity(2); if let Some(entry) = self.build_msg_secret_entry( &info.source.chat, - &original_sender, - target_id, + persist_sender, + persist_id, secret_bytes, class, message_ts, ) { entries.push(entry); } - if let Some(alternate_sender) = fallback_original_sender.as_ref() + if let Some(alternate_sender) = persist_alt && let Some(entry) = self.build_msg_secret_entry( &info.source.chat, alternate_sender, - target_id, + persist_id, secret_bytes, class, message_ts, @@ -664,7 +711,7 @@ impl Client { /// `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_jid( + pub(crate) async fn alternate_msg_secret_jid( &self, backend: &Arc, primary_sender: &Jid, @@ -702,11 +749,120 @@ impl Client { .await } + /// Resolve the parent message's author and `messageSecret` for an OUTGOING + /// addon (CAG reaction/comment). The author comes from the target key + /// (`participant`, else ourselves for `from_me`); the secret is looked up + /// under the author, then the LID/PN alternate, then the app resolver. + pub(crate) async fn resolve_outgoing_addon_parent( + &self, + chat: &Jid, + target_key: &wa::MessageKey, + ) -> Result<(Jid, Vec), anyhow::Error> { + use anyhow::{Context, anyhow}; + + let target_id = target_key + .id + .as_deref() + .ok_or_else(|| anyhow!("target message key missing id"))?; + let author: Jid = if let Some(p) = target_key.participant.as_deref() { + p.parse().context("invalid participant in target key")? + } else if target_key.from_me == Some(true) { + self.addon_self_jid_for_chat(chat) + .await + .ok_or_else(|| anyhow!("not logged in"))? + } else { + target_key + .remote_jid + .as_deref() + .ok_or_else(|| anyhow!("target message key missing participant and remote_jid"))? + .parse() + .context("invalid remote_jid in target key")? + }; + + let backend = self.persistence_manager.backend(); + let chat_str = chat.to_non_ad_string(); + let author_str = author.to_non_ad_string(); + let secret = match backend + .get_msg_secret(&chat_str, &author_str, target_id) + .await + { + Ok(Some(s)) => Some(s), + Ok(None) => self + .alternate_msg_secret_lookup(&backend, &chat_str, &author, target_id) + .await + .unwrap_or(None), + Err(e) => { + log::warn!("backend error reading message_secret for addon send: {e:?}"); + None + } + }; + let secret = match secret { + Some(s) => s, + None => { + let alternate = self + .alternate_msg_secret_jid(&backend, &author) + .await + .ok() + .flatten() + .map(|j| j.to_non_ad_string()); + self.resolve_msg_secret_via_app( + &chat_str, + &author_str, + alternate.as_deref(), + target_id, + ) + .await + .ok_or_else(|| { + anyhow!( + "no messageSecret stored for target {target_id}; the parent \ + message was not captured (received before this session, or \ + msg_secret_policy disabled without a resolver)" + ) + })? + } + }; + Ok((author, secret)) + } + + /// Our own JID in the namespace the chat addresses us under: the group's + /// addressing mode for groups (outbound group secrets are persisted under + /// the group sender identity), the peer's namespace for DMs. + pub(crate) async fn addon_self_jid_for_chat(&self, chat: &Jid) -> Option { + use wacore_binary::JidExt; + if chat.is_group() { + let lid_mode = match self.groups().query_info(chat).await { + Ok(info) => info.addressing_mode == wacore::types::message::AddressingMode::Lid, + Err(e) => { + log::warn!("addon self identity: group info lookup failed: {e:?}"); + false + } + }; + return if lid_mode { + self.get_lid().or_else(|| self.get_pn()) + } else { + self.get_pn().or_else(|| self.get_lid()) + } + .map(|j| j.to_non_ad()); + } + self.addon_self_jid(chat) + } + + /// Our own JID in the namespace matching `reference` (the parent author or + /// chat addressing): LID identities key the HKDF of LID-addressed addons. + pub(crate) fn addon_self_jid(&self, reference: &Jid) -> Option { + if reference.is_lid() { + self.get_lid().or_else(|| self.get_pn()) + } else { + self.get_pn().or_else(|| self.get_lid()) + } + .map(|j| j.to_non_ad()) + } + /// On a total store miss, consult the app-supplied resolver for the parent /// secret, trying the primary then the LID/PN alternate sender. Bounded by a /// timeout because it runs inside the per-chat receive lane, so a slow app /// callback degrades to a miss instead of stalling the chat. - async fn resolve_msg_secret_via_app( + pub(crate) async fn resolve_msg_secret_via_app( &self, chat: &str, primary_sender: &str, diff --git a/src/message/tests.rs b/src/message/tests.rs index 0ca1b04c1..76c69c21b 100644 --- a/src/message/tests.rs +++ b/src/message/tests.rs @@ -3386,6 +3386,7 @@ fn create_test_message_info(chat: &str, msg_id: &str, sender: &str) -> MessageIn verified_level: None, verified_name_serial: None, peer_recipient_pn: None, + comment_target: None, bcl_participants: Vec::new(), } } @@ -9537,3 +9538,243 @@ async fn msmsg_without_meta_target_id_nacks_495() { } assert_eq!(code, Some(495)); } + +/// Incoming CAG encrypted reaction: decrypted inline and surfaced in the +/// plaintext-reaction shape, with the key filled from the envelope's target. +#[tokio::test] +async fn enc_reaction_inbound_decrypts_to_plaintext_shape() { + use wacore::types::message::{MessageInfo, MessageSource}; + + let client = crate::test_utils::create_test_client_with_name("enc_reaction_inbound").await; + ensure_bob_paired(&client).await; + + let group: Jid = "120363400000000001@g.us".parse().expect("group"); + let author: Jid = "5511888887777@s.whatsapp.net".parse().expect("author"); + let reactor: Jid = "5511777776666@s.whatsapp.net".parse().expect("reactor"); + let secret = [0x5Au8; 32]; + const PARENT_ID: &str = "3EB0PARENTPOST1"; + + client + .persistence_manager + .backend() + .put_msg_secrets(vec![wacore::store::traits::MsgSecretEntry { + chat: group.to_non_ad_string(), + sender: author.to_non_ad_string(), + msg_id: PARENT_ID.to_string(), + secret: secret.to_vec(), + expires_at: 0, + message_ts: 0, + }]) + .await + .expect("persist parent secret"); + + let (payload, iv) = wacore::reaction::encrypt_reaction_with_secret( + "\u{1F525}", + 1_700_000_000_000, + &secret, + PARENT_ID, + &author.to_non_ad_string(), + &reactor.to_non_ad_string(), + ) + .expect("encrypt"); + + let target_key = wa::MessageKey { + remote_jid: Some(group.to_string()), + from_me: Some(false), + id: Some(PARENT_ID.to_string()), + participant: Some(author.to_string()), + }; + let msg = wa::Message { + enc_reaction_message: Some(wa::message::EncReactionMessage { + target_message_key: Some(target_key.clone()), + enc_payload: Some(payload), + enc_iv: Some(iv.to_vec()), + }), + ..Default::default() + }; + let info = Arc::new(MessageInfo { + id: "REACT1".to_string(), + source: MessageSource { + chat: group.clone(), + sender: reactor.clone(), + is_group: true, + ..Default::default() + }, + timestamp: wacore::time::now_utc(), + ..Default::default() + }); + + let out = client + .maybe_decrypt_secret_encrypted_message(&msg, &info) + .await + .expect("reaction must decrypt"); + let rm = out.reaction_message.expect("plaintext reaction shape"); + assert_eq!(rm.text.as_deref(), Some("\u{1F525}")); + assert_eq!(rm.sender_timestamp_ms, Some(1_700_000_000_000)); + assert_eq!( + rm.key.as_ref().and_then(|k| k.id.as_deref()), + Some(PARENT_ID), + "key must be filled from the envelope target" + ); + assert_eq!( + rm.key.as_ref().and_then(|k| k.participant.as_deref()), + Some(author.to_string().as_str()) + ); +} + +/// Incoming CAG encrypted comment: dispatched as the decrypted body with the +/// parent post key carried on `MessageInfo::comment_target`, and the comment's +/// own secret persisted under the comment's id (not the parent's). +#[tokio::test] +async fn enc_comment_inbound_dispatches_body_with_parent_link() { + use wacore::types::events::ChannelEventHandler; + use wacore::types::message::{MessageInfo, MessageSource}; + + let (client, _transport) = capturing_client("enc_comment_inbound").await; + ensure_bob_paired(&client).await; + let (handler, rx) = ChannelEventHandler::new(); + client.core.event_bus.add_handler(handler); + + let group: Jid = "120363400000000002@g.us".parse().expect("group"); + let author: Jid = "5511888887777@s.whatsapp.net".parse().expect("author"); + let commenter: Jid = "5511777776666@s.whatsapp.net".parse().expect("commenter"); + let secret = [0x6Bu8; 32]; + let comment_secret = [0x7Cu8; 32]; + const PARENT_ID: &str = "3EB0PARENTPOST2"; + const COMMENT_ID: &str = "3EB0COMMENT1"; + + client + .persistence_manager + .backend() + .put_msg_secrets(vec![wacore::store::traits::MsgSecretEntry { + chat: group.to_non_ad_string(), + sender: author.to_non_ad_string(), + msg_id: PARENT_ID.to_string(), + secret: secret.to_vec(), + expires_at: 0, + message_ts: 0, + }]) + .await + .expect("persist parent secret"); + + let body = wa::Message { + extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + text: Some("great post".to_string()), + ..Default::default() + })), + // Present but secret-less: the outer secret must merge in, not be + // dropped because a context already exists. + message_context_info: Some(wa::MessageContextInfo::default()), + ..Default::default() + }; + let (payload, iv) = wacore::comment::encrypt_comment_with_secret( + &body, + &secret, + PARENT_ID, + &author.to_non_ad_string(), + &commenter.to_non_ad_string(), + ) + .expect("encrypt"); + + let msg = wa::Message { + enc_comment_message: Some(wa::message::EncCommentMessage { + target_message_key: Some(wa::MessageKey { + remote_jid: Some(group.to_string()), + from_me: Some(false), + id: Some(PARENT_ID.to_string()), + participant: Some(author.to_string()), + }), + enc_payload: Some(payload), + enc_iv: Some(iv.to_vec()), + }), + // WA Web ships the comment's own secret on the OUTER envelope (the + // comment msgData), not inside the encrypted body. + message_context_info: Some(wa::MessageContextInfo { + message_secret: Some(comment_secret.to_vec()), + ..Default::default() + }), + ..Default::default() + }; + let info = Arc::new(MessageInfo { + id: COMMENT_ID.to_string(), + source: MessageSource { + chat: group.clone(), + sender: commenter.clone(), + is_group: true, + ..Default::default() + }, + timestamp: wacore::time::now_utc(), + ..Default::default() + }); + + client.dispatch_parsed_message(msg, &info).await; + + // Deadline-poll instead of a fixed sleep so a slow CI cannot race the + // event delivery. + let mut seen = false; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); + 'outer: while tokio::time::Instant::now() < deadline { + while let Ok(event) = rx.try_recv() { + if let Event::Message(msg, info) = event.as_ref() + && info.id == COMMENT_ID + { + seen = true; + assert_eq!( + msg.extended_text_message + .as_ref() + .and_then(|m| m.text.as_deref()), + Some("great post"), + "the decrypted body must be dispatched" + ); + assert!( + msg.enc_comment_message.is_none(), + "the envelope must not survive substitution" + ); + assert_eq!( + info.comment_target.as_ref().and_then(|k| k.id.as_deref()), + Some(PARENT_ID), + "the parent post key must surface on the info" + ); + assert_eq!( + msg.message_context_info + .as_ref() + .and_then(|m| m.message_secret.as_deref()), + Some(comment_secret.as_slice()), + "the comment's own secret must survive substitution for app-managed storage" + ); + break 'outer; + } + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert!(seen, "the decrypted comment must be dispatched"); + + // The comment's own secret keys add-ons on the COMMENT, so it must be + // stored under the comment's id and sender, never the parent's. + let stored = client + .persistence_manager + .backend() + .get_msg_secret( + &group.to_non_ad_string(), + &commenter.to_non_ad_string(), + COMMENT_ID, + ) + .await + .expect("lookup"); + assert_eq!(stored.as_deref(), Some(comment_secret.as_slice())); + let mis_keyed = client + .persistence_manager + .backend() + .get_msg_secret( + &group.to_non_ad_string(), + &author.to_non_ad_string(), + PARENT_ID, + ) + .await + .expect("lookup"); + assert_eq!( + mis_keyed.as_deref(), + Some(secret.as_slice()), + "the parent's own secret must stay untouched" + ); +} diff --git a/src/pdo.rs b/src/pdo.rs index dbaf9b08a..f1c445a74 100644 --- a/src/pdo.rs +++ b/src/pdo.rs @@ -460,6 +460,7 @@ impl Client { verified_level: None, verified_name_serial: None, peer_recipient_pn: None, + comment_target: None, bcl_participants: Vec::new(), }) } diff --git a/wacore/src/client/context.rs b/wacore/src/client/context.rs index e065ae9a5..ca77eaf9d 100644 --- a/wacore/src/client/context.rs +++ b/wacore/src/client/context.rs @@ -20,6 +20,11 @@ fn build_pn_to_lid_map( pub struct GroupInfo { pub participants: Vec, pub addressing_mode: AddressingMode, + /// Whether this group is a Community Announcement Group (WA Web `isCag`, + /// derived from `default_sub_group`). `None` means the persisted blob + /// predates the field, so the answer is unknown and callers must re-query. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub is_community_announce: Option, /// Maps a LID user identifier (the `user` part of the LID JID) to the /// corresponding phone-number JID. This is used for device queries since /// LID usync requests may not work reliably. @@ -40,12 +45,16 @@ struct GroupInfoDe { participants: Vec, addressing_mode: AddressingMode, #[serde(default)] + is_community_announce: Option, + #[serde(default)] lid_to_pn_map: HashMap, } impl From for GroupInfo { fn from(d: GroupInfoDe) -> Self { - Self::with_lid_to_pn_map(d.participants, d.addressing_mode, d.lid_to_pn_map) + let mut info = Self::with_lid_to_pn_map(d.participants, d.addressing_mode, d.lid_to_pn_map); + info.is_community_announce = d.is_community_announce; + info } } @@ -59,6 +68,7 @@ impl GroupInfo { Self { participants, addressing_mode, + is_community_announce: None, lid_to_pn_map: HashMap::new(), pn_to_lid_map: HashMap::new(), } @@ -75,6 +85,7 @@ impl GroupInfo { Self { participants, addressing_mode, + is_community_announce: None, lid_to_pn_map, pn_to_lid_map, } diff --git a/wacore/src/comment.rs b/wacore/src/comment.rs new file mode 100644 index 000000000..e3ee8f8d2 --- /dev/null +++ b/wacore/src/comment.rs @@ -0,0 +1,134 @@ +//! Encrypted channel comment (CAG) encryption. +//! +//! Thin wrapper over [`crate::secret_enc_addon`] specialised for the +//! `enc_comment_message` envelope (threaded comments under a Community +//! Announcement Group post). Mirrors `WAWebAddonEncryption` (`MessageSpec` + +//! `ENC_COMMENT` use-case, empty AAD): the inner plaintext is a full `Message` +//! proto carrying the comment body; the parent post's key travels in the +//! outer envelope. + +use anyhow::{Result, ensure}; +use prost::Message; +use waproto::whatsapp as wa; + +use crate::secret_enc_addon::{AddonContext, ModificationType, decrypt_addon, encrypt_addon}; + +const GCM_IV_SIZE: usize = 12; +const MESSAGE_SECRET_SIZE: usize = 32; + +fn comment_addon_ctx<'a>( + parent_msg_id: &'a str, + parent_sender_jid: &'a str, + commenter_jid: &'a str, +) -> AddonContext<'a> { + AddonContext { + stanza_id: parent_msg_id, + parent_msg_original_sender: parent_sender_jid, + modification_sender: commenter_jid, + modification_type: ModificationType::EncComment, + } +} + +/// Encrypt a comment body given the parent post's `messageSecret`. +/// Returns `(payload_with_tag, iv)`. +pub fn encrypt_comment_with_secret( + inner: &wa::Message, + message_secret: &[u8], + parent_msg_id: &str, + parent_sender_jid: &str, + commenter_jid: &str, +) -> Result<(Vec, [u8; GCM_IV_SIZE])> { + ensure!( + message_secret.len() == MESSAGE_SECRET_SIZE, + "message_secret must be {MESSAGE_SECRET_SIZE} bytes, got {}", + message_secret.len() + ); + let plaintext = inner.encode_to_vec(); + encrypt_addon( + &plaintext, + message_secret, + &comment_addon_ctx(parent_msg_id, parent_sender_jid, commenter_jid), + ) +} + +/// Decrypt an `enc_comment_message` payload given the parent's `messageSecret`. +/// Returns the inner comment body `Message`. +pub fn decrypt_comment_with_secret( + enc_payload: &[u8], + iv: &[u8], + message_secret: &[u8], + parent_msg_id: &str, + parent_sender_jid: &str, + commenter_jid: &str, +) -> Result { + ensure!( + message_secret.len() == MESSAGE_SECRET_SIZE, + "message_secret must be {MESSAGE_SECRET_SIZE} bytes, got {}", + message_secret.len() + ); + let plaintext = decrypt_addon( + enc_payload, + iv, + message_secret, + &comment_addon_ctx(parent_msg_id, parent_sender_jid, commenter_jid), + )?; + Ok(wa::Message::decode(&plaintext[..])?) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SECRET: [u8; 32] = [0x24; 32]; + const PARENT_ID: &str = "3EB0POST"; + const AUTHOR: &str = "111111111111111@lid"; + const COMMENTER: &str = "222222222222222@lid"; + + fn body(text: &str) -> wa::Message { + wa::Message { + extended_text_message: Some(Box::new(wa::message::ExtendedTextMessage { + text: Some(text.to_string()), + ..Default::default() + })), + ..Default::default() + } + } + + #[test] + fn roundtrip_extended_text_body() { + let inner = body("nice post"); + let (enc, iv) = + encrypt_comment_with_secret(&inner, &SECRET, PARENT_ID, AUTHOR, COMMENTER).unwrap(); + let out = + decrypt_comment_with_secret(&enc, &iv, &SECRET, PARENT_ID, AUTHOR, COMMENTER).unwrap(); + assert_eq!( + out.extended_text_message + .as_ref() + .and_then(|m| m.text.as_deref()), + Some("nice post") + ); + } + + #[test] + fn use_case_differs_from_reaction() { + // Same inputs under the reaction use-case must not decrypt a comment: + // the HKDF info embeds the use-case literal. + let inner = body("hi"); + let (enc, iv) = + encrypt_comment_with_secret(&inner, &SECRET, PARENT_ID, AUTHOR, COMMENTER).unwrap(); + assert!( + crate::reaction::decrypt_reaction_with_secret( + &enc, &iv, &SECRET, PARENT_ID, AUTHOR, COMMENTER + ) + .is_err() + ); + } + + #[test] + fn invalid_secret_size_rejected() { + let inner = body("x"); + assert!( + encrypt_comment_with_secret(&inner, &[0u8; 31], PARENT_ID, AUTHOR, COMMENTER).is_err() + ); + } +} diff --git a/wacore/src/lib.rs b/wacore/src/lib.rs index 55019a091..d5e3638fd 100644 --- a/wacore/src/lib.rs +++ b/wacore/src/lib.rs @@ -20,6 +20,7 @@ pub mod handshake; pub mod history_sync; pub mod ib; pub use wacore_libsignal as libsignal; +pub mod comment; pub mod event; pub mod media_retry; pub mod message_edit; @@ -32,6 +33,7 @@ pub mod pair_code; pub mod poll; pub mod prekeys; pub mod proto_helpers; +pub mod reaction; pub mod reporting_token; pub mod request; pub mod runtime; diff --git a/wacore/src/reaction.rs b/wacore/src/reaction.rs new file mode 100644 index 000000000..24b7b3e5f --- /dev/null +++ b/wacore/src/reaction.rs @@ -0,0 +1,150 @@ +//! Encrypted reaction (CAG) encryption. +//! +//! Thin wrapper over [`crate::secret_enc_addon`] specialised for the +//! `enc_reaction_message` envelope used in Community Announcement Groups. +//! Mirrors `WAWebReactionEncryptMsgData` / `WAWebReactionsEncryption`: the +//! inner plaintext is a `ReactionMessage` proto carrying ONLY `text` and +//! `sender_timestamp_ms` (the target key travels in the outer envelope), and +//! the HKDF use-case is `"Enc Reaction"` with empty AAD. + +use anyhow::{Result, ensure}; +use prost::Message; +use waproto::whatsapp::message::ReactionMessage; + +use crate::secret_enc_addon::{AddonContext, ModificationType, decrypt_addon, encrypt_addon}; + +const GCM_IV_SIZE: usize = 12; +const MESSAGE_SECRET_SIZE: usize = 32; + +fn reaction_addon_ctx<'a>( + parent_msg_id: &'a str, + parent_sender_jid: &'a str, + reactor_jid: &'a str, +) -> AddonContext<'a> { + AddonContext { + stanza_id: parent_msg_id, + parent_msg_original_sender: parent_sender_jid, + modification_sender: reactor_jid, + modification_type: ModificationType::EncReaction, + } +} + +/// Encrypt a reaction given the parent message's `messageSecret`. +/// Returns `(payload_with_tag, iv)`. +/// +/// An empty `text` is the reaction-removal form, same as the plaintext path. +pub fn encrypt_reaction_with_secret( + text: &str, + sender_timestamp_ms: i64, + message_secret: &[u8], + parent_msg_id: &str, + parent_sender_jid: &str, + reactor_jid: &str, +) -> Result<(Vec, [u8; GCM_IV_SIZE])> { + ensure!( + message_secret.len() == MESSAGE_SECRET_SIZE, + "message_secret must be {MESSAGE_SECRET_SIZE} bytes, got {}", + message_secret.len() + ); + // WA Web encodes only { text, senderTimestampMs }; the key is envelope-side. + let inner = ReactionMessage { + text: Some(text.to_string()), + sender_timestamp_ms: Some(sender_timestamp_ms), + ..Default::default() + }; + let plaintext = inner.encode_to_vec(); + encrypt_addon( + &plaintext, + message_secret, + &reaction_addon_ctx(parent_msg_id, parent_sender_jid, reactor_jid), + ) +} + +/// Decrypt an `enc_reaction_message` payload given the parent's `messageSecret`. +/// +/// The returned `ReactionMessage` carries no `key`; the caller fills it from +/// the envelope's `target_message_key`. +pub fn decrypt_reaction_with_secret( + enc_payload: &[u8], + iv: &[u8], + message_secret: &[u8], + parent_msg_id: &str, + parent_sender_jid: &str, + reactor_jid: &str, +) -> Result { + ensure!( + message_secret.len() == MESSAGE_SECRET_SIZE, + "message_secret must be {MESSAGE_SECRET_SIZE} bytes, got {}", + message_secret.len() + ); + let plaintext = decrypt_addon( + enc_payload, + iv, + message_secret, + &reaction_addon_ctx(parent_msg_id, parent_sender_jid, reactor_jid), + )?; + Ok(ReactionMessage::decode(&plaintext[..])?) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SECRET: [u8; 32] = [0x42; 32]; + const PARENT_ID: &str = "3EB0PARENT"; + const AUTHOR: &str = "111111111111111@lid"; + const REACTOR: &str = "222222222222222@lid"; + + #[test] + fn roundtrip_keeps_text_and_timestamp_only() { + let (enc, iv) = encrypt_reaction_with_secret( + "\u{1F525}", + 1_700_000_000_123, + &SECRET, + PARENT_ID, + AUTHOR, + REACTOR, + ) + .unwrap(); + let out = + decrypt_reaction_with_secret(&enc, &iv, &SECRET, PARENT_ID, AUTHOR, REACTOR).unwrap(); + assert_eq!(out.text.as_deref(), Some("\u{1F525}")); + assert_eq!(out.sender_timestamp_ms, Some(1_700_000_000_123)); + assert!(out.key.is_none(), "key must not travel in the plaintext"); + assert!(out.grouping_key.is_none()); + } + + #[test] + fn empty_text_removal_roundtrips() { + let (enc, iv) = + encrypt_reaction_with_secret("", 1, &SECRET, PARENT_ID, AUTHOR, REACTOR).unwrap(); + let out = + decrypt_reaction_with_secret(&enc, &iv, &SECRET, PARENT_ID, AUTHOR, REACTOR).unwrap(); + assert_eq!(out.text.as_deref(), Some("")); + } + + #[test] + fn wrong_reactor_fails_decrypt() { + // The reactor JID keys the HKDF info, so a mismatched identity must + // not produce a valid key. + let (enc, iv) = + encrypt_reaction_with_secret("x", 1, &SECRET, PARENT_ID, AUTHOR, REACTOR).unwrap(); + assert!( + decrypt_reaction_with_secret(&enc, &iv, &SECRET, PARENT_ID, AUTHOR, "333333@lid") + .is_err() + ); + } + + #[test] + fn invalid_secret_size_rejected() { + assert!( + encrypt_reaction_with_secret("x", 1, &[0u8; 16], PARENT_ID, AUTHOR, REACTOR).is_err() + ); + assert!( + decrypt_reaction_with_secret( + &[0u8; 32], &[0u8; 12], &[0u8; 16], PARENT_ID, AUTHOR, REACTOR + ) + .is_err() + ); + } +} diff --git a/wacore/src/types/message.rs b/wacore/src/types/message.rs index 6a99ac194..189f16fb7 100644 --- a/wacore/src/types/message.rs +++ b/wacore/src/types/message.rs @@ -338,6 +338,11 @@ pub struct MessageInfo { /// goes to the right routing target). #[serde(skip_serializing_if = "Option::is_none")] pub peer_recipient_pn: Option, + /// Parent post key when the dispatched message is a decrypted CAG channel + /// comment (`enc_comment_message`). The inner `Message` proto has no slot + /// for the threading link, so it surfaces here. + #[serde(skip_serializing_if = "Option::is_none")] + pub comment_target: Option, /// Broadcast-contact-list recipients from `` on an /// incoming broadcast/status stanza. Populated only for broadcasts; used to /// validate a `deviceSentMessage.phash` (WA Web `validateBclHash`). Empty