Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 207 additions & 0 deletions src/features/events.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
pub start_time: Option<i64>,
pub end_time: Option<i64>,
pub join_link: Option<String>,
pub location: Option<wa::message::LocationMessage>,
pub is_scheduled_call: Option<bool>,
pub extra_guests_allowed: Option<bool>,
}

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<u8>)> {
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<u8> = {
use rand::Rng;
let mut secret = vec![0u8; 32];
rand::make_rng::<rand::rngs::StdRng>().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<i32>,
) -> Result<SendResult> {
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<Client> = 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);
}
}
3 changes: 3 additions & 0 deletions src/features/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -30,6 +31,8 @@ pub use chatstate::{ChatStateType, Chatstate};

pub use contacts::{Contacts, IsOnWhatsAppResult, ProfilePicture, UserInfo, VerifiedName};

pub use events::{EventCreationParams, EventResponseType, Events};
Comment thread
jlucaso1 marked this conversation as resolved.

pub use groups::{
BatchGroupResult, CreateGroupResult, GroupCreateOptions, GroupDescription, GroupJoinError,
GroupMetadata, GroupParticipant, GroupParticipantOptions, GroupProfilePicture, GroupSubject,
Expand Down
25 changes: 13 additions & 12 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
141 changes: 141 additions & 0 deletions wacore/src/event.rs
Original file line number Diff line number Diff line change
@@ -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>, [u8; GCM_IV_SIZE])> {
Comment on lines +30 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Lock down fixed-size crypto inputs at the API boundary.

Line 29 and Line 48 accept unconstrained slices for message_secret/iv, but this protocol path requires fixed sizes (32-byte secret, 12-byte IV). Reject invalid sizes early so callers get deterministic errors instead of opaque downstream crypto failures.

Proposed patch
-use anyhow::Result;
+use anyhow::{ensure, Result};
@@
 pub fn encrypt_event_response_with_secret(
     response: &EventResponseMessage,
     message_secret: &[u8],
@@
 ) -> Result<(Vec<u8>, [u8; GCM_IV_SIZE])> {
+    ensure!(
+        message_secret.len() == 32,
+        "message_secret must be exactly 32 bytes"
+    );
     let plaintext = response.encode_to_vec();
@@
 pub fn decrypt_event_response_with_secret(
     enc_payload: &[u8],
     iv: &[u8],
     message_secret: &[u8],
@@
 ) -> Result<EventResponseMessage> {
+    ensure!(
+        message_secret.len() == 32,
+        "message_secret must be exactly 32 bytes"
+    );
+    ensure!(
+        iv.len() == GCM_IV_SIZE,
+        "iv must be exactly {} bytes",
+        GCM_IV_SIZE
+    );
     let plaintext = decrypt_addon(

Also applies to: 48-55

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wacore/src/event.rs` around lines 29 - 35, Validate fixed-size crypto inputs
at the API boundary: in encrypt_event_response_with_secret (and the other
function that accepts an iv slice) check that message_secret.len() == 32 and
iv.len() == GCM_IV_SIZE (12) immediately and return an Err with a clear error if
sizes mismatch; do this before any cryptographic operations so callers get
deterministic errors rather than downstream panics/crypto failures, and
reference the functions encrypt_event_response_with_secret and the corresponding
decrypt/iv-handling function to locate and apply the same checks.

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<EventResponseMessage> {
// 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()
);
}
}
Loading
Loading