Skip to main content

whatsapp_rust/features/
message_edit.rs

1//! Decryption of E2E message-edit envelopes (`secret_encrypted_message`
2//! with `secret_enc_type = MESSAGE_EDIT`).
3//!
4//! See [`wacore::message_edit`] for the cryptographic primitives. This
5//! module is the high-level surface: it takes typed [`Jid`]s, normalises
6//! them the same way WA Web does (strip device suffix, optional LID↔PN
7//! fallback) and returns the decrypted inner [`wa::Message`].
8//!
9//! ### Integration
10//!
11//! The library does not auto-decrypt edits on the dispatch path because
12//! doing so requires a callback into the consumer's message store to
13//! fetch the parent's `messageContextInfo.messageSecret`. Consumers:
14//!
15//! 1. Observe `Event::Messages` for messages whose
16//!    `message.secret_encrypted_message.secret_enc_type == MessageEdit`.
17//! 2. Detect the envelope with [`extract_envelope`].
18//! 3. Look up the targeted message via `target_message_key`.
19//! 4. Call [`decrypt`] with the parent's `messageSecret`.
20//! 5. Optionally call [`rewrap_as_legacy_edit`] so downstream code that
21//!    already handles `protocol_message.edited_message` sees one shape.
22//!
23//! Mirrors the existing flow for poll vote decryption (`Polls::decrypt_vote`).
24
25use anyhow::{Result, anyhow};
26use buffa::MessageField;
27use log::warn;
28use wacore::message_edit::{self, MessageEditContext};
29use wacore::secret_enc_addon::ModificationType;
30use wacore_binary::Jid;
31use wacore_binary::jid::JidError;
32use waproto::whatsapp as wa;
33
34/// Failures of the target-key sender resolvers ([`EncryptedEdit::original_sender_jid`],
35/// [`SecretEncrypted::original_sender_jid`], [`SecretEncrypted::original_sender_for_dispatch`]).
36///
37/// Both variants mean the peer sent a target message key we cannot attribute,
38/// so retrying the same envelope yields the same result.
39#[derive(Debug, thiserror::Error)]
40#[non_exhaustive]
41pub enum MessageEditError {
42    /// A JID carried by the target message key did not parse.
43    #[error("invalid {field} in target message key")]
44    InvalidTargetJid {
45        /// Wire name of the offending target-key field.
46        field: &'static str,
47        #[source]
48        source: JidError,
49    },
50    /// The target key carried neither `participant` nor `remote_jid`, and
51    /// `from_me` was not `Some(true)`, so no author can be derived from it.
52    #[error("target message key missing participant and remote_jid")]
53    MissingTargetSender,
54}
55
56/// Decrypt a `secret_encrypted_message` MESSAGE_EDIT envelope.
57///
58/// JIDs may carry their device suffix — they are normalised before being
59/// fed into the HKDF info buffer (matching WA Web's `widToUserJid`).
60///
61/// Returns the inner [`wa::Message`]; the new content is at
62/// `result.protocol_message.edited_message`.
63///
64/// Implementation notes:
65/// - HKDF: `salt = zeros[32]`, `ikm = message_secret`,
66///   `info = original_msg_id || original_sender_jid || editor_jid || "Message Edit"`,
67///   `L = 32`.
68/// - AAD: empty. WA Web's `WAWebAddonEncryption` (function `g`) only binds
69///   `stanzaId\0sender` into AAD for PollVote/EventResponse; everything
70///   else, including MessageEdit, uses an empty AAD.
71/// - IV must be exactly 12 bytes (matches WA Web's
72///   `WAWebParseMessageEditEncryptedMessageProto`).
73pub fn decrypt(
74    enc_payload: &[u8],
75    enc_iv: &[u8],
76    message_secret: &[u8],
77    original_msg_id: &str,
78    original_sender_jid: &Jid,
79    editor_jid: &Jid,
80) -> Result<wa::Message> {
81    let primary_orig = original_sender_jid.to_non_ad_string();
82    let primary_editor = editor_jid.to_non_ad_string();
83    let primary = MessageEditContext {
84        original_msg_id,
85        original_sender_jid: &primary_orig,
86        editor_jid: &primary_editor,
87    };
88    message_edit::decrypt_message_edit(enc_payload, enc_iv, message_secret, &primary)
89}
90
91/// Same as [`decrypt`] but tries a fallback addressing combination if
92/// the first attempt fails its GCM tag check.
93///
94/// `fallback_original_sender` / `fallback_editor` are typically the LID
95/// form when the primary attempt used PN form (or vice versa). Mirrors
96/// `WAWebAddonEncryption.decryptAddOn`, which falls back across LID/PN
97/// to handle cross-addressing edits between newer and legacy clients.
98#[allow(clippy::too_many_arguments)]
99pub fn decrypt_with_fallback(
100    enc_payload: &[u8],
101    enc_iv: &[u8],
102    message_secret: &[u8],
103    original_msg_id: &str,
104    original_sender_jid: &Jid,
105    editor_jid: &Jid,
106    fallback_original_sender: Option<&Jid>,
107    fallback_editor: Option<&Jid>,
108) -> Result<wa::Message> {
109    decrypt_secret_encrypted_with_fallback(
110        enc_payload,
111        enc_iv,
112        message_secret,
113        SecretEncKind::MessageEdit,
114        original_msg_id,
115        original_sender_jid,
116        editor_jid,
117        fallback_original_sender,
118        fallback_editor,
119    )
120}
121
122/// Pull `enc_payload` / `enc_iv` / `target_message_key` out of a received
123/// [`wa::Message`] if it carries a MESSAGE_EDIT envelope. Returns `None`
124/// if the message is not an encrypted edit, or if the envelope is
125/// malformed (missing fields, IV not 12 bytes).
126///
127/// Malformed-but-tagged envelopes emit a `log::warn!` so the gap is
128/// visible without exposing the encrypted payload.
129pub fn extract_envelope(msg: &wa::Message) -> Option<EncryptedEdit<'_>> {
130    let env = extract_secret_encrypted(msg)?;
131    (env.kind == SecretEncKind::MessageEdit).then_some(EncryptedEdit {
132        enc_payload: env.enc_payload,
133        enc_iv: env.enc_iv,
134        target_message_key: env.target_message_key,
135    })
136}
137
138/// Rewrap a decrypted edit `inner` into the same shape produced by the
139/// legacy `protocol_message.edited_message` path so downstream consumers
140/// can use one code path:
141///
142/// ```text
143/// Message { protocol_message: { edited_message: <inner_edited_message> } }
144/// ```
145///
146/// `inner` is the value returned by [`decrypt`]. Returns `None` if the
147/// decrypted message did not contain `protocol_message.edited_message`
148/// (caller should log + skip).
149pub fn rewrap_as_legacy_edit(inner: wa::Message) -> Option<wa::Message> {
150    let pm = inner.protocol_message.into_option()?;
151    let edited = pm.edited_message.into_option()?;
152    Some(wa::Message {
153        protocol_message: MessageField::some(wa::message::ProtocolMessage {
154            key: pm.key,
155            r#type: Some(wa::message::protocol_message::Type::MESSAGE_EDIT),
156            edited_message: MessageField::some(edited),
157            timestamp_ms: pm.timestamp_ms,
158            ..Default::default()
159        }),
160        ..Default::default()
161    })
162}
163
164/// Extracted edit-envelope fields ready to feed into [`decrypt`].
165#[derive(Debug, Clone, Copy)]
166pub struct EncryptedEdit<'a> {
167    pub enc_payload: &'a [u8],
168    pub enc_iv: &'a [u8],
169    pub target_message_key: &'a wa::MessageKey,
170}
171
172impl<'a> EncryptedEdit<'a> {
173    /// Convenience: returns the targeted message id.
174    pub fn target_id(&self) -> Option<&str> {
175        self.target_message_key.id.as_deref()
176    }
177
178    /// Resolve the original sender JID from the target message key alone.
179    ///
180    /// CAUTION: `target_message_key` is written in the *editor's* frame, so its
181    /// `from_me` is `true` for any edit the editor authored, including an
182    /// incoming peer edit, where it then resolves to `my_jid` (the receiver)
183    /// rather than the peer. On the receive path use
184    /// [`Self::original_sender_for_dispatch`], which takes the author from the
185    /// envelope frame. This target-key-only resolver is kept for callers that
186    /// have no envelope frame.
187    ///
188    /// `my_jid` is the receiver's own JID in the addressing mode of the chat
189    /// (PN or LID). Resolution order:
190    /// 1. `participant` if present (always set in groups).
191    /// 2. `my_jid` if `from_me == Some(true)`.
192    /// 3. `remote_jid` otherwise.
193    pub fn original_sender_jid(&self, my_jid: &Jid) -> Result<Jid, MessageEditError> {
194        resolve_target_sender(self.target_message_key, my_jid)
195    }
196
197    /// Resolve the edit's parent author from the dispatch-time envelope frame.
198    ///
199    /// A message can only be edited by its author, so the parent author of a
200    /// MESSAGE_EDIT is always the editor: ourselves for a self-synced edit
201    /// (`is_from_me`), else the envelope sender. Mirrors WA Web's
202    /// `MsgGetters.getOriginalSender = originalSelfAuthor || sender` and is the
203    /// correct resolver for the receive path, where the target key's `from_me`
204    /// reflects the editor, not the receiver.
205    pub fn original_sender_for_dispatch(
206        &self,
207        is_from_me: bool,
208        envelope_sender: &Jid,
209        my_jid: &Jid,
210    ) -> Jid {
211        edit_author_from_envelope(is_from_me, envelope_sender, my_jid)
212    }
213}
214
215/// Resolve a MESSAGE_EDIT's parent author from the dispatch-time envelope
216/// frame: us for a self-synced edit (`is_from_me`), else the envelope sender.
217/// The editor is always the author, so this is the parent author too.
218fn edit_author_from_envelope(is_from_me: bool, envelope_sender: &Jid, my_jid: &Jid) -> Jid {
219    if is_from_me {
220        my_jid.to_non_ad()
221    } else {
222        envelope_sender.to_non_ad()
223    }
224}
225
226/// Resolve the original sender JID from a `secret_encrypted_message`'s target
227/// key (see [`EncryptedEdit::original_sender_jid`] for the rationale).
228fn resolve_target_sender(target: &wa::MessageKey, my_jid: &Jid) -> Result<Jid, MessageEditError> {
229    if let Some(p) = target.participant.as_deref() {
230        return p
231            .parse::<Jid>()
232            .map_err(|source| MessageEditError::InvalidTargetJid {
233                field: "participant",
234                source,
235            });
236    }
237    if target.from_me == Some(true) {
238        return Ok(my_jid.to_non_ad());
239    }
240    let raw = target
241        .remote_jid
242        .as_deref()
243        .ok_or(MessageEditError::MissingTargetSender)?;
244    raw.parse::<Jid>()
245        .map_err(|source| MessageEditError::InvalidTargetJid {
246            field: "remoteJid",
247            source,
248        })
249}
250
251/// Which `secret_encrypted_message` use case an envelope carries.
252///
253/// These are the `SecretEncType` variants that decrypt to a `Message` with the
254/// shared empty-AAD scheme. `MESSAGE_SCHEDULE` and `UNKNOWN` are intentionally
255/// excluded — neither WA Web (`WAWebAddonEncryption`) nor whatsmeow assigns them
256/// a use-case secret, so they are not decryptable through this path.
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub enum SecretEncKind {
259    EventEdit,
260    MessageEdit,
261    PollEdit,
262    PollAddOption,
263    /// `enc_reaction_message` (CAG reaction): a distinct top-level field, not a
264    /// `SecretEncType`; the inner plaintext is a `ReactionMessage`, not a `Message`.
265    EncReaction,
266    /// `enc_comment_message` (CAG channel comment): distinct top-level field;
267    /// the inner plaintext is the comment body `Message`.
268    EncComment,
269}
270
271impl SecretEncKind {
272    fn from_proto(t: wa::message::secret_encrypted_message::SecretEncType) -> Option<Self> {
273        use wa::message::secret_encrypted_message::SecretEncType as T;
274        match t {
275            T::EVENT_EDIT => Some(Self::EventEdit),
276            T::MESSAGE_EDIT => Some(Self::MessageEdit),
277            T::POLL_EDIT => Some(Self::PollEdit),
278            T::POLL_ADD_OPTION => Some(Self::PollAddOption),
279            T::MESSAGE_SCHEDULE | T::UNKNOWN => None,
280        }
281    }
282
283    fn modification_type(self) -> ModificationType {
284        match self {
285            Self::EventEdit => ModificationType::EventEdit,
286            Self::MessageEdit => ModificationType::MessageEdit,
287            Self::PollEdit => ModificationType::PollEdit,
288            Self::PollAddOption => ModificationType::PollAddOption,
289            Self::EncReaction => ModificationType::EncReaction,
290            Self::EncComment => ModificationType::EncComment,
291        }
292    }
293}
294
295/// A decryptable `secret_encrypted_message` envelope of any supported kind.
296///
297/// The general counterpart of [`EncryptedEdit`]: use [`extract_secret_encrypted`]
298/// to obtain it, [`Self::original_sender_jid`] to resolve the targeted message's
299/// author, then [`decrypt_secret_encrypted`] with the parent's `messageSecret`.
300#[derive(Debug, Clone, Copy)]
301pub struct SecretEncrypted<'a> {
302    pub kind: SecretEncKind,
303    pub enc_payload: &'a [u8],
304    pub enc_iv: &'a [u8],
305    pub target_message_key: &'a wa::MessageKey,
306}
307
308impl<'a> SecretEncrypted<'a> {
309    pub fn target_id(&self) -> Option<&str> {
310        self.target_message_key.id.as_deref()
311    }
312
313    /// Resolve the targeted message's author from the target key alone.
314    ///
315    /// See the caution on [`EncryptedEdit::original_sender_jid`]: for
316    /// MESSAGE_EDIT prefer [`Self::original_sender_for_dispatch`] on the receive
317    /// path. Authoritative for poll/event kinds (whose target key carries the
318    /// real author).
319    pub fn original_sender_jid(&self, my_jid: &Jid) -> Result<Jid, MessageEditError> {
320        resolve_target_sender(self.target_message_key, my_jid)
321    }
322
323    /// Resolve the parent message's author for the secret lookup + HKDF info,
324    /// using the dispatch-time envelope frame.
325    ///
326    /// For `MESSAGE_EDIT` the editor is always the author (you can only edit
327    /// your own message) and `target_message_key` is written in the editor's
328    /// frame — its `from_me` is `true` even for an incoming peer edit, so it is
329    /// not a reliable receiver-side signal. Take the author from the envelope
330    /// instead: ourselves for a self-synced edit, else the envelope sender.
331    /// Other kinds (poll/event) can be modified by a non-author, so for those
332    /// `target_message_key` stays authoritative.
333    pub fn original_sender_for_dispatch(
334        &self,
335        is_from_me: bool,
336        envelope_sender: &Jid,
337        my_jid: &Jid,
338    ) -> Result<Jid, MessageEditError> {
339        match self.kind {
340            SecretEncKind::MessageEdit => Ok(edit_author_from_envelope(
341                is_from_me,
342                envelope_sender,
343                my_jid,
344            )),
345            _ => resolve_target_sender(self.target_message_key, my_jid),
346        }
347    }
348}
349
350/// Extract any supported `secret_encrypted_message` envelope (EVENT_EDIT,
351/// MESSAGE_EDIT, POLL_EDIT, POLL_ADD_OPTION) from a received message.
352///
353/// Returns `None` when the message is not secret-encrypted, carries an
354/// unsupported type, or is malformed (missing fields, IV not 12 bytes).
355pub fn extract_secret_encrypted(msg: &wa::Message) -> Option<SecretEncrypted<'_>> {
356    if let Some(sec) = msg.secret_encrypted_message.as_option() {
357        let kind = SecretEncKind::from_proto(sec.secret_enc_type?)?;
358        return secret_envelope(
359            kind,
360            sec.target_message_key.as_option(),
361            sec.enc_payload.as_deref(),
362            sec.enc_iv.as_deref(),
363        );
364    }
365    if let Some(enc) = msg.enc_reaction_message.as_option() {
366        return secret_envelope(
367            SecretEncKind::EncReaction,
368            enc.target_message_key.as_option(),
369            enc.enc_payload.as_deref(),
370            enc.enc_iv.as_deref(),
371        );
372    }
373    if let Some(enc) = msg.enc_comment_message.as_option() {
374        return secret_envelope(
375            SecretEncKind::EncComment,
376            enc.target_message_key.as_option(),
377            enc.enc_payload.as_deref(),
378            enc.enc_iv.as_deref(),
379        );
380    }
381    None
382}
383
384/// Validate the shared `{target_message_key, enc_payload, enc_iv}` envelope
385/// shape (all three present, 12-byte IV) for any addon kind.
386fn secret_envelope<'a>(
387    kind: SecretEncKind,
388    target_message_key: Option<&'a wa::MessageKey>,
389    enc_payload: Option<&'a [u8]>,
390    enc_iv: Option<&'a [u8]>,
391) -> Option<SecretEncrypted<'a>> {
392    match (target_message_key, enc_payload, enc_iv) {
393        (Some(tk), Some(payload), Some(iv)) if iv.len() == 12 => Some(SecretEncrypted {
394            kind,
395            enc_payload: payload,
396            enc_iv: iv,
397            target_message_key: tk,
398        }),
399        (tk, payload, iv) => {
400            warn!(
401                "secret_encrypted_message {kind:?} malformed: target_id={:?} has_payload={} iv_len={:?} (expected 12)",
402                tk.and_then(|t| t.id.as_deref()),
403                payload.is_some(),
404                iv.map(|b| b.len()),
405            );
406            None
407        }
408    }
409}
410
411/// Decrypt a `secret_encrypted_message` of the given `kind` to its inner
412/// [`wa::Message`]. JIDs are normalised the same way as [`decrypt`].
413pub fn decrypt_secret_encrypted(
414    enc_payload: &[u8],
415    enc_iv: &[u8],
416    message_secret: &[u8],
417    kind: SecretEncKind,
418    original_msg_id: &str,
419    original_sender_jid: &Jid,
420    modification_sender_jid: &Jid,
421) -> Result<wa::Message> {
422    let orig = original_sender_jid.to_non_ad_string();
423    let sender = modification_sender_jid.to_non_ad_string();
424    match kind {
425        // The reaction plaintext is a ReactionMessage, not a Message; surface
426        // it in the plaintext-reaction shape (key filled by the caller from
427        // the envelope's target_message_key).
428        SecretEncKind::EncReaction => {
429            let reaction = wacore::reaction::decrypt_reaction_with_secret(
430                enc_payload,
431                enc_iv,
432                message_secret,
433                original_msg_id,
434                &orig,
435                &sender,
436            )?;
437            Ok(wa::Message {
438                reaction_message: MessageField::some(reaction),
439                ..Default::default()
440            })
441        }
442        SecretEncKind::EncComment => wacore::comment::decrypt_comment_with_secret(
443            enc_payload,
444            enc_iv,
445            message_secret,
446            original_msg_id,
447            &orig,
448            &sender,
449        ),
450        _ => {
451            let ctx = MessageEditContext {
452                original_msg_id,
453                original_sender_jid: &orig,
454                editor_jid: &sender,
455            };
456            message_edit::decrypt_secret_encrypted(
457                enc_payload,
458                enc_iv,
459                message_secret,
460                kind.modification_type(),
461                &ctx,
462            )
463        }
464    }
465}
466
467/// [`decrypt_secret_encrypted`] with a LID↔PN fallback addressing, mirroring
468/// [`decrypt_with_fallback`].
469#[allow(clippy::too_many_arguments)]
470pub fn decrypt_secret_encrypted_with_fallback(
471    enc_payload: &[u8],
472    enc_iv: &[u8],
473    message_secret: &[u8],
474    kind: SecretEncKind,
475    original_msg_id: &str,
476    original_sender_jid: &Jid,
477    modification_sender_jid: &Jid,
478    fallback_original_sender: Option<&Jid>,
479    fallback_modification_sender: Option<&Jid>,
480) -> Result<wa::Message> {
481    // The reaction/comment kinds decode a different inner proto, so they go
482    // through the per-kind dispatch instead of the wacore Message-only helper.
483    // Like the receive path, every distinct LID/PN combination is attempted:
484    // a migration case can need the alternate on only ONE side of the HKDF.
485    if matches!(kind, SecretEncKind::EncReaction | SecretEncKind::EncComment) {
486        let mut last_err = match decrypt_secret_encrypted(
487            enc_payload,
488            enc_iv,
489            message_secret,
490            kind,
491            original_msg_id,
492            original_sender_jid,
493            modification_sender_jid,
494        ) {
495            Ok(inner) => return Ok(inner),
496            Err(e) => e,
497        };
498
499        let combos = [
500            (fallback_original_sender, Some(modification_sender_jid)),
501            (Some(original_sender_jid), fallback_modification_sender),
502            (fallback_original_sender, fallback_modification_sender),
503        ];
504        let mut tried: Vec<(Jid, Jid)> = vec![(
505            original_sender_jid.to_non_ad(),
506            modification_sender_jid.to_non_ad(),
507        )];
508        for (orig, sender) in combos {
509            let (Some(orig), Some(sender)) = (orig, sender) else {
510                continue;
511            };
512            let pair = (orig.to_non_ad(), sender.to_non_ad());
513            if tried.contains(&pair) {
514                continue;
515            }
516            match decrypt_secret_encrypted(
517                enc_payload,
518                enc_iv,
519                message_secret,
520                kind,
521                original_msg_id,
522                orig,
523                sender,
524            ) {
525                Ok(inner) => return Ok(inner),
526                Err(e) => last_err = anyhow!("{last_err}; fallback: {e}"),
527            }
528            tried.push(pair);
529        }
530        return Err(last_err);
531    }
532
533    let orig = original_sender_jid.to_non_ad_string();
534    let sender = modification_sender_jid.to_non_ad_string();
535    let primary = MessageEditContext {
536        original_msg_id,
537        original_sender_jid: &orig,
538        editor_jid: &sender,
539    };
540
541    let fb_orig = fallback_original_sender.map(|j| j.to_non_ad_string());
542    let fb_sender = fallback_modification_sender.map(|j| j.to_non_ad_string());
543    let fb_orig_resolved = fb_orig.as_deref().unwrap_or(primary.original_sender_jid);
544    let fb_sender_resolved = fb_sender.as_deref().unwrap_or(primary.editor_jid);
545    let fallback_ctx = if fb_orig_resolved == primary.original_sender_jid
546        && fb_sender_resolved == primary.editor_jid
547    {
548        None
549    } else {
550        Some(MessageEditContext {
551            original_msg_id,
552            original_sender_jid: fb_orig_resolved,
553            editor_jid: fb_sender_resolved,
554        })
555    };
556
557    message_edit::decrypt_secret_encrypted_with_fallback(
558        enc_payload,
559        enc_iv,
560        message_secret,
561        kind.modification_type(),
562        &primary,
563        fallback_ctx.as_ref(),
564    )
565}
566
567#[cfg(test)]
568#[allow(clippy::disallowed_methods)]
569mod tests {
570    use super::*;
571    use wacore::message_edit::encrypt_message_edit;
572
573    fn inner(text: &str) -> wa::Message {
574        wa::Message {
575            protocol_message: MessageField::some(wa::message::ProtocolMessage {
576                key: MessageField::some(wa::MessageKey {
577                    remote_jid: Some("123@s.whatsapp.net".to_string()),
578                    from_me: Some(false),
579                    id: Some("AC1".to_string()),
580                    participant: None,
581                }),
582                r#type: Some(wa::message::protocol_message::Type::MESSAGE_EDIT),
583                edited_message: MessageField::some(wa::Message {
584                    conversation: Some(text.to_string()),
585                    ..Default::default()
586                }),
587                timestamp_ms: Some(1_700_000_000_000),
588                ..Default::default()
589            }),
590            ..Default::default()
591        }
592    }
593
594    #[test]
595    fn decrypt_normalises_device_suffix() {
596        let secret = [0x55u8; 32];
597        // Encrypt with the non-AD form, the only form WA actually feeds to HKDF.
598        let ctx = MessageEditContext {
599            original_msg_id: "AC1",
600            original_sender_jid: "5511999@s.whatsapp.net",
601            editor_jid: "5511999@s.whatsapp.net",
602        };
603        let (enc, iv) = encrypt_message_edit(&inner("hi"), &secret, &ctx).unwrap();
604
605        // Caller passes JIDs with device numbers — they should be stripped.
606        let with_device = "5511999:13@s.whatsapp.net".parse::<Jid>().unwrap();
607        let m = decrypt(&enc, &iv, &secret, "AC1", &with_device, &with_device).unwrap();
608        assert_eq!(
609            m.protocol_message
610                .as_option()
611                .and_then(|pm| pm.edited_message.as_option())
612                .and_then(|e| e.conversation.as_deref()),
613            Some("hi")
614        );
615    }
616
617    #[test]
618    fn extract_envelope_recognises_message_edit() {
619        let msg = wa::Message {
620            secret_encrypted_message: MessageField::some(wa::message::SecretEncryptedMessage {
621                target_message_key: MessageField::some(wa::MessageKey {
622                    remote_jid: Some("g@g.us".to_string()),
623                    from_me: Some(false),
624                    id: Some("AC1".to_string()),
625                    participant: Some("5511999@s.whatsapp.net".to_string()),
626                }),
627                enc_payload: Some(vec![0u8; 32]),
628                enc_iv: Some(vec![0u8; 12]),
629                secret_enc_type: Some(SecretEncType::MESSAGE_EDIT),
630                remote_key_id: None,
631            }),
632            ..Default::default()
633        };
634        let env = extract_envelope(&msg).expect("recognised");
635        assert_eq!(env.target_id(), Some("AC1"));
636        // Group: participant takes priority over my_jid and remote_jid.
637        let my_jid = "999@s.whatsapp.net".parse::<Jid>().unwrap();
638        assert_eq!(
639            env.original_sender_jid(&my_jid).unwrap().to_string(),
640            "5511999@s.whatsapp.net"
641        );
642    }
643
644    #[test]
645    fn original_sender_jid_uses_my_jid_for_self_sent_edits() {
646        let msg = wa::Message {
647            secret_encrypted_message: MessageField::some(wa::message::SecretEncryptedMessage {
648                target_message_key: MessageField::some(wa::MessageKey {
649                    remote_jid: Some("5510000@s.whatsapp.net".to_string()),
650                    from_me: Some(true),
651                    id: Some("AC1".to_string()),
652                    participant: None,
653                }),
654                enc_payload: Some(vec![0u8; 32]),
655                enc_iv: Some(vec![0u8; 12]),
656                secret_enc_type: Some(SecretEncType::MESSAGE_EDIT),
657                remote_key_id: None,
658            }),
659            ..Default::default()
660        };
661        let env = extract_envelope(&msg).expect("recognised");
662        let my_jid = "5511999:13@s.whatsapp.net".parse::<Jid>().unwrap();
663        // Must return my_jid (stripped of device), NOT remote_jid (the other party).
664        assert_eq!(
665            env.original_sender_jid(&my_jid).unwrap().to_string(),
666            "5511999@s.whatsapp.net"
667        );
668    }
669
670    #[test]
671    fn original_sender_jid_reports_an_unparseable_participant() {
672        let msg = wa::Message {
673            secret_encrypted_message: MessageField::some(wa::message::SecretEncryptedMessage {
674                target_message_key: MessageField::some(wa::MessageKey {
675                    remote_jid: Some("g@g.us".to_string()),
676                    from_me: Some(false),
677                    id: Some("AC1".to_string()),
678                    participant: Some("not a jid".to_string()),
679                }),
680                enc_payload: Some(vec![0u8; 32]),
681                enc_iv: Some(vec![0u8; 12]),
682                secret_enc_type: Some(SecretEncType::MESSAGE_EDIT),
683                remote_key_id: None,
684            }),
685            ..Default::default()
686        };
687        let env = extract_envelope(&msg).expect("recognised");
688        let my_jid = "5511999@s.whatsapp.net".parse::<Jid>().unwrap();
689        let err = env
690            .original_sender_jid(&my_jid)
691            .expect_err("a malformed participant must not resolve");
692        assert!(matches!(
693            err,
694            MessageEditError::InvalidTargetJid {
695                field: "participant",
696                ..
697            }
698        ));
699        assert!(std::error::Error::source(&err).is_some());
700    }
701
702    #[test]
703    fn original_sender_jid_reports_a_target_key_without_any_sender() {
704        let msg = wa::Message {
705            secret_encrypted_message: MessageField::some(wa::message::SecretEncryptedMessage {
706                target_message_key: MessageField::some(wa::MessageKey {
707                    remote_jid: None,
708                    from_me: Some(false),
709                    id: Some("AC1".to_string()),
710                    participant: None,
711                }),
712                enc_payload: Some(vec![0u8; 32]),
713                enc_iv: Some(vec![0u8; 12]),
714                secret_enc_type: Some(SecretEncType::MESSAGE_EDIT),
715                remote_key_id: None,
716            }),
717            ..Default::default()
718        };
719        let env = extract_envelope(&msg).expect("recognised");
720        let my_jid = "5511999@s.whatsapp.net".parse::<Jid>().unwrap();
721        let err = env
722            .original_sender_jid(&my_jid)
723            .expect_err("a target key with no sender must not resolve");
724        assert!(matches!(err, MessageEditError::MissingTargetSender));
725    }
726
727    #[test]
728    fn original_sender_jid_reports_an_unparseable_remote_jid() {
729        let msg = wa::Message {
730            secret_encrypted_message: MessageField::some(wa::message::SecretEncryptedMessage {
731                target_message_key: MessageField::some(wa::MessageKey {
732                    remote_jid: Some("not a jid".to_string()),
733                    from_me: Some(false),
734                    id: Some("AC1".to_string()),
735                    participant: None,
736                }),
737                enc_payload: Some(vec![0u8; 32]),
738                enc_iv: Some(vec![0u8; 12]),
739                secret_enc_type: Some(SecretEncType::MESSAGE_EDIT),
740                remote_key_id: None,
741            }),
742            ..Default::default()
743        };
744        let env = extract_envelope(&msg).expect("recognised");
745        let my_jid = "5511999@s.whatsapp.net".parse::<Jid>().unwrap();
746        let err = env
747            .original_sender_jid(&my_jid)
748            .expect_err("a malformed remote_jid must not resolve");
749        assert!(matches!(
750            err,
751            MessageEditError::InvalidTargetJid {
752                field: "remoteJid",
753                ..
754            }
755        ));
756        assert!(std::error::Error::source(&err).is_some());
757    }
758
759    #[test]
760    fn original_sender_jid_uses_remote_jid_when_target_not_from_me() {
761        // Unit-tests the `resolve_target_sender` remote_jid branch, not a real
762        // edit frame: an actual incoming peer edit writes the target key in the
763        // editor's frame (from_me=true), so the receive path uses the envelope
764        // sender via `original_sender_for_dispatch`, not this resolver.
765        let msg = wa::Message {
766            secret_encrypted_message: MessageField::some(wa::message::SecretEncryptedMessage {
767                target_message_key: MessageField::some(wa::MessageKey {
768                    remote_jid: Some("5510000@s.whatsapp.net".to_string()),
769                    from_me: Some(false),
770                    id: Some("AC1".to_string()),
771                    participant: None,
772                }),
773                enc_payload: Some(vec![0u8; 32]),
774                enc_iv: Some(vec![0u8; 12]),
775                secret_enc_type: Some(SecretEncType::MESSAGE_EDIT),
776                remote_key_id: None,
777            }),
778            ..Default::default()
779        };
780        let env = extract_envelope(&msg).expect("recognised");
781        let my_jid = "5511999@s.whatsapp.net".parse::<Jid>().unwrap();
782        assert_eq!(
783            env.original_sender_jid(&my_jid).unwrap().to_string(),
784            "5510000@s.whatsapp.net"
785        );
786    }
787
788    #[test]
789    fn encrypted_edit_dispatch_resolver_uses_envelope_frame() {
790        // The MESSAGE_EDIT-specific consumer API resolves from the envelope
791        // frame, ignoring the editor-framed target key (here from_me=true).
792        let msg = wa::Message {
793            secret_encrypted_message: MessageField::some(wa::message::SecretEncryptedMessage {
794                target_message_key: MessageField::some(wa::MessageKey {
795                    remote_jid: Some("100000000000001@lid".to_string()),
796                    from_me: Some(true),
797                    id: Some("AC1".to_string()),
798                    participant: None,
799                }),
800                enc_payload: Some(vec![0u8; 32]),
801                enc_iv: Some(vec![0u8; 12]),
802                secret_enc_type: Some(SecretEncType::MESSAGE_EDIT),
803                remote_key_id: None,
804            }),
805            ..Default::default()
806        };
807        let env = extract_envelope(&msg).expect("recognised");
808        let my_jid = "100000000000001:3@lid".parse::<Jid>().unwrap();
809        let editor = "200000000000002@lid".parse::<Jid>().unwrap();
810        // Incoming peer edit → envelope sender (editor); device suffix stripped.
811        assert_eq!(
812            env.original_sender_for_dispatch(false, &editor, &my_jid)
813                .to_string(),
814            "200000000000002@lid"
815        );
816        // Self-synced edit → us, device suffix stripped.
817        assert_eq!(
818            env.original_sender_for_dispatch(true, &editor, &my_jid)
819                .to_string(),
820            "100000000000001@lid"
821        );
822    }
823
824    #[test]
825    fn message_edit_original_sender_uses_envelope_sender_for_incoming_peer_edit() {
826        // Captured wire data: when a peer edits THEIR OWN message, the target
827        // key is written in the EDITOR's frame — from_me=true, no participant,
828        // even in groups. Trusting target_key.from_me resolves the original
829        // sender to *us*, so the parent messageSecret lookup misses. The editor
830        // is always the author (you can only edit your own message), so the
831        // sender must come from the envelope frame.
832        let msg = wa::Message {
833            secret_encrypted_message: MessageField::some(wa::message::SecretEncryptedMessage {
834                target_message_key: MessageField::some(wa::MessageKey {
835                    remote_jid: Some("100000000000001@lid".to_string()), // our LID (editor's frame)
836                    from_me: Some(true),
837                    id: Some("AC1".to_string()),
838                    participant: None,
839                }),
840                enc_payload: Some(vec![0u8; 32]),
841                enc_iv: Some(vec![0u8; 12]),
842                secret_enc_type: Some(SecretEncType::MESSAGE_EDIT),
843                remote_key_id: None,
844            }),
845            ..Default::default()
846        };
847        let env = extract_secret_encrypted(&msg).expect("recognised");
848        let my_jid = "100000000000001@lid".parse::<Jid>().unwrap();
849        let editor = "200000000000002@lid".parse::<Jid>().unwrap();
850        // Incoming peer edit: envelope is NOT from me → sender is the editor.
851        assert_eq!(
852            env.original_sender_for_dispatch(false, &editor, &my_jid)
853                .unwrap()
854                .to_string(),
855            "200000000000002@lid"
856        );
857    }
858
859    #[test]
860    fn message_edit_original_sender_uses_my_jid_for_self_synced_edit() {
861        // Our own edit, synced from another linked device: the envelope IS from
862        // me, so the original sender is us — device suffix stripped.
863        let msg = wa::Message {
864            secret_encrypted_message: MessageField::some(wa::message::SecretEncryptedMessage {
865                target_message_key: MessageField::some(wa::MessageKey {
866                    remote_jid: Some("200000000000002@lid".to_string()),
867                    from_me: Some(true),
868                    id: Some("AC1".to_string()),
869                    participant: None,
870                }),
871                enc_payload: Some(vec![0u8; 32]),
872                enc_iv: Some(vec![0u8; 12]),
873                secret_enc_type: Some(SecretEncType::MESSAGE_EDIT),
874                remote_key_id: None,
875            }),
876            ..Default::default()
877        };
878        let env = extract_secret_encrypted(&msg).expect("recognised");
879        let my_jid = "100000000000001:3@lid".parse::<Jid>().unwrap();
880        let editor = "100000000000001@lid".parse::<Jid>().unwrap();
881        assert_eq!(
882            env.original_sender_for_dispatch(true, &editor, &my_jid)
883                .unwrap()
884                .to_string(),
885            "100000000000001@lid"
886        );
887    }
888
889    #[test]
890    fn poll_edit_original_sender_still_uses_target_key() {
891        // Regression guard: poll/event modifications can be authored by someone
892        // other than the target's author (e.g. a peer votes on our poll), so the
893        // target key stays authoritative for non-edit kinds.
894        let msg = wa::Message {
895            secret_encrypted_message: MessageField::some(wa::message::SecretEncryptedMessage {
896                target_message_key: MessageField::some(wa::MessageKey {
897                    remote_jid: Some("g@g.us".to_string()),
898                    from_me: Some(false),
899                    id: Some("AC1".to_string()),
900                    participant: Some("creator@s.whatsapp.net".to_string()),
901                }),
902                enc_payload: Some(vec![0u8; 32]),
903                enc_iv: Some(vec![0u8; 12]),
904                secret_enc_type: Some(SecretEncType::POLL_EDIT),
905                remote_key_id: None,
906            }),
907            ..Default::default()
908        };
909        let env = extract_secret_encrypted(&msg).expect("recognised");
910        let my_jid = "999@s.whatsapp.net".parse::<Jid>().unwrap();
911        let voter = "voter@s.whatsapp.net".parse::<Jid>().unwrap();
912        // Envelope sender (voter) differs, but the target's participant (poll
913        // creator) wins for poll kinds.
914        assert_eq!(
915            env.original_sender_for_dispatch(false, &voter, &my_jid)
916                .unwrap()
917                .to_string(),
918            "creator@s.whatsapp.net"
919        );
920    }
921
922    #[test]
923    fn extract_envelope_rejects_non_edit_secret_enc_type() {
924        let msg = wa::Message {
925            secret_encrypted_message: MessageField::some(wa::message::SecretEncryptedMessage {
926                target_message_key: MessageField::some(wa::MessageKey::default()),
927                enc_payload: Some(vec![0u8; 32]),
928                enc_iv: Some(vec![0u8; 12]),
929                secret_enc_type: Some(SecretEncType::EVENT_EDIT),
930                remote_key_id: None,
931            }),
932            ..Default::default()
933        };
934        assert!(extract_envelope(&msg).is_none());
935    }
936
937    #[test]
938    fn extract_envelope_rejects_invalid_iv_size() {
939        let msg = wa::Message {
940            secret_encrypted_message: MessageField::some(wa::message::SecretEncryptedMessage {
941                target_message_key: MessageField::some(wa::MessageKey::default()),
942                enc_payload: Some(vec![0u8; 32]),
943                enc_iv: Some(vec![0u8; 11]),
944                secret_enc_type: Some(SecretEncType::MESSAGE_EDIT),
945                remote_key_id: None,
946            }),
947            ..Default::default()
948        };
949        assert!(extract_envelope(&msg).is_none());
950    }
951
952    #[test]
953    fn fallback_normalising_to_primary_jids_is_skipped() {
954        // wacore::message_edit::decrypt_message_edit_with_fallback returns the
955        // bare primary error when no fallback is run, or a combined
956        // "edit decrypt failed: primary=...; fallback=..." when both attempts
957        // run. We use that to assert the dedup path.
958        let secret = [0xAAu8; 32];
959        let real_ctx = MessageEditContext {
960            original_msg_id: "ID",
961            original_sender_jid: "5511777@s.whatsapp.net",
962            editor_jid: "5511777@s.whatsapp.net",
963        };
964        let (enc, iv) = encrypt_message_edit(&inner("hi"), &secret, &real_ctx).unwrap();
965
966        // Wrong primary JID so decrypt fails; fallback is a device-suffixed
967        // form of the *same* wrong jid → normalises identical → must be skipped.
968        let wrong = "5511000@s.whatsapp.net".parse::<Jid>().unwrap();
969        let wrong_with_device = "5511000:5@s.whatsapp.net".parse::<Jid>().unwrap();
970
971        let err = decrypt_with_fallback(
972            &enc,
973            &iv,
974            &secret,
975            "ID",
976            &wrong,
977            &wrong,
978            Some(&wrong_with_device),
979            Some(&wrong_with_device),
980        )
981        .expect_err("decryption should fail");
982        assert!(
983            !err.to_string().contains("fallback="),
984            "no-op fallback must be skipped, got: {err}"
985        );
986    }
987
988    #[test]
989    fn rewrap_yields_legacy_shape() {
990        let dec = inner("edited");
991        let rewrap = rewrap_as_legacy_edit(dec).expect("present");
992        let edited = rewrap
993            .protocol_message
994            .as_option()
995            .and_then(|pm| pm.edited_message.as_option())
996            .and_then(|m| m.conversation.as_deref());
997        assert_eq!(edited, Some("edited"));
998        assert_eq!(
999            rewrap.protocol_message.as_option().and_then(|pm| pm.r#type),
1000            Some(wa::message::protocol_message::Type::MESSAGE_EDIT)
1001        );
1002    }
1003
1004    #[test]
1005    fn rewrap_returns_none_when_inner_missing_edit() {
1006        let m = wa::Message {
1007            protocol_message: MessageField::some(wa::message::ProtocolMessage::default()),
1008            ..Default::default()
1009        };
1010        assert!(rewrap_as_legacy_edit(m).is_none());
1011    }
1012
1013    use wa::message::secret_encrypted_message::SecretEncType;
1014
1015    fn secret_msg(enc_type: SecretEncType, payload: Vec<u8>, iv: Vec<u8>) -> wa::Message {
1016        wa::Message {
1017            secret_encrypted_message: MessageField::some(wa::message::SecretEncryptedMessage {
1018                target_message_key: MessageField::some(wa::MessageKey {
1019                    remote_jid: Some("5510000@s.whatsapp.net".to_string()),
1020                    from_me: Some(false),
1021                    id: Some("PARENT1".to_string()),
1022                    participant: None,
1023                }),
1024                enc_payload: Some(payload),
1025                enc_iv: Some(iv),
1026                secret_enc_type: Some(enc_type),
1027                remote_key_id: None,
1028            }),
1029            ..Default::default()
1030        }
1031    }
1032
1033    #[test]
1034    fn extract_secret_encrypted_recognises_all_supported_kinds() {
1035        for (t, k) in [
1036            (SecretEncType::EVENT_EDIT, SecretEncKind::EventEdit),
1037            (SecretEncType::MESSAGE_EDIT, SecretEncKind::MessageEdit),
1038            (SecretEncType::POLL_EDIT, SecretEncKind::PollEdit),
1039            (SecretEncType::POLL_ADD_OPTION, SecretEncKind::PollAddOption),
1040        ] {
1041            let msg = secret_msg(t, vec![0u8; 32], vec![0u8; 12]);
1042            let env = extract_secret_encrypted(&msg).expect("recognised");
1043            assert_eq!(env.kind, k);
1044            assert_eq!(env.target_id(), Some("PARENT1"));
1045        }
1046    }
1047
1048    #[test]
1049    fn extract_secret_encrypted_rejects_unsupported_kinds() {
1050        for t in [SecretEncType::MESSAGE_SCHEDULE, SecretEncType::UNKNOWN] {
1051            let msg = secret_msg(t, vec![0u8; 32], vec![0u8; 12]);
1052            assert!(extract_secret_encrypted(&msg).is_none());
1053        }
1054    }
1055
1056    #[test]
1057    fn extract_envelope_still_only_matches_message_edit() {
1058        // The MESSAGE_EDIT-specific helper must ignore other kinds even though
1059        // the general extractor accepts them.
1060        let poll = secret_msg(SecretEncType::POLL_EDIT, vec![0u8; 32], vec![0u8; 12]);
1061        assert!(extract_envelope(&poll).is_none());
1062        assert!(extract_secret_encrypted(&poll).is_some());
1063
1064        let edit = secret_msg(SecretEncType::MESSAGE_EDIT, vec![0u8; 32], vec![0u8; 12]);
1065        assert!(extract_envelope(&edit).is_some());
1066    }
1067
1068    #[test]
1069    fn decrypt_secret_encrypted_roundtrip_poll_edit() {
1070        use buffa::Message as _;
1071        use wacore::secret_enc_addon::{AddonContext, encrypt_addon};
1072
1073        let secret = [0x63u8; 32];
1074        let parent_id = "PARENT1";
1075        let creator: Jid = "5510000@s.whatsapp.net".parse().unwrap();
1076        let actor: Jid = "5511111@s.whatsapp.net".parse().unwrap();
1077
1078        let payload = wa::Message {
1079            conversation: Some("poll edited".to_string()),
1080            ..Default::default()
1081        }
1082        .encode_to_vec();
1083        let (enc, iv) = encrypt_addon(
1084            &payload,
1085            &secret,
1086            &AddonContext {
1087                stanza_id: parent_id,
1088                parent_msg_original_sender: &creator.to_string(),
1089                modification_sender: &actor.to_string(),
1090                modification_type: ModificationType::PollEdit,
1091            },
1092        )
1093        .unwrap();
1094
1095        let msg = {
1096            let mut m = secret_msg(SecretEncType::POLL_EDIT, enc, iv.to_vec());
1097            // creator is the parent's remote_jid (1:1 incoming).
1098            if let Some(sec) = m.secret_encrypted_message.as_option_mut()
1099                && let Some(key) = sec.target_message_key.as_option_mut()
1100            {
1101                key.remote_jid = Some(creator.to_string());
1102            }
1103            m
1104        };
1105        let env = extract_secret_encrypted(&msg).unwrap();
1106        assert_eq!(env.kind, SecretEncKind::PollEdit);
1107
1108        let my_jid: Jid = "5599999@s.whatsapp.net".parse().unwrap();
1109        let original_sender = env.original_sender_jid(&my_jid).unwrap();
1110        assert_eq!(original_sender, creator);
1111
1112        let out = decrypt_secret_encrypted(
1113            env.enc_payload,
1114            env.enc_iv,
1115            &secret,
1116            env.kind,
1117            env.target_id().unwrap(),
1118            &original_sender,
1119            &actor,
1120        )
1121        .unwrap();
1122        assert_eq!(out.conversation.as_deref(), Some("poll edited"));
1123    }
1124}
1125
1126#[cfg(test)]
1127mod enc_addon_tests {
1128    use super::*;
1129
1130    fn key(id: &str) -> wa::MessageKey {
1131        wa::MessageKey {
1132            id: Some(id.to_string()),
1133            ..Default::default()
1134        }
1135    }
1136
1137    #[test]
1138    fn extract_recognises_enc_reaction_and_comment_envelopes() {
1139        let reaction = wa::Message {
1140            enc_reaction_message: MessageField::some(wa::message::EncReactionMessage {
1141                target_message_key: MessageField::some(key("PARENT1")),
1142                enc_payload: Some(vec![0; 32]),
1143                enc_iv: Some(vec![0; 12]),
1144            }),
1145            ..Default::default()
1146        };
1147        let env = extract_secret_encrypted(&reaction).expect("reaction recognised");
1148        assert_eq!(env.kind, SecretEncKind::EncReaction);
1149        assert_eq!(env.target_id(), Some("PARENT1"));
1150
1151        let comment = wa::Message {
1152            enc_comment_message: MessageField::some(wa::message::EncCommentMessage {
1153                target_message_key: MessageField::some(key("PARENT2")),
1154                enc_payload: Some(vec![0; 32]),
1155                enc_iv: Some(vec![0; 12]),
1156            }),
1157            ..Default::default()
1158        };
1159        let env = extract_secret_encrypted(&comment).expect("comment recognised");
1160        assert_eq!(env.kind, SecretEncKind::EncComment);
1161        assert_eq!(env.target_id(), Some("PARENT2"));
1162    }
1163
1164    #[test]
1165    fn extract_rejects_malformed_enc_reaction_envelope() {
1166        let bad_iv = wa::Message {
1167            enc_reaction_message: MessageField::some(wa::message::EncReactionMessage {
1168                target_message_key: MessageField::some(key("PARENT1")),
1169                enc_payload: Some(vec![0; 32]),
1170                enc_iv: Some(vec![0; 8]),
1171            }),
1172            ..Default::default()
1173        };
1174        assert!(extract_secret_encrypted(&bad_iv).is_none());
1175
1176        let no_key = wa::Message {
1177            enc_reaction_message: MessageField::some(wa::message::EncReactionMessage {
1178                target_message_key: MessageField::none(),
1179                enc_payload: Some(vec![0; 32]),
1180                enc_iv: Some(vec![0; 12]),
1181            }),
1182            ..Default::default()
1183        };
1184        assert!(extract_secret_encrypted(&no_key).is_none());
1185    }
1186
1187    #[test]
1188    fn enc_reaction_decrypts_via_kind_dispatch_with_fallback() {
1189        let secret = [0x21u8; 32];
1190        let author: Jid = "5511000000001@s.whatsapp.net".parse().unwrap();
1191        let author_lid: Jid = "111111111111111@lid".parse().unwrap();
1192        let reactor: Jid = "5511000000002@s.whatsapp.net".parse().unwrap();
1193
1194        // Encrypted under the author's LID identity; the primary (PN) attempt
1195        // must fail and the LID fallback succeed.
1196        let (enc, iv) = wacore::reaction::encrypt_reaction_with_secret(
1197            "\u{2764}",
1198            42,
1199            &secret,
1200            "PARENT1",
1201            &author_lid.to_non_ad_string(),
1202            &reactor.to_non_ad_string(),
1203        )
1204        .unwrap();
1205
1206        let out = decrypt_secret_encrypted_with_fallback(
1207            &enc,
1208            &iv,
1209            &secret,
1210            SecretEncKind::EncReaction,
1211            "PARENT1",
1212            &author,
1213            &reactor,
1214            Some(&author_lid),
1215            None,
1216        )
1217        .expect("fallback identity must decrypt");
1218        let rm = out.reaction_message.into_option().expect("reaction shape");
1219        assert_eq!(rm.text.as_deref(), Some("\u{2764}"));
1220
1221        // Without a distinct fallback the primary error surfaces.
1222        assert!(
1223            decrypt_secret_encrypted_with_fallback(
1224                &enc,
1225                &iv,
1226                &secret,
1227                SecretEncKind::EncReaction,
1228                "PARENT1",
1229                &author,
1230                &reactor,
1231                None,
1232                None,
1233            )
1234            .is_err()
1235        );
1236
1237        // Mixed combo on the OTHER side: encrypted under the modifier's LID
1238        // while the parent author is already in the right namespace.
1239        let reactor_lid: Jid = "222222222222222@lid".parse().unwrap();
1240        let (enc2, iv2) = wacore::reaction::encrypt_reaction_with_secret(
1241            "\u{1F44D}",
1242            43,
1243            &secret,
1244            "PARENT1",
1245            &author.to_non_ad_string(),
1246            &reactor_lid.to_non_ad_string(),
1247        )
1248        .unwrap();
1249        let out = decrypt_secret_encrypted_with_fallback(
1250            &enc2,
1251            &iv2,
1252            &secret,
1253            SecretEncKind::EncReaction,
1254            "PARENT1",
1255            &author,
1256            &reactor,
1257            Some(&author_lid),
1258            Some(&reactor_lid),
1259        )
1260        .expect("primary-author + fallback-modifier combination must decrypt");
1261        assert_eq!(
1262            out.reaction_message
1263                .as_option()
1264                .and_then(|r| r.text.as_deref()),
1265            Some("\u{1F44D}")
1266        );
1267    }
1268
1269    #[test]
1270    fn enc_comment_decrypts_to_inner_body() {
1271        let secret = [0x22u8; 32];
1272        let author: Jid = "5511000000001@s.whatsapp.net".parse().unwrap();
1273        let commenter: Jid = "5511000000002@s.whatsapp.net".parse().unwrap();
1274        let body = wa::Message {
1275            extended_text_message: MessageField::some(wa::message::ExtendedTextMessage {
1276                text: Some("hi".to_string()),
1277                ..Default::default()
1278            }),
1279            ..Default::default()
1280        };
1281        let (enc, iv) = wacore::comment::encrypt_comment_with_secret(
1282            &body,
1283            &secret,
1284            "PARENT1",
1285            &author.to_non_ad_string(),
1286            &commenter.to_non_ad_string(),
1287        )
1288        .unwrap();
1289
1290        let out = decrypt_secret_encrypted(
1291            &enc,
1292            &iv,
1293            &secret,
1294            SecretEncKind::EncComment,
1295            "PARENT1",
1296            &author,
1297            &commenter,
1298        )
1299        .expect("comment decrypts");
1300        assert_eq!(
1301            out.extended_text_message
1302                .as_option()
1303                .and_then(|m| m.text.as_deref()),
1304            Some("hi")
1305        );
1306    }
1307}