diff --git a/src/features/message_edit.rs b/src/features/message_edit.rs index b33cd2903..4ceca0205 100644 --- a/src/features/message_edit.rs +++ b/src/features/message_edit.rs @@ -25,6 +25,7 @@ use anyhow::{Result, anyhow}; use log::warn; use wacore::message_edit::{self, MessageEditContext}; +use wacore::secret_enc_addon::ModificationType; use wacore_binary::Jid; use waproto::whatsapp as wa; @@ -81,39 +82,16 @@ pub fn decrypt_with_fallback( fallback_original_sender: Option<&Jid>, fallback_editor: Option<&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 = MessageEditContext { - original_msg_id, - original_sender_jid: &primary_orig, - editor_jid: &primary_editor, - }; - - let fb_orig = fallback_original_sender.map(|j| j.to_non_ad().to_string()); - let fb_editor = fallback_editor.map(|j| j.to_non_ad().to_string()); - let fb_orig_resolved = fb_orig.as_deref().unwrap_or(primary.original_sender_jid); - let fb_editor_resolved = fb_editor.as_deref().unwrap_or(primary.editor_jid); - // Skip the retry when the fallback would key the HKDF identically to - // primary — covers both "no fallback supplied" and "fallback normalises - // to the same JIDs". Avoids a guaranteed-failing duplicate decrypt. - let fallback_ctx = if fb_orig_resolved == primary.original_sender_jid - && fb_editor_resolved == primary.editor_jid - { - None - } else { - Some(MessageEditContext { - original_msg_id, - original_sender_jid: fb_orig_resolved, - editor_jid: fb_editor_resolved, - }) - }; - - message_edit::decrypt_message_edit_with_fallback( + decrypt_secret_encrypted_with_fallback( enc_payload, enc_iv, message_secret, - &primary, - fallback_ctx.as_ref(), + SecretEncKind::MessageEdit, + original_msg_id, + original_sender_jid, + editor_jid, + fallback_original_sender, + fallback_editor, ) } @@ -125,31 +103,12 @@ pub fn decrypt_with_fallback( /// Malformed-but-tagged envelopes emit a `log::warn!` so the gap is /// visible without exposing the encrypted payload. pub fn extract_envelope(msg: &wa::Message) -> Option> { - let sec = msg.secret_encrypted_message.as_ref()?; - let enc_type = sec.secret_enc_type(); - if enc_type != wa::message::secret_encrypted_message::SecretEncType::MessageEdit { - return None; - } - let target_key = sec.target_message_key.as_ref(); - let enc_payload = sec.enc_payload.as_deref(); - let enc_iv = sec.enc_iv.as_deref(); - - match (target_key, enc_payload, enc_iv) { - (Some(tk), Some(payload), Some(iv)) if iv.len() == 12 => Some(EncryptedEdit { - enc_payload: payload, - enc_iv: iv, - target_message_key: tk, - }), - (tk, payload, iv) => { - warn!( - "secret_encrypted_message MESSAGE_EDIT malformed: target_id={:?} has_payload={} iv_len={:?} (expected 12)", - tk.and_then(|t| t.id.as_deref()), - payload.is_some(), - iv.map(|b| b.len()), - ); - None - } - } + let env = extract_secret_encrypted(msg)?; + (env.kind == SecretEncKind::MessageEdit).then_some(EncryptedEdit { + enc_payload: env.enc_payload, + enc_iv: env.enc_iv, + target_message_key: env.target_message_key, + }) } /// Rewrap a decrypted edit `inner` into the same shape produced by the @@ -208,24 +167,194 @@ impl<'a> EncryptedEdit<'a> { /// 2. `my_jid` if `from_me == Some(true)` (self-sent edit sync). /// 3. `remote_jid` (1:1 incoming edit; the chat is the other party). pub fn original_sender_jid(&self, my_jid: &Jid) -> Result { - if let Some(p) = self.target_message_key.participant.as_deref() { - return p - .parse::() - .map_err(|e| anyhow!("invalid participant jid in target key: {e}")); + resolve_target_sender(self.target_message_key, my_jid) + } +} + +/// Resolve the original sender JID from a `secret_encrypted_message`'s target +/// key (see [`EncryptedEdit::original_sender_jid`] for the rationale). +fn resolve_target_sender(target: &wa::MessageKey, my_jid: &Jid) -> Result { + if let Some(p) = target.participant.as_deref() { + return p + .parse::() + .map_err(|e| anyhow!("invalid participant jid in target key: {e}")); + } + if target.from_me == Some(true) { + return Ok(my_jid.to_non_ad()); + } + let raw = target + .remote_jid + .as_deref() + .ok_or_else(|| anyhow!("target message key missing participant and remote_jid"))?; + raw.parse::() + .map_err(|e| anyhow!("invalid remote_jid in target key: {e}")) +} + +/// Which `secret_encrypted_message` use case an envelope carries. +/// +/// These are the `SecretEncType` variants that decrypt to a `Message` with the +/// shared empty-AAD scheme. `MESSAGE_SCHEDULE` and `UNKNOWN` are intentionally +/// excluded — neither WA Web (`WAWebAddonEncryption`) nor whatsmeow assigns them +/// a use-case secret, so they are not decryptable through this path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SecretEncKind { + EventEdit, + MessageEdit, + PollEdit, + PollAddOption, +} + +impl SecretEncKind { + fn from_proto(t: wa::message::secret_encrypted_message::SecretEncType) -> Option { + use wa::message::secret_encrypted_message::SecretEncType as T; + match t { + T::EventEdit => Some(Self::EventEdit), + T::MessageEdit => Some(Self::MessageEdit), + T::PollEdit => Some(Self::PollEdit), + T::PollAddOption => Some(Self::PollAddOption), + T::MessageSchedule | T::Unknown => None, } - if self.target_message_key.from_me == Some(true) { - return Ok(my_jid.to_non_ad()); + } + + fn modification_type(self) -> ModificationType { + match self { + Self::EventEdit => ModificationType::EventEdit, + Self::MessageEdit => ModificationType::MessageEdit, + Self::PollEdit => ModificationType::PollEdit, + Self::PollAddOption => ModificationType::PollAddOption, } - let raw = self - .target_message_key - .remote_jid - .as_deref() - .ok_or_else(|| anyhow!("target message key missing participant and remote_jid"))?; - raw.parse::() - .map_err(|e| anyhow!("invalid remote_jid in target key: {e}")) } } +/// A decryptable `secret_encrypted_message` envelope of any supported kind. +/// +/// The general counterpart of [`EncryptedEdit`]: use [`extract_secret_encrypted`] +/// to obtain it, [`Self::original_sender_jid`] to resolve the targeted message's +/// author, then [`decrypt_secret_encrypted`] with the parent's `messageSecret`. +#[derive(Debug, Clone, Copy)] +pub struct SecretEncrypted<'a> { + pub kind: SecretEncKind, + pub enc_payload: &'a [u8], + pub enc_iv: &'a [u8], + pub target_message_key: &'a wa::MessageKey, +} + +impl<'a> SecretEncrypted<'a> { + pub fn target_id(&self) -> Option<&str> { + self.target_message_key.id.as_deref() + } + + pub fn original_sender_jid(&self, my_jid: &Jid) -> Result { + resolve_target_sender(self.target_message_key, my_jid) + } +} + +/// Extract any supported `secret_encrypted_message` envelope (EVENT_EDIT, +/// MESSAGE_EDIT, POLL_EDIT, POLL_ADD_OPTION) from a received message. +/// +/// 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(), + ) { + (Some(tk), Some(payload), Some(iv)) if iv.len() == 12 => Some(SecretEncrypted { + kind, + enc_payload: payload, + enc_iv: iv, + target_message_key: tk, + }), + (tk, payload, iv) => { + warn!( + "secret_encrypted_message {kind:?} malformed: target_id={:?} has_payload={} iv_len={:?} (expected 12)", + tk.and_then(|t| t.id.as_deref()), + payload.is_some(), + iv.map(|b| b.len()), + ); + None + } + } +} + +/// Decrypt a `secret_encrypted_message` of the given `kind` to its inner +/// [`wa::Message`]. JIDs are normalised the same way as [`decrypt`]. +pub fn decrypt_secret_encrypted( + enc_payload: &[u8], + enc_iv: &[u8], + message_secret: &[u8], + kind: SecretEncKind, + original_msg_id: &str, + 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 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 +/// [`decrypt_with_fallback`]. +#[allow(clippy::too_many_arguments)] +pub fn decrypt_secret_encrypted_with_fallback( + enc_payload: &[u8], + enc_iv: &[u8], + message_secret: &[u8], + kind: SecretEncKind, + original_msg_id: &str, + original_sender_jid: &Jid, + modification_sender_jid: &Jid, + 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 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_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 + && fb_sender_resolved == primary.editor_jid + { + None + } else { + Some(MessageEditContext { + original_msg_id, + original_sender_jid: fb_orig_resolved, + editor_jid: fb_sender_resolved, + }) + }; + + message_edit::decrypt_secret_encrypted_with_fallback( + enc_payload, + enc_iv, + message_secret, + kind.modification_type(), + &primary, + fallback_ctx.as_ref(), + ) +} + #[cfg(test)] mod tests { use super::*; @@ -453,4 +582,114 @@ mod tests { }; assert!(rewrap_as_legacy_edit(m).is_none()); } + + use wa::message::secret_encrypted_message::SecretEncType; + + fn secret_msg(enc_type: SecretEncType, payload: Vec, iv: Vec) -> wa::Message { + wa::Message { + secret_encrypted_message: Some(wa::message::SecretEncryptedMessage { + target_message_key: Some(wa::MessageKey { + remote_jid: Some("5510000@s.whatsapp.net".to_string()), + from_me: Some(false), + id: Some("PARENT1".to_string()), + participant: None, + }), + enc_payload: Some(payload), + enc_iv: Some(iv), + secret_enc_type: Some(enc_type as i32), + remote_key_id: None, + }), + ..Default::default() + } + } + + #[test] + fn extract_secret_encrypted_recognises_all_supported_kinds() { + for (t, k) in [ + (SecretEncType::EventEdit, SecretEncKind::EventEdit), + (SecretEncType::MessageEdit, SecretEncKind::MessageEdit), + (SecretEncType::PollEdit, SecretEncKind::PollEdit), + (SecretEncType::PollAddOption, SecretEncKind::PollAddOption), + ] { + let msg = secret_msg(t, vec![0u8; 32], vec![0u8; 12]); + let env = extract_secret_encrypted(&msg).expect("recognised"); + assert_eq!(env.kind, k); + assert_eq!(env.target_id(), Some("PARENT1")); + } + } + + #[test] + fn extract_secret_encrypted_rejects_unsupported_kinds() { + for t in [SecretEncType::MessageSchedule, SecretEncType::Unknown] { + let msg = secret_msg(t, vec![0u8; 32], vec![0u8; 12]); + assert!(extract_secret_encrypted(&msg).is_none()); + } + } + + #[test] + fn extract_envelope_still_only_matches_message_edit() { + // The MESSAGE_EDIT-specific helper must ignore other kinds even though + // the general extractor accepts them. + let poll = secret_msg(SecretEncType::PollEdit, vec![0u8; 32], vec![0u8; 12]); + assert!(extract_envelope(&poll).is_none()); + assert!(extract_secret_encrypted(&poll).is_some()); + + let edit = secret_msg(SecretEncType::MessageEdit, vec![0u8; 32], vec![0u8; 12]); + assert!(extract_envelope(&edit).is_some()); + } + + #[test] + fn decrypt_secret_encrypted_roundtrip_poll_edit() { + use prost::Message as _; + use wacore::secret_enc_addon::{AddonContext, encrypt_addon}; + + let secret = [0x63u8; 32]; + let parent_id = "PARENT1"; + let creator: Jid = "5510000@s.whatsapp.net".parse().unwrap(); + let actor: Jid = "5511111@s.whatsapp.net".parse().unwrap(); + + let payload = wa::Message { + conversation: Some("poll edited".to_string()), + ..Default::default() + } + .encode_to_vec(); + let (enc, iv) = encrypt_addon( + &payload, + &secret, + &AddonContext { + stanza_id: parent_id, + parent_msg_original_sender: &creator.to_string(), + modification_sender: &actor.to_string(), + modification_type: ModificationType::PollEdit, + }, + ) + .unwrap(); + + let msg = { + let mut m = secret_msg(SecretEncType::PollEdit, enc, iv.to_vec()); + // creator is the parent's remote_jid (1:1 incoming). + if let Some(sec) = m.secret_encrypted_message.as_mut() { + sec.target_message_key.as_mut().unwrap().remote_jid = Some(creator.to_string()); + } + m + }; + let env = extract_secret_encrypted(&msg).unwrap(); + assert_eq!(env.kind, SecretEncKind::PollEdit); + + let my_jid: Jid = "5599999@s.whatsapp.net".parse().unwrap(); + let original_sender = env.original_sender_jid(&my_jid).unwrap(); + assert_eq!(original_sender, creator); + + let out = decrypt_secret_encrypted( + env.enc_payload, + env.enc_iv, + &secret, + env.kind, + env.target_id().unwrap(), + &original_sender, + &actor, + ) + .unwrap(); + assert_eq!(out.conversation.as_deref(), Some("poll edited")); + } } diff --git a/src/features/mod.rs b/src/features/mod.rs index 61dabc1ea..e45635b65 100644 --- a/src/features/mod.rs +++ b/src/features/mod.rs @@ -38,7 +38,7 @@ pub use groups::{ pub use media_reupload::{MediaRetryResult, MediaReupload, MediaReuploadRequest}; -pub use message_edit::EncryptedEdit; +pub use message_edit::{EncryptedEdit, SecretEncKind, SecretEncrypted}; pub use mex::{Mex, MexError, MexErrorExtensions, MexGraphQLError, MexRequest, MexResponse}; diff --git a/src/lib.rs b/src/lib.rs index f54810dbc..7505399b2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -74,10 +74,10 @@ pub use features::{ MexError, MexErrorExtensions, MexRequest, MexResponse, Newsletter, NewsletterMessage, NewsletterMessageType, NewsletterMetadata, NewsletterReactionCount, NewsletterRole, NewsletterState, NewsletterVerification, ParticipantChangeResponse, ParticipantType, - PictureType, Presence, PresenceError, PresenceStatus, Profile, ProfilePicture, - SetProfilePictureResponse, Signal, Status, StatusPrivacySetting, StatusSendOptions, - SyncActionMessageRange, TcToken, UnlinkSubgroupsResult, UserInfo, group_type, message_key, - message_range, + PictureType, Presence, PresenceError, PresenceStatus, Profile, ProfilePicture, SecretEncKind, + SecretEncrypted, SetProfilePictureResponse, Signal, Status, StatusPrivacySetting, + StatusSendOptions, SyncActionMessageRange, TcToken, UnlinkSubgroupsResult, UserInfo, + group_type, message_key, message_range, }; pub mod bot; diff --git a/wacore/src/message_edit.rs b/wacore/src/message_edit.rs index da89a09d6..37ac8ebea 100644 --- a/wacore/src/message_edit.rs +++ b/wacore/src/message_edit.rs @@ -70,59 +70,113 @@ pub fn encrypt_message_edit( encrypt_addon(&plaintext, message_secret, &ctx.as_addon_ctx()) } -/// Decrypt a `secret_encrypted_message` MESSAGE_EDIT envelope. +/// Decrypt any `secret_encrypted_message` envelope to its inner `Message`. /// -/// Returns the inner `Message` whose `protocolMessage.editedMessage` carries -/// the new content. The caller can re-wrap it in `protocolMessage.editedMessage` -/// for consumer parity with the legacy edit path. +/// All `SecretEncType` envelope variants (`EVENT_EDIT`, `MESSAGE_EDIT`, +/// `POLL_EDIT`, `POLL_ADD_OPTION`) share the same shape: they decrypt to a full +/// `Message` proto with an empty AAD (per `WAWebAddonEncryption`'s function `g`), +/// differing only in the use-case secret fed into the HKDF — which is exactly +/// what `modification_type` selects. Mirrors whatsmeow's +/// `DecryptSecretEncryptedMessage` dispatch. /// /// `iv` must be exactly 12 bytes (matches WA Web's `u.length !== 12` check). -pub fn decrypt_message_edit( +pub fn decrypt_secret_encrypted( enc_payload: &[u8], iv: &[u8], message_secret: &[u8], + modification_type: ModificationType, ctx: &MessageEditContext<'_>, ) -> Result { if iv.len() != IV_SIZE { return Err(anyhow!( - "Invalid edit IV length: expected {IV_SIZE}, got {}", + "Invalid secret-encrypted IV length: expected {IV_SIZE}, got {}", iv.len() )); } - let plaintext = decrypt_addon(enc_payload, iv, message_secret, &ctx.as_addon_ctx())?; - let msg = waproto::whatsapp::Message::decode(&plaintext[..]) - .map_err(|e| anyhow!("Failed to decode inner edit Message: {e}"))?; - Ok(msg) + let addon = AddonContext { + stanza_id: ctx.original_msg_id, + parent_msg_original_sender: ctx.original_sender_jid, + modification_sender: ctx.editor_jid, + modification_type, + }; + let plaintext = decrypt_addon(enc_payload, iv, message_secret, &addon)?; + waproto::whatsapp::Message::decode(&plaintext[..]) + .map_err(|e| anyhow!("Failed to decode inner secret-encrypted Message: {e}")) } -/// Try decryption with up to two JID combinations, mirroring WA Web's -/// `decryptAddOn` resilience for cross-addressing edits. +/// Decrypt a `secret_encrypted_message` MESSAGE_EDIT envelope. /// -/// The pair `(originals, alternates)` is the (as-received, normalised) -/// combination — caller decides which is which. Returns the first success. +/// Returns the inner `Message` whose `protocolMessage.editedMessage` carries +/// the new content. The caller can re-wrap it in `protocolMessage.editedMessage` +/// for consumer parity with the legacy edit path. /// -/// WA Web tries 3 attempts (LID→LID, PN→PN, originals) but two are enough -/// in practice when the caller already knows the canonical pair. -pub fn decrypt_message_edit_with_fallback( +/// `iv` must be exactly 12 bytes (matches WA Web's `u.length !== 12` check). +pub fn decrypt_message_edit( + enc_payload: &[u8], + iv: &[u8], + message_secret: &[u8], + ctx: &MessageEditContext<'_>, +) -> Result { + decrypt_secret_encrypted( + enc_payload, + iv, + message_secret, + ModificationType::MessageEdit, + ctx, + ) +} + +/// Decrypt a secret-encrypted envelope, retrying once under an alternate +/// addressing, mirroring WA Web's `decryptAddOn` LID/PN resilience. +/// +/// `primary` is the as-received JID pair; `fallback` is the swapped pair. +/// Returns the first success. WA Web tries 3 attempts (LID→LID, PN→PN, +/// originals) but two suffice when the caller already knows the canonical pair. +pub fn decrypt_secret_encrypted_with_fallback( enc_payload: &[u8], iv: &[u8], message_secret: &[u8], + modification_type: ModificationType, primary: &MessageEditContext<'_>, fallback: Option<&MessageEditContext<'_>>, ) -> Result { - match decrypt_message_edit(enc_payload, iv, message_secret, primary) { + match decrypt_secret_encrypted(enc_payload, iv, message_secret, modification_type, primary) { Ok(m) => Ok(m), Err(primary_err) => match fallback { - Some(fb) => { - decrypt_message_edit(enc_payload, iv, message_secret, fb).map_err(|fb_err| { - anyhow!("edit decrypt failed: primary={primary_err}; fallback={fb_err}") - }) - } + Some(fb) => decrypt_secret_encrypted( + enc_payload, + iv, + message_secret, + modification_type, + fb, + ) + .map_err(|fb_err| { + anyhow!("secret-encrypted decrypt failed: primary={primary_err}; fallback={fb_err}") + }), None => Err(primary_err), }, } } +/// MESSAGE_EDIT-specialised wrapper over +/// [`decrypt_secret_encrypted_with_fallback`]. +pub fn decrypt_message_edit_with_fallback( + enc_payload: &[u8], + iv: &[u8], + message_secret: &[u8], + primary: &MessageEditContext<'_>, + fallback: Option<&MessageEditContext<'_>>, +) -> Result { + decrypt_secret_encrypted_with_fallback( + enc_payload, + iv, + message_secret, + ModificationType::MessageEdit, + primary, + fallback, + ) +} + #[cfg(test)] mod tests { use super::*; @@ -232,6 +286,41 @@ mod tests { assert!(decrypt_message_edit(&enc, &[0u8; 16], &[0x07u8; 32], &ctx).is_err()); } + #[test] + fn general_decrypt_roundtrips_non_edit_use_case() { + use crate::secret_enc_addon::{AddonContext, ModificationType, encrypt_addon}; + use prost::Message as _; + + // A POLL_EDIT envelope: same shape as MESSAGE_EDIT, different use-case. + let secret = [0x71u8; 32]; + let ctx = MessageEditContext { + original_msg_id: "POLLID", + original_sender_jid: "creator@s.whatsapp.net", + editor_jid: "editor@s.whatsapp.net", + }; + let inner = wa::Message { + conversation: Some("poll edited".to_string()), + ..Default::default() + }; + let (enc, iv) = encrypt_addon( + &inner.encode_to_vec(), + &secret, + &AddonContext { + stanza_id: ctx.original_msg_id, + parent_msg_original_sender: ctx.original_sender_jid, + modification_sender: ctx.editor_jid, + modification_type: ModificationType::PollEdit, + }, + ) + .unwrap(); + + // Correct use-case decrypts; the MESSAGE_EDIT wrapper (wrong use-case) fails. + let out = + decrypt_secret_encrypted(&enc, &iv, &secret, ModificationType::PollEdit, &ctx).unwrap(); + assert_eq!(out.conversation.as_deref(), Some("poll edited")); + assert!(decrypt_message_edit(&enc, &iv, &secret, &ctx).is_err()); + } + #[test] fn fallback_recovers_on_alternate_jid_form() { let secret = [0x09u8; 32];