1use anyhow::{Result, anyhow};
26use log::warn;
27use wacore::message_edit::{self, MessageEditContext};
28use wacore_binary::Jid;
29use waproto::whatsapp as wa;
30
31pub 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#[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 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
120pub 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
155pub 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#[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 pub fn target_id(&self) -> Option<&str> {
192 self.target_message_key.id.as_deref()
193 }
194
195 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 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 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 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 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 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 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}