1use 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#[derive(Debug, thiserror::Error)]
40#[non_exhaustive]
41pub enum MessageEditError {
42 #[error("invalid {field} in target message key")]
44 InvalidTargetJid {
45 field: &'static str,
47 #[source]
48 source: JidError,
49 },
50 #[error("target message key missing participant and remote_jid")]
53 MissingTargetSender,
54}
55
56pub 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#[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
122pub 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
138pub 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#[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 pub fn target_id(&self) -> Option<&str> {
175 self.target_message_key.id.as_deref()
176 }
177
178 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 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
215fn 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
226fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub enum SecretEncKind {
259 EventEdit,
260 MessageEdit,
261 PollEdit,
262 PollAddOption,
263 EncReaction,
266 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#[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 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 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
350pub 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
384fn 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
411pub 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 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#[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 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 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 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 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 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 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 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 assert_eq!(
812 env.original_sender_for_dispatch(false, &editor, &my_jid)
813 .to_string(),
814 "200000000000002@lid"
815 );
816 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 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()), 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 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 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 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 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 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 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 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 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 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 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 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}