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::Message` 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 log::warn;
27use wacore::message_edit::{self, MessageEditContext};
28use wacore_binary::Jid;
29use waproto::whatsapp as wa;
30
31/// Decrypt a `secret_encrypted_message` MESSAGE_EDIT envelope.
32///
33/// JIDs may carry their device suffix — they are normalised before being
34/// fed into the HKDF info buffer (matching WA Web's `widToUserJid`).
35///
36/// Returns the inner [`wa::Message`]; the new content is at
37/// `result.protocol_message.edited_message`.
38///
39/// Implementation notes:
40/// - HKDF: `salt = zeros[32]`, `ikm = message_secret`,
41///   `info = original_msg_id || original_sender_jid || editor_jid || "Message Edit"`,
42///   `L = 32`.
43/// - AAD: empty. WA Web's `WAWebAddonEncryption` (function `g`) only binds
44///   `stanzaId\0sender` into AAD for PollVote/EventResponse; everything
45///   else, including MessageEdit, uses an empty AAD.
46/// - IV must be exactly 12 bytes (matches WA Web's
47///   `WAWebParseMessageEditEncryptedMessageProto`).
48pub fn decrypt(
49    enc_payload: &[u8],
50    enc_iv: &[u8],
51    message_secret: &[u8],
52    original_msg_id: &str,
53    original_sender_jid: &Jid,
54    editor_jid: &Jid,
55) -> Result<wa::Message> {
56    let primary_orig = original_sender_jid.to_non_ad().to_string();
57    let primary_editor = editor_jid.to_non_ad().to_string();
58    let primary = MessageEditContext {
59        original_msg_id,
60        original_sender_jid: &primary_orig,
61        editor_jid: &primary_editor,
62    };
63    message_edit::decrypt_message_edit(enc_payload, enc_iv, message_secret, &primary)
64}
65
66/// Same as [`decrypt`] but tries a fallback addressing combination if
67/// the first attempt fails its GCM tag check.
68///
69/// `fallback_original_sender` / `fallback_editor` are typically the LID
70/// form when the primary attempt used PN form (or vice versa). Mirrors
71/// `WAWebAddonEncryption.decryptAddOn`, which falls back across LID/PN
72/// to handle cross-addressing edits between newer and legacy clients.
73#[allow(clippy::too_many_arguments)]
74pub fn decrypt_with_fallback(
75    enc_payload: &[u8],
76    enc_iv: &[u8],
77    message_secret: &[u8],
78    original_msg_id: &str,
79    original_sender_jid: &Jid,
80    editor_jid: &Jid,
81    fallback_original_sender: Option<&Jid>,
82    fallback_editor: Option<&Jid>,
83) -> Result<wa::Message> {
84    let primary_orig = original_sender_jid.to_non_ad().to_string();
85    let primary_editor = editor_jid.to_non_ad().to_string();
86    let primary = MessageEditContext {
87        original_msg_id,
88        original_sender_jid: &primary_orig,
89        editor_jid: &primary_editor,
90    };
91
92    let fb_orig = fallback_original_sender.map(|j| j.to_non_ad().to_string());
93    let fb_editor = fallback_editor.map(|j| j.to_non_ad().to_string());
94    let fb_orig_resolved = fb_orig.as_deref().unwrap_or(primary.original_sender_jid);
95    let fb_editor_resolved = fb_editor.as_deref().unwrap_or(primary.editor_jid);
96    // Skip the retry when the fallback would key the HKDF identically to
97    // primary — covers both "no fallback supplied" and "fallback normalises
98    // to the same JIDs". Avoids a guaranteed-failing duplicate decrypt.
99    let fallback_ctx = if fb_orig_resolved == primary.original_sender_jid
100        && fb_editor_resolved == primary.editor_jid
101    {
102        None
103    } else {
104        Some(MessageEditContext {
105            original_msg_id,
106            original_sender_jid: fb_orig_resolved,
107            editor_jid: fb_editor_resolved,
108        })
109    };
110
111    message_edit::decrypt_message_edit_with_fallback(
112        enc_payload,
113        enc_iv,
114        message_secret,
115        &primary,
116        fallback_ctx.as_ref(),
117    )
118}
119
120/// Pull `enc_payload` / `enc_iv` / `target_message_key` out of a received
121/// [`wa::Message`] if it carries a MESSAGE_EDIT envelope. Returns `None`
122/// if the message is not an encrypted edit, or if the envelope is
123/// malformed (missing fields, IV not 12 bytes).
124///
125/// Malformed-but-tagged envelopes emit a `log::warn!` so the gap is
126/// visible without exposing the encrypted payload.
127pub fn extract_envelope(msg: &wa::Message) -> Option<EncryptedEdit<'_>> {
128    let sec = msg.secret_encrypted_message.as_ref()?;
129    let enc_type = sec.secret_enc_type();
130    if enc_type != wa::message::secret_encrypted_message::SecretEncType::MessageEdit {
131        return None;
132    }
133    let target_key = sec.target_message_key.as_ref();
134    let enc_payload = sec.enc_payload.as_deref();
135    let enc_iv = sec.enc_iv.as_deref();
136
137    match (target_key, enc_payload, enc_iv) {
138        (Some(tk), Some(payload), Some(iv)) if iv.len() == 12 => Some(EncryptedEdit {
139            enc_payload: payload,
140            enc_iv: iv,
141            target_message_key: tk,
142        }),
143        (tk, payload, iv) => {
144            warn!(
145                "secret_encrypted_message MESSAGE_EDIT malformed: target_id={:?} has_payload={} iv_len={:?} (expected 12)",
146                tk.and_then(|t| t.id.as_deref()),
147                payload.is_some(),
148                iv.map(|b| b.len()),
149            );
150            None
151        }
152    }
153}
154
155/// Rewrap a decrypted edit `inner` into the same shape produced by the
156/// legacy `protocol_message.edited_message` path so downstream consumers
157/// can use one code path:
158///
159/// ```text
160/// Message { protocol_message: { edited_message: <inner_edited_message> } }
161/// ```
162///
163/// `inner` is the value returned by [`decrypt`]. Returns `None` if the
164/// decrypted message did not contain `protocol_message.edited_message`
165/// (caller should log + skip).
166pub fn rewrap_as_legacy_edit(inner: wa::Message) -> Option<wa::Message> {
167    let pm = inner.protocol_message?;
168    let edited = pm.edited_message?;
169    Some(wa::Message {
170        protocol_message: Some(Box::new(wa::message::ProtocolMessage {
171            key: pm.key,
172            r#type: Some(wa::message::protocol_message::Type::MessageEdit as i32),
173            edited_message: Some(edited),
174            timestamp_ms: pm.timestamp_ms,
175            ..Default::default()
176        })),
177        ..Default::default()
178    })
179}
180
181/// Extracted edit-envelope fields ready to feed into [`decrypt`].
182#[derive(Debug, Clone, Copy)]
183pub struct EncryptedEdit<'a> {
184    pub enc_payload: &'a [u8],
185    pub enc_iv: &'a [u8],
186    pub target_message_key: &'a wa::MessageKey,
187}
188
189impl<'a> EncryptedEdit<'a> {
190    /// Convenience: returns the targeted message id.
191    pub fn target_id(&self) -> Option<&str> {
192        self.target_message_key.id.as_deref()
193    }
194
195    /// Resolve the original sender JID from the target message key.
196    ///
197    /// `my_jid` is the receiver's own JID in the addressing mode of the
198    /// chat (PN or LID). It is needed because for self-sent edits — e.g.
199    /// edits to our own messages that arrive via device sync —
200    /// `target_message_key` has `from_me = true` and its `remote_jid`
201    /// points to the *other* party, not us. WA Web's
202    /// `MsgGetters.getOriginalSender` reads `originalSelfAuthor || sender`
203    /// from its materialised msg-row store; we have no row here, so we
204    /// reconstruct the same fact from `from_me` + own jid.
205    ///
206    /// Resolution order:
207    /// 1. `participant` if present (always set in groups).
208    /// 2. `my_jid` if `from_me == Some(true)` (self-sent edit sync).
209    /// 3. `remote_jid` (1:1 incoming edit; the chat is the other party).
210    pub fn original_sender_jid(&self, my_jid: &Jid) -> Result<Jid> {
211        if let Some(p) = self.target_message_key.participant.as_deref() {
212            return p
213                .parse::<Jid>()
214                .map_err(|e| anyhow!("invalid participant jid in target key: {e}"));
215        }
216        if self.target_message_key.from_me == Some(true) {
217            return Ok(my_jid.to_non_ad());
218        }
219        let raw = self
220            .target_message_key
221            .remote_jid
222            .as_deref()
223            .ok_or_else(|| anyhow!("target message key missing participant and remote_jid"))?;
224        raw.parse::<Jid>()
225            .map_err(|e| anyhow!("invalid remote_jid in target key: {e}"))
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use wacore::message_edit::encrypt_message_edit;
233
234    fn inner(text: &str) -> wa::Message {
235        wa::Message {
236            protocol_message: Some(Box::new(wa::message::ProtocolMessage {
237                key: Some(wa::MessageKey {
238                    remote_jid: Some("123@s.whatsapp.net".to_string()),
239                    from_me: Some(false),
240                    id: Some("AC1".to_string()),
241                    participant: None,
242                }),
243                r#type: Some(wa::message::protocol_message::Type::MessageEdit as i32),
244                edited_message: Some(Box::new(wa::Message {
245                    conversation: Some(text.to_string()),
246                    ..Default::default()
247                })),
248                timestamp_ms: Some(1_700_000_000_000),
249                ..Default::default()
250            })),
251            ..Default::default()
252        }
253    }
254
255    #[test]
256    fn decrypt_normalises_device_suffix() {
257        let secret = [0x55u8; 32];
258        // Encrypt with the non-AD form, the only form WA actually feeds to HKDF.
259        let ctx = MessageEditContext {
260            original_msg_id: "AC1",
261            original_sender_jid: "5511999@s.whatsapp.net",
262            editor_jid: "5511999@s.whatsapp.net",
263        };
264        let (enc, iv) = encrypt_message_edit(&inner("hi"), &secret, &ctx).unwrap();
265
266        // Caller passes JIDs with device numbers — they should be stripped.
267        let with_device = "5511999:13@s.whatsapp.net".parse::<Jid>().unwrap();
268        let m = decrypt(&enc, &iv, &secret, "AC1", &with_device, &with_device).unwrap();
269        assert_eq!(
270            m.protocol_message
271                .as_ref()
272                .and_then(|pm| pm.edited_message.as_ref())
273                .and_then(|e| e.conversation.as_deref()),
274            Some("hi")
275        );
276    }
277
278    #[test]
279    fn extract_envelope_recognises_message_edit() {
280        let msg = wa::Message {
281            secret_encrypted_message: Some(wa::message::SecretEncryptedMessage {
282                target_message_key: Some(wa::MessageKey {
283                    remote_jid: Some("g@g.us".to_string()),
284                    from_me: Some(false),
285                    id: Some("AC1".to_string()),
286                    participant: Some("5511999@s.whatsapp.net".to_string()),
287                }),
288                enc_payload: Some(vec![0u8; 32]),
289                enc_iv: Some(vec![0u8; 12]),
290                secret_enc_type: Some(
291                    wa::message::secret_encrypted_message::SecretEncType::MessageEdit as i32,
292                ),
293                remote_key_id: None,
294            }),
295            ..Default::default()
296        };
297        let env = extract_envelope(&msg).expect("recognised");
298        assert_eq!(env.target_id(), Some("AC1"));
299        // Group: participant takes priority over my_jid and remote_jid.
300        let my_jid = "999@s.whatsapp.net".parse::<Jid>().unwrap();
301        assert_eq!(
302            env.original_sender_jid(&my_jid).unwrap().to_string(),
303            "5511999@s.whatsapp.net"
304        );
305    }
306
307    #[test]
308    fn original_sender_jid_uses_my_jid_for_self_sent_edits() {
309        let msg = wa::Message {
310            secret_encrypted_message: Some(wa::message::SecretEncryptedMessage {
311                target_message_key: Some(wa::MessageKey {
312                    remote_jid: Some("5510000@s.whatsapp.net".to_string()),
313                    from_me: Some(true),
314                    id: Some("AC1".to_string()),
315                    participant: None,
316                }),
317                enc_payload: Some(vec![0u8; 32]),
318                enc_iv: Some(vec![0u8; 12]),
319                secret_enc_type: Some(
320                    wa::message::secret_encrypted_message::SecretEncType::MessageEdit as i32,
321                ),
322                remote_key_id: None,
323            }),
324            ..Default::default()
325        };
326        let env = extract_envelope(&msg).expect("recognised");
327        let my_jid = "5511999:13@s.whatsapp.net".parse::<Jid>().unwrap();
328        // Must return my_jid (stripped of device), NOT remote_jid (the other party).
329        assert_eq!(
330            env.original_sender_jid(&my_jid).unwrap().to_string(),
331            "5511999@s.whatsapp.net"
332        );
333    }
334
335    #[test]
336    fn original_sender_jid_falls_back_to_remote_jid_for_incoming_one_to_one_edit() {
337        let msg = wa::Message {
338            secret_encrypted_message: Some(wa::message::SecretEncryptedMessage {
339                target_message_key: Some(wa::MessageKey {
340                    remote_jid: Some("5510000@s.whatsapp.net".to_string()),
341                    from_me: Some(false),
342                    id: Some("AC1".to_string()),
343                    participant: None,
344                }),
345                enc_payload: Some(vec![0u8; 32]),
346                enc_iv: Some(vec![0u8; 12]),
347                secret_enc_type: Some(
348                    wa::message::secret_encrypted_message::SecretEncType::MessageEdit as i32,
349                ),
350                remote_key_id: None,
351            }),
352            ..Default::default()
353        };
354        let env = extract_envelope(&msg).expect("recognised");
355        let my_jid = "5511999@s.whatsapp.net".parse::<Jid>().unwrap();
356        assert_eq!(
357            env.original_sender_jid(&my_jid).unwrap().to_string(),
358            "5510000@s.whatsapp.net"
359        );
360    }
361
362    #[test]
363    fn extract_envelope_rejects_non_edit_secret_enc_type() {
364        let msg = wa::Message {
365            secret_encrypted_message: Some(wa::message::SecretEncryptedMessage {
366                target_message_key: Some(wa::MessageKey::default()),
367                enc_payload: Some(vec![0u8; 32]),
368                enc_iv: Some(vec![0u8; 12]),
369                secret_enc_type: Some(
370                    wa::message::secret_encrypted_message::SecretEncType::EventEdit as i32,
371                ),
372                remote_key_id: None,
373            }),
374            ..Default::default()
375        };
376        assert!(extract_envelope(&msg).is_none());
377    }
378
379    #[test]
380    fn extract_envelope_rejects_invalid_iv_size() {
381        let msg = wa::Message {
382            secret_encrypted_message: Some(wa::message::SecretEncryptedMessage {
383                target_message_key: Some(wa::MessageKey::default()),
384                enc_payload: Some(vec![0u8; 32]),
385                enc_iv: Some(vec![0u8; 11]),
386                secret_enc_type: Some(
387                    wa::message::secret_encrypted_message::SecretEncType::MessageEdit as i32,
388                ),
389                remote_key_id: None,
390            }),
391            ..Default::default()
392        };
393        assert!(extract_envelope(&msg).is_none());
394    }
395
396    #[test]
397    fn fallback_normalising_to_primary_jids_is_skipped() {
398        // wacore::message_edit::decrypt_message_edit_with_fallback returns the
399        // bare primary error when no fallback is run, or a combined
400        // "edit decrypt failed: primary=...; fallback=..." when both attempts
401        // run. We use that to assert the dedup path.
402        let secret = [0xAAu8; 32];
403        let real_ctx = MessageEditContext {
404            original_msg_id: "ID",
405            original_sender_jid: "5511777@s.whatsapp.net",
406            editor_jid: "5511777@s.whatsapp.net",
407        };
408        let (enc, iv) = encrypt_message_edit(&inner("hi"), &secret, &real_ctx).unwrap();
409
410        // Wrong primary JID so decrypt fails; fallback is a device-suffixed
411        // form of the *same* wrong jid → normalises identical → must be skipped.
412        let wrong = "5511000@s.whatsapp.net".parse::<Jid>().unwrap();
413        let wrong_with_device = "5511000:5@s.whatsapp.net".parse::<Jid>().unwrap();
414
415        let err = decrypt_with_fallback(
416            &enc,
417            &iv,
418            &secret,
419            "ID",
420            &wrong,
421            &wrong,
422            Some(&wrong_with_device),
423            Some(&wrong_with_device),
424        )
425        .expect_err("decryption should fail");
426        assert!(
427            !err.to_string().contains("fallback="),
428            "no-op fallback must be skipped, got: {err}"
429        );
430    }
431
432    #[test]
433    fn rewrap_yields_legacy_shape() {
434        let dec = inner("edited");
435        let rewrap = rewrap_as_legacy_edit(dec).expect("present");
436        let edited = rewrap
437            .protocol_message
438            .as_ref()
439            .and_then(|pm| pm.edited_message.as_ref())
440            .and_then(|m| m.conversation.as_deref());
441        assert_eq!(edited, Some("edited"));
442        assert_eq!(
443            rewrap.protocol_message.as_ref().and_then(|pm| pm.r#type),
444            Some(wa::message::protocol_message::Type::MessageEdit as i32)
445        );
446    }
447
448    #[test]
449    fn rewrap_returns_none_when_inner_missing_edit() {
450        let m = wa::Message {
451            protocol_message: Some(Box::new(wa::message::ProtocolMessage::default())),
452            ..Default::default()
453        };
454        assert!(rewrap_as_legacy_edit(m).is_none());
455    }
456}