diff --git a/src/features/events.rs b/src/features/events.rs new file mode 100644 index 000000000..207cfdd65 --- /dev/null +++ b/src/features/events.rs @@ -0,0 +1,207 @@ +//! Event creation and response (RSVP). + +use anyhow::{Result, anyhow}; +use wacore::event; +use wacore_binary::{Jid, JidExt}; +use waproto::whatsapp as wa; + +pub use waproto::whatsapp::message::event_response_message::EventResponseType; + +use crate::client::Client; +use crate::send::SendResult; + +/// Parameters for creating an event message. Only `name` is required. +#[derive(Debug, Clone, Default)] +pub struct EventCreationParams { + pub name: String, + pub description: Option, + pub start_time: Option, + pub end_time: Option, + pub join_link: Option, + pub location: Option, + pub is_scheduled_call: Option, + pub extra_guests_allowed: Option, +} + +pub struct Events<'a> { + client: &'a Client, +} + +impl<'a> Events<'a> { + pub(crate) fn new(client: &'a Client) -> Self { + Self { client } + } + + /// Create an event. Returns the `message_secret` the creator needs to decrypt + /// later responses (RSVPs) via [`wacore::event::decrypt_event_response_with_secret`]. + pub async fn create( + &self, + to: &Jid, + params: EventCreationParams, + ) -> Result<(SendResult, Vec)> { + if params.name.trim().is_empty() { + return Err(anyhow!("Event name must not be empty")); + } + + let mut message = wa::Message { + event_message: Some(Box::new(build_event_message(params))), + ..Default::default() + }; + + // Events carry a per-message secret (like polls); responders derive their + // RSVP encryption key from it. WA Web rejects an event without one + // (Events/ValidationError MISSING_MESSAGE_SECRET). + let message_secret: Vec = { + use rand::Rng; + let mut secret = vec![0u8; 32]; + rand::make_rng::().fill_bytes(&mut secret); + secret + }; + message.message_context_info = Some(wa::MessageContextInfo { + message_secret: Some(message_secret.clone()), + ..Default::default() + }); + + let result = self.client.send_message(to.clone(), message).await?; + Ok((result, message_secret)) + } + + /// RSVP to an event. `message_secret` is the event's secret (from its creation + /// message); `event_creator_jid` is who created the event. + pub async fn respond( + &self, + chat_jid: &Jid, + event_msg_id: &str, + event_creator_jid: &Jid, + message_secret: &[u8], + response: EventResponseType, + extra_guest_count: Option, + ) -> Result { + let my_jid = self + .client + .get_pn() + .await + .ok_or_else(|| anyhow!("Not logged in — cannot determine own JID"))?; + let my_base = my_jid.to_non_ad(); + + let responder = self + .resolve_responder_jid(event_creator_jid, &my_base) + .await; + let responder_str = responder.to_string(); + let creator_str = event_creator_jid.to_non_ad_string(); + + let response_msg = wa::message::EventResponseMessage { + response: Some(response as i32), + timestamp_ms: Some(wacore::time::now_millis()), + extra_guest_count, + }; + + let (enc_payload, iv) = event::encrypt_event_response_with_secret( + &response_msg, + message_secret, + event_msg_id, + &creator_str, + &responder_str, + )?; + + let from_me = my_base.is_same_user_as(event_creator_jid); + let enc = wa::message::EncEventResponseMessage { + event_creation_message_key: Some(wa::MessageKey { + remote_jid: Some(chat_jid.to_string()), + from_me: Some(from_me), + id: Some(event_msg_id.to_string()), + participant: if chat_jid.is_group() { + Some(event_creator_jid.to_string()) + } else { + None + }, + }), + enc_payload: Some(enc_payload), + enc_iv: Some(iv.to_vec()), + }; + + let message = wa::Message { + enc_event_response_message: Some(enc), + ..Default::default() + }; + + self.client.send_message(chat_jid.clone(), message).await + } + + /// The responder (self) JID keys the RSVP's HKDF/AAD, so it must use the event + /// creator's namespace: own LID for a LID-addressed event, own PN otherwise, + /// falling back to PN when our LID isn't known. Mirrors the poll-vote path. + async fn resolve_responder_jid(&self, event_creator_jid: &Jid, own_pn: &Jid) -> Jid { + if !event_creator_jid.is_lid() { + return own_pn.clone(); + } + match self.client.get_lid().await { + Some(lid) => lid.to_non_ad(), + None => own_pn.clone(), + } + } +} + +impl Client { + pub fn events(&self) -> Events<'_> { + Events::new(self) + } +} + +/// Build an `EventMessage` from the public params. Mirrors WA Web's +/// `GenerateEventCreationMessageProto` field set. +fn build_event_message(params: EventCreationParams) -> wa::message::EventMessage { + wa::message::EventMessage { + name: Some(params.name), + description: params.description, + start_time: params.start_time, + end_time: params.end_time, + join_link: params.join_link, + location: params.location.map(Box::new), + is_schedule_call: params.is_scheduled_call, + extra_guests_allowed: params.extra_guests_allowed, + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::create_test_client; + use std::sync::Arc; + + #[test] + fn build_event_message_maps_fields() { + let params = EventCreationParams { + name: "Launch".into(), + description: Some("desc".into()), + start_time: Some(1_700_000_000), + end_time: Some(1_700_003_600), + join_link: Some("https://call".into()), + is_scheduled_call: Some(true), + extra_guests_allowed: Some(true), + ..Default::default() + }; + let msg = build_event_message(params); + assert_eq!(msg.name.as_deref(), Some("Launch")); + assert_eq!(msg.description.as_deref(), Some("desc")); + assert_eq!(msg.start_time, Some(1_700_000_000)); + assert_eq!(msg.end_time, Some(1_700_003_600)); + assert_eq!(msg.join_link.as_deref(), Some("https://call")); + assert_eq!(msg.is_schedule_call, Some(true)); + assert_eq!(msg.extra_guests_allowed, Some(true)); + assert!(msg.is_canceled.is_none()); + } + + #[tokio::test] + async fn responder_is_pn_when_creator_is_pn() { + let client: Arc = create_test_client().await; + let own_pn = Jid::pn("5511999999999"); + let creator = Jid::pn("5511777777777"); + let responder = client + .events() + .resolve_responder_jid(&creator, &own_pn) + .await; + assert_eq!(responder, own_pn); + } +} diff --git a/src/features/mod.rs b/src/features/mod.rs index 394cbb169..68f3841ae 100644 --- a/src/features/mod.rs +++ b/src/features/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod chat_actions; mod chatstate; mod community; mod contacts; +mod events; mod groups; pub(crate) mod labels; mod media_reupload; @@ -30,6 +31,8 @@ pub use chatstate::{ChatStateType, Chatstate}; pub use contacts::{Contacts, IsOnWhatsAppResult, ProfilePicture, UserInfo, VerifiedName}; +pub use events::{EventCreationParams, EventResponseType, Events}; + pub use groups::{ BatchGroupResult, CreateGroupResult, GroupCreateOptions, GroupDescription, GroupJoinError, GroupMetadata, GroupParticipant, GroupParticipantOptions, GroupProfilePicture, GroupSubject, diff --git a/src/lib.rs b/src/lib.rs index 7a821d1c6..1d706a19b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -80,18 +80,19 @@ pub mod features; pub use features::{ BatchGroupResult, Blocking, BlocklistEntry, ChatActions, ChatStateType, Chatstate, Community, CommunitySubgroup, Contacts, CreateCommunityOptions, CreateCommunityResult, CreateGroupResult, - EncryptedEdit, 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, - VerifiedName, group_type, message_key, message_range, + 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, VerifiedName, group_type, + message_key, message_range, }; pub mod bot; diff --git a/wacore/src/event.rs b/wacore/src/event.rs new file mode 100644 index 000000000..f06afa90b --- /dev/null +++ b/wacore/src/event.rs @@ -0,0 +1,141 @@ +//! Event response encryption. +//! +//! Thin wrapper over [`secret_enc_addon`] specialised for the +//! `EventResponseMessage` proto and the `"Event Response"` use-case. + +use anyhow::{Result, ensure}; +use prost::Message; +use waproto::whatsapp::message::EventResponseMessage; + +use crate::secret_enc_addon::{AddonContext, ModificationType, decrypt_addon, encrypt_addon}; + +const GCM_IV_SIZE: usize = 12; +const MESSAGE_SECRET_SIZE: usize = 32; + +fn event_response_addon_ctx<'a>( + stanza_id: &'a str, + event_creator_jid: &'a str, + responder_jid: &'a str, +) -> AddonContext<'a> { + AddonContext { + stanza_id, + parent_msg_original_sender: event_creator_jid, + modification_sender: responder_jid, + modification_type: ModificationType::EventResponse, + } +} + +/// Encrypt an event response given the parent event's `messageSecret`. +/// Returns `(payload_with_tag, iv)`. +pub fn encrypt_event_response_with_secret( + response: &EventResponseMessage, + message_secret: &[u8], + stanza_id: &str, + event_creator_jid: &str, + responder_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 = response.encode_to_vec(); + encrypt_addon( + &plaintext, + message_secret, + &event_response_addon_ctx(stanza_id, event_creator_jid, responder_jid), + ) +} + +/// Decrypt an event response given the parent event's `messageSecret`. +/// +/// The event creator + responder JIDs key the derivation and AAD, so they must +/// match what the responder used (matches WA Web `WAWebAddonEncryption`). +pub fn decrypt_event_response_with_secret( + enc_payload: &[u8], + iv: &[u8], + message_secret: &[u8], + stanza_id: &str, + event_creator_jid: &str, + responder_jid: &str, +) -> Result { + // The IV length is validated downstream by decrypt_addon (try_into [u8; 12]). + 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, + &event_response_addon_ctx(stanza_id, event_creator_jid, responder_jid), + )?; + Ok(EventResponseMessage::decode(&plaintext[..])?) +} + +#[cfg(test)] +mod tests { + use super::*; + use waproto::whatsapp::message::event_response_message::EventResponseType; + + #[test] + fn event_response_roundtrip() { + let secret = [0x55u8; 32]; + let resp = EventResponseMessage { + response: Some(EventResponseType::Going as i32), + timestamp_ms: Some(1_700_000_000_000), + extra_guest_count: Some(2), + }; + let (enc, iv) = encrypt_event_response_with_secret( + &resp, + &secret, + "EVTID", + "5511777777777@s.whatsapp.net", + "5511888888888@s.whatsapp.net", + ) + .unwrap(); + let out = decrypt_event_response_with_secret( + &enc, + &iv, + &secret, + "EVTID", + "5511777777777@s.whatsapp.net", + "5511888888888@s.whatsapp.net", + ) + .unwrap(); + assert_eq!(out.response, Some(EventResponseType::Going as i32)); + assert_eq!(out.extra_guest_count, Some(2)); + } + + #[test] + fn event_response_wrong_responder_fails() { + // A different responder JID derives a different key + AAD, so decryption + // must fail rather than silently mis-decrypt. + let secret = [0x55u8; 32]; + let resp = EventResponseMessage { + response: Some(EventResponseType::Maybe as i32), + timestamp_ms: None, + extra_guest_count: None, + }; + let (enc, iv) = encrypt_event_response_with_secret( + &resp, + &secret, + "EVTID", + "creator@s.whatsapp.net", + "responder@s.whatsapp.net", + ) + .unwrap(); + assert!( + decrypt_event_response_with_secret( + &enc, + &iv, + &secret, + "EVTID", + "creator@s.whatsapp.net", + "other@s.whatsapp.net", + ) + .is_err() + ); + } +} diff --git a/wacore/src/lib.rs b/wacore/src/lib.rs index 97acc9018..55019a091 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 event; pub mod media_retry; pub mod message_edit; pub mod message_processing;