Skip to main content

wacore/
message_processing.rs

1//! Pure functions for message processing logic.
2//!
3//! These functions extract the classification and categorization logic from
4//! the runtime-dependent message handling code, making them usable from any
5//! runtime (Tokio, bridge, etc.) without side effects.
6
7use crate::types::events::DecryptFailMode;
8use wacore_binary::Node;
9use waproto::whatsapp as wa;
10
11// ---------------------------------------------------------------------------
12// 2a. Enc-node categorization
13// ---------------------------------------------------------------------------
14
15/// Classification of an encryption node's type.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum EncType {
18    /// Pre-key Signal message (`"pkmsg"`) — initial 1:1 session establishment.
19    PreKeyMessage,
20    /// Regular Signal message (`"msg"`) — established 1:1 session.
21    Message,
22    /// Sender-key message (`"skmsg"`) — group encryption.
23    SenderKey,
24    /// Message-secret bot reply (`"msmsg"`) — Meta AI / fbid bot envelope
25    /// decrypted via [`crate::bot_message::decrypt_bot_message`].
26    MessageSecret,
27}
28
29impl EncType {
30    /// Parse from the wire-format string in the `type` attribute.
31    pub fn from_wire(s: &str) -> Option<Self> {
32        match s {
33            "pkmsg" => Some(Self::PreKeyMessage),
34            "msg" => Some(Self::Message),
35            "skmsg" => Some(Self::SenderKey),
36            "msmsg" => Some(Self::MessageSecret),
37            _ => None,
38        }
39    }
40
41    /// Returns the wire-format string.
42    pub fn as_wire_str(&self) -> &'static str {
43        match self {
44            Self::PreKeyMessage => "pkmsg",
45            Self::Message => "msg",
46            Self::SenderKey => "skmsg",
47            Self::MessageSecret => "msmsg",
48        }
49    }
50
51    /// Whether this is a 1:1 session-based encryption type (pkmsg or msg).
52    /// msmsg is neither session- nor group-based; it has its own dispatch path.
53    pub fn is_session(&self) -> bool {
54        matches!(self, Self::PreKeyMessage | Self::Message)
55    }
56
57    /// True for the bot-secret envelope (`msmsg`). Mutually exclusive with
58    /// session/group classification.
59    pub fn is_bot_secret(&self) -> bool {
60        matches!(self, Self::MessageSecret)
61    }
62}
63
64/// Information extracted from a single `<enc>` node.
65#[derive(Debug, Clone)]
66pub struct EncNodeInfo<'a> {
67    /// Raw ciphertext bytes from the node content.
68    pub ciphertext: &'a [u8],
69    /// The encryption type (pkmsg, msg, or skmsg).
70    pub enc_type: EncType,
71    /// Padding version from the `v` attribute (default 2).
72    pub padding_version: u8,
73    /// Sender retry count from the `count` attribute.
74    pub retry_count: u8,
75}
76
77/// Result of categorizing all `<enc>` nodes in a message stanza.
78#[derive(Debug)]
79pub struct CategorizedEncNodes<'a> {
80    /// 1:1 session enc nodes (pkmsg/msg) — must be processed before group nodes.
81    pub session_enc: Vec<EncNodeInfo<'a>>,
82    /// Group enc nodes (skmsg) — require prior SKDM from session nodes.
83    pub group_enc: Vec<EncNodeInfo<'a>>,
84    /// Bot-secret enc nodes (msmsg) — decrypted via the per-message
85    /// `messageSecret` persisted at outbound send.
86    pub bot_enc: Vec<EncNodeInfo<'a>>,
87    /// Maximum sender retry count across all enc nodes.
88    pub max_retry_count: u8,
89    /// Whether decryption failures should be hidden (edited messages).
90    pub decrypt_fail_mode: DecryptFailMode,
91    /// Encryption type strings that were not recognized as built-in types.
92    /// The caller can use these to dispatch to custom handlers.
93    pub unknown_enc_types: Vec<String>,
94    /// True if skmsg appeared before pkmsg/msg in a multi-enc message
95    /// (protocol violation — SKDM won't have been processed yet).
96    pub has_ordering_violation: bool,
97}
98
99use crate::protocol::retry::MAX_RETRY_COUNT as MAX_DECRYPT_RETRIES;
100
101/// Categorize the `<enc>` child nodes of a message stanza into session (1:1)
102/// and group (sender-key) buckets.
103///
104/// This is a pure function — no I/O, no state mutation. It only reads node
105/// attributes and content bytes.
106///
107/// Enc nodes that have no byte content or no `type` attribute are silently
108/// skipped (with a log warning).
109pub fn categorize_enc_nodes<'a>(enc_nodes: &[&'a Node]) -> CategorizedEncNodes<'a> {
110    let mut session_enc = Vec::with_capacity(enc_nodes.len());
111    let mut group_enc = Vec::with_capacity(enc_nodes.len());
112    let mut bot_enc = Vec::with_capacity(enc_nodes.len());
113    let mut unknown_enc_types = Vec::new();
114    let mut max_retry_count: u8 = 0;
115    let mut has_hide_fail = false;
116
117    for &enc_node in enc_nodes {
118        // Parse sender retry count (WA Web: e.maybeAttrInt("count") ?? 0)
119        // Clamp to MAX_DECRYPT_RETRIES to prevent u64->u8 truncation.
120        let retry_count = enc_node
121            .attrs()
122            .optional_u64("count")
123            .map(|c| c.min(MAX_DECRYPT_RETRIES as u64) as u8)
124            .unwrap_or(0);
125        max_retry_count = max_retry_count.max(retry_count);
126
127        // Parse decrypt-fail attribute (WA Web: e.maybeAttrString("decrypt-fail") === "hide")
128        if enc_node
129            .attrs
130            .get("decrypt-fail")
131            .is_some_and(|v| v == "hide")
132        {
133            has_hide_fail = true;
134        }
135
136        let enc_type_str = match enc_node.attrs().optional_string("type") {
137            Some(t) => t,
138            None => {
139                log::warn!("Enc node missing 'type' attribute, skipping");
140                continue;
141            }
142        };
143
144        let ciphertext: &[u8] = match &enc_node.content {
145            Some(wacore_binary::NodeContent::Bytes(b)) => b,
146            _ => {
147                log::warn!("Enc node has no byte content, skipping");
148                continue;
149            }
150        };
151
152        let padding_version = enc_node.attrs().optional_u64("v").unwrap_or(2) as u8;
153
154        match EncType::from_wire(enc_type_str.as_ref()) {
155            Some(et @ (EncType::PreKeyMessage | EncType::Message)) => {
156                session_enc.push(EncNodeInfo {
157                    ciphertext,
158                    enc_type: et,
159                    padding_version,
160                    retry_count,
161                });
162            }
163            Some(EncType::SenderKey) => {
164                group_enc.push(EncNodeInfo {
165                    ciphertext,
166                    enc_type: EncType::SenderKey,
167                    padding_version,
168                    retry_count,
169                });
170            }
171            Some(EncType::MessageSecret) => {
172                bot_enc.push(EncNodeInfo {
173                    ciphertext,
174                    enc_type: EncType::MessageSecret,
175                    padding_version,
176                    retry_count,
177                });
178            }
179            None => {
180                unknown_enc_types.push(enc_type_str.to_string());
181            }
182        }
183    }
184
185    // WA Web diagnostic: validate skmsg is not first in multi-enc messages.
186    // If skmsg comes first, the SKDM (carried in pkmsg/msg) hasn't been processed yet.
187    let has_ordering_violation = !session_enc.is_empty()
188        && !group_enc.is_empty()
189        && enc_nodes
190            .first()
191            .is_some_and(|n| n.attrs.get("type").is_some_and(|v| v == "skmsg"));
192
193    let decrypt_fail_mode = if has_hide_fail {
194        DecryptFailMode::Hide
195    } else {
196        DecryptFailMode::Show
197    };
198
199    CategorizedEncNodes {
200        session_enc,
201        group_enc,
202        bot_enc,
203        max_retry_count,
204        decrypt_fail_mode,
205        unknown_enc_types,
206        has_ordering_violation,
207    }
208}
209
210// ---------------------------------------------------------------------------
211// 2b. Decrypted plaintext classification
212// ---------------------------------------------------------------------------
213
214/// Information about protocol-level messages embedded in the decrypted content.
215#[derive(Debug, Clone, Default)]
216pub struct ProtocolMessageInfo {
217    /// History sync notification (triggers download + processing of history blobs).
218    pub history_sync_notification: Option<wa::message::HistorySyncNotification>,
219    /// App state sync key share (encryption keys for app state patches).
220    pub app_state_sync_key_share: Option<wa::message::AppStateSyncKeyShare>,
221    /// Peer data operation response (PDO — retry-based message recovery).
222    pub peer_data_operation_request_response:
223        Option<wa::message::PeerDataOperationRequestResponseMessage>,
224}
225
226/// Result of classifying a decrypted plaintext message.
227///
228/// This separates the *what kind of message is this?* question from the
229/// *what should we do about it?* question. The caller decides how to
230/// dispatch (emit events, store keys, download history, etc.).
231#[derive(Debug, Clone)]
232pub struct DecryptedMessageResult {
233    /// The user-visible message content (with DeviceSentMessage unwrapped).
234    pub message: wa::Message,
235    /// The sender key distribution message, if present.
236    /// Must be processed to store the sender key for future group decryption.
237    pub skdm: Option<wa::message::SenderKeyDistributionMessage>,
238    /// Protocol-level messages that require special handling.
239    pub protocol_message: Option<ProtocolMessageInfo>,
240    /// True if the message contains only SKDM with no user-visible content.
241    /// These should not be surfaced as user events.
242    pub is_skdm_only: bool,
243    /// True if a DeviceSentMessage wrapper was present but the sender was
244    /// not "from me" (protocol violation — should be logged as a warning).
245    pub has_invalid_dsm: bool,
246}
247
248/// Classify a decrypted (and decoded) plaintext message into its component parts.
249///
250/// This is a pure function that:
251/// 1. Validates DeviceSentMessage presence against `is_from_me`
252/// 2. Unwraps DeviceSentMessage wrappers (self-sent message sync)
253/// 3. Extracts SKDM, protocol messages, and user content
254/// 4. Determines whether the message is SKDM-only
255///
256/// The caller is responsible for:
257/// - Actually processing the SKDM (storing sender keys)
258/// - Handling protocol messages (history sync, key shares, PDO)
259/// - Dispatching user-visible messages to the event bus
260pub fn process_decrypted_plaintext(
261    padded_plaintext: &[u8],
262    padding_version: u8,
263    is_from_me: bool,
264) -> Result<DecryptedMessageResult, anyhow::Error> {
265    let original_msg = crate::messages::decode_plaintext(padded_plaintext, padding_version)?;
266
267    // Validate DSM presence against sender identity
268    let has_invalid_dsm = original_msg.device_sent_message.is_set() && !is_from_me;
269
270    // Unwrap DeviceSentMessage wrapper
271    let mut msg = crate::messages::unwrap_device_sent(original_msg);
272
273    // Extract SKDM
274    let skdm = msg.sender_key_distribution_message.as_option().cloned();
275
276    // Check if SKDM-only
277    let is_skdm_only = crate::messages::is_sender_key_distribution_only(&mut msg);
278
279    // Extract protocol message info
280    let protocol_message = msg
281        .protocol_message
282        .as_option()
283        .map(|pm| ProtocolMessageInfo {
284            history_sync_notification: pm.history_sync_notification.as_option().cloned(),
285            app_state_sync_key_share: pm.app_state_sync_key_share.as_option().cloned(),
286            peer_data_operation_request_response: pm
287                .peer_data_operation_request_response_message
288                .as_option()
289                .cloned(),
290        });
291
292    Ok(DecryptedMessageResult {
293        message: msg,
294        skdm,
295        protocol_message,
296        is_skdm_only,
297        has_invalid_dsm,
298    })
299}
300
301#[cfg(test)]
302#[allow(clippy::disallowed_methods)]
303mod tests {
304    use super::*;
305    use wacore_binary::{Attrs, Node, NodeContent, NodeValue};
306
307    fn make_enc_node(enc_type: &str, content: &[u8]) -> Node {
308        let mut attrs = Attrs::new();
309        attrs.insert("type", NodeValue::from(enc_type));
310        Node::new("enc", attrs, Some(NodeContent::Bytes(content.to_vec())))
311    }
312
313    fn make_enc_node_with_attrs(
314        enc_type: &str,
315        content: &[u8],
316        count: Option<u64>,
317        decrypt_fail: Option<&str>,
318        v: Option<u64>,
319    ) -> Node {
320        let mut attrs = Attrs::new();
321        attrs.insert("type", NodeValue::from(enc_type));
322        if let Some(c) = count {
323            attrs.insert("count", NodeValue::from(c.to_string()));
324        }
325        if let Some(df) = decrypt_fail {
326            attrs.insert("decrypt-fail", NodeValue::from(df));
327        }
328        if let Some(ver) = v {
329            attrs.insert("v", NodeValue::from(ver.to_string()));
330        }
331        Node::new("enc", attrs, Some(NodeContent::Bytes(content.to_vec())))
332    }
333
334    #[test]
335    fn test_categorize_empty() {
336        let result = categorize_enc_nodes(&[]);
337        assert!(result.session_enc.is_empty());
338        assert!(result.group_enc.is_empty());
339        assert!(result.bot_enc.is_empty());
340        assert_eq!(result.max_retry_count, 0);
341        assert_eq!(result.decrypt_fail_mode, DecryptFailMode::Show);
342        assert!(!result.has_ordering_violation);
343    }
344
345    #[test]
346    fn test_categorize_msmsg_goes_into_bot_bucket() {
347        let msmsg = make_enc_node("msmsg", b"bot_cipher");
348        let nodes: Vec<&Node> = vec![&msmsg];
349
350        let result = categorize_enc_nodes(&nodes);
351        assert!(result.session_enc.is_empty());
352        assert!(result.group_enc.is_empty());
353        assert_eq!(result.bot_enc.len(), 1);
354        assert_eq!(result.bot_enc[0].enc_type, EncType::MessageSecret);
355        assert_eq!(result.bot_enc[0].ciphertext, b"bot_cipher");
356        assert!(result.unknown_enc_types.is_empty());
357    }
358
359    #[test]
360    fn test_enc_type_msmsg_round_trip() {
361        assert_eq!(
362            EncType::from_wire("msmsg"),
363            Some(EncType::MessageSecret),
364            "msmsg must parse"
365        );
366        assert_eq!(EncType::MessageSecret.as_wire_str(), "msmsg");
367        assert!(
368            !EncType::MessageSecret.is_session(),
369            "msmsg is NOT a Signal session type"
370        );
371        assert!(
372            EncType::MessageSecret.is_bot_secret(),
373            "msmsg IS the bot-secret envelope"
374        );
375        for t in [EncType::PreKeyMessage, EncType::Message, EncType::SenderKey] {
376            assert!(!t.is_bot_secret(), "{t:?} must not be a bot-secret type");
377        }
378    }
379
380    #[test]
381    fn test_categorize_session_types() {
382        let pkmsg = make_enc_node("pkmsg", b"cipher1");
383        let msg = make_enc_node("msg", b"cipher2");
384        let nodes: Vec<&Node> = vec![&pkmsg, &msg];
385
386        let result = categorize_enc_nodes(&nodes);
387        assert_eq!(result.session_enc.len(), 2);
388        assert!(result.group_enc.is_empty());
389        assert_eq!(result.session_enc[0].enc_type, EncType::PreKeyMessage);
390        assert_eq!(result.session_enc[1].enc_type, EncType::Message);
391        assert_eq!(result.session_enc[0].ciphertext, b"cipher1");
392        assert_eq!(result.session_enc[1].ciphertext, b"cipher2");
393    }
394
395    #[test]
396    fn test_categorize_group_type() {
397        let skmsg = make_enc_node("skmsg", b"group_cipher");
398        let nodes: Vec<&Node> = vec![&skmsg];
399
400        let result = categorize_enc_nodes(&nodes);
401        assert!(result.session_enc.is_empty());
402        assert_eq!(result.group_enc.len(), 1);
403        assert_eq!(result.group_enc[0].enc_type, EncType::SenderKey);
404    }
405
406    #[test]
407    fn test_categorize_mixed_correct_order() {
408        let pkmsg = make_enc_node("pkmsg", b"session");
409        let skmsg = make_enc_node("skmsg", b"group");
410        let nodes: Vec<&Node> = vec![&pkmsg, &skmsg];
411
412        let result = categorize_enc_nodes(&nodes);
413        assert_eq!(result.session_enc.len(), 1);
414        assert_eq!(result.group_enc.len(), 1);
415        assert!(!result.has_ordering_violation);
416    }
417
418    #[test]
419    fn test_categorize_ordering_violation() {
420        let skmsg = make_enc_node("skmsg", b"group");
421        let pkmsg = make_enc_node("pkmsg", b"session");
422        let nodes: Vec<&Node> = vec![&skmsg, &pkmsg];
423
424        let result = categorize_enc_nodes(&nodes);
425        assert!(result.has_ordering_violation);
426    }
427
428    #[test]
429    fn test_categorize_retry_count() {
430        let node1 = make_enc_node_with_attrs("msg", b"c1", Some(2), None, None);
431        let node2 = make_enc_node_with_attrs("msg", b"c2", Some(4), None, None);
432        let nodes: Vec<&Node> = vec![&node1, &node2];
433
434        let result = categorize_enc_nodes(&nodes);
435        assert_eq!(result.max_retry_count, 4);
436    }
437
438    #[test]
439    fn test_categorize_retry_count_clamped() {
440        let node = make_enc_node_with_attrs("msg", b"c", Some(100), None, None);
441        let nodes: Vec<&Node> = vec![&node];
442
443        let result = categorize_enc_nodes(&nodes);
444        assert_eq!(result.max_retry_count, MAX_DECRYPT_RETRIES);
445    }
446
447    #[test]
448    fn test_categorize_decrypt_fail_hide() {
449        let node = make_enc_node_with_attrs("msg", b"c", None, Some("hide"), None);
450        let nodes: Vec<&Node> = vec![&node];
451
452        let result = categorize_enc_nodes(&nodes);
453        assert_eq!(result.decrypt_fail_mode, DecryptFailMode::Hide);
454    }
455
456    #[test]
457    fn test_categorize_decrypt_fail_show_default() {
458        let node = make_enc_node_with_attrs("msg", b"c", None, Some("show"), None);
459        let nodes: Vec<&Node> = vec![&node];
460
461        let result = categorize_enc_nodes(&nodes);
462        assert_eq!(result.decrypt_fail_mode, DecryptFailMode::Show);
463    }
464
465    #[test]
466    fn test_categorize_padding_version() {
467        let node = make_enc_node_with_attrs("msg", b"c", None, None, Some(3));
468        let nodes: Vec<&Node> = vec![&node];
469
470        let result = categorize_enc_nodes(&nodes);
471        assert_eq!(result.session_enc[0].padding_version, 3);
472    }
473
474    #[test]
475    fn test_categorize_padding_version_default() {
476        let node = make_enc_node("msg", b"c");
477        let nodes: Vec<&Node> = vec![&node];
478
479        let result = categorize_enc_nodes(&nodes);
480        assert_eq!(result.session_enc[0].padding_version, 2);
481    }
482
483    #[test]
484    fn test_categorize_unknown_type() {
485        let node = make_enc_node("frskmsg", b"custom");
486        let nodes: Vec<&Node> = vec![&node];
487
488        let result = categorize_enc_nodes(&nodes);
489        assert!(result.session_enc.is_empty());
490        assert!(result.group_enc.is_empty());
491        assert_eq!(result.unknown_enc_types, vec!["frskmsg"]);
492    }
493
494    #[test]
495    fn test_categorize_missing_content() {
496        let mut attrs = Attrs::new();
497        attrs.insert("type", NodeValue::from("msg"));
498        let node = Node::new("enc", attrs, None);
499        let nodes: Vec<&Node> = vec![&node];
500
501        let result = categorize_enc_nodes(&nodes);
502        assert!(result.session_enc.is_empty());
503    }
504
505    #[test]
506    fn test_enc_type_wire_roundtrip() {
507        for wire in &["pkmsg", "msg", "skmsg"] {
508            let et = EncType::from_wire(wire).unwrap();
509            assert_eq!(et.as_wire_str(), *wire);
510        }
511        assert!(EncType::from_wire("unknown").is_none());
512    }
513
514    #[test]
515    fn test_enc_type_is_session() {
516        assert!(EncType::PreKeyMessage.is_session());
517        assert!(EncType::Message.is_session());
518        assert!(!EncType::SenderKey.is_session());
519    }
520
521    #[test]
522    fn test_process_decrypted_plaintext_simple() {
523        use buffa::Message as ProtoMessage;
524
525        // Create a simple text message
526        let msg = wa::Message {
527            conversation: Some("hello".to_string()),
528            ..Default::default()
529        };
530        let plaintext = msg.encode_to_vec();
531        let padded = crate::messages::MessageUtils::pad_message_v2(plaintext);
532
533        let result = process_decrypted_plaintext(&padded, 2, false).unwrap();
534        assert_eq!(result.message.conversation.as_deref(), Some("hello"));
535        assert!(result.skdm.is_none());
536        assert!(result.protocol_message.is_none());
537        assert!(!result.is_skdm_only);
538        assert!(!result.has_invalid_dsm);
539    }
540
541    #[test]
542    fn test_process_decrypted_plaintext_with_skdm() {
543        use buffa::Message as ProtoMessage;
544
545        let msg = wa::Message {
546            conversation: Some("hello".to_string()),
547            sender_key_distribution_message: buffa::MessageField::some(
548                wa::message::SenderKeyDistributionMessage {
549                    group_id: Some("group@g.us".to_string()),
550                    axolotl_sender_key_distribution_message: Some(vec![1, 2, 3]),
551                },
552            ),
553            ..Default::default()
554        };
555        let plaintext = msg.encode_to_vec();
556        let padded = crate::messages::MessageUtils::pad_message_v2(plaintext);
557
558        let result = process_decrypted_plaintext(&padded, 2, false).unwrap();
559        assert!(result.skdm.is_some());
560        assert!(!result.is_skdm_only); // has conversation content too
561    }
562
563    #[test]
564    fn test_process_decrypted_plaintext_skdm_only() {
565        use buffa::Message as ProtoMessage;
566
567        let msg = wa::Message {
568            sender_key_distribution_message: buffa::MessageField::some(
569                wa::message::SenderKeyDistributionMessage {
570                    group_id: Some("group@g.us".to_string()),
571                    axolotl_sender_key_distribution_message: Some(vec![1, 2, 3]),
572                },
573            ),
574            ..Default::default()
575        };
576        let plaintext = msg.encode_to_vec();
577        let padded = crate::messages::MessageUtils::pad_message_v2(plaintext);
578
579        let result = process_decrypted_plaintext(&padded, 2, false).unwrap();
580        assert!(result.skdm.is_some());
581        assert!(result.is_skdm_only);
582    }
583
584    #[test]
585    fn test_process_decrypted_plaintext_invalid_dsm() {
586        use buffa::Message as ProtoMessage;
587
588        let msg = wa::Message {
589            device_sent_message: buffa::MessageField::some(wa::message::DeviceSentMessage {
590                message: buffa::MessageField::some(wa::Message {
591                    conversation: Some("inner".to_string()),
592                    ..Default::default()
593                }),
594                ..Default::default()
595            }),
596            ..Default::default()
597        };
598        let plaintext = msg.encode_to_vec();
599        let padded = crate::messages::MessageUtils::pad_message_v2(plaintext);
600
601        // is_from_me = false but DSM is present => invalid
602        let result = process_decrypted_plaintext(&padded, 2, false).unwrap();
603        assert!(result.has_invalid_dsm);
604        // The inner message should still be unwrapped
605        assert_eq!(result.message.conversation.as_deref(), Some("inner"));
606    }
607
608    #[test]
609    fn test_process_decrypted_plaintext_valid_dsm() {
610        use buffa::Message as ProtoMessage;
611
612        let msg = wa::Message {
613            device_sent_message: buffa::MessageField::some(wa::message::DeviceSentMessage {
614                message: buffa::MessageField::some(wa::Message {
615                    conversation: Some("self-sent".to_string()),
616                    ..Default::default()
617                }),
618                ..Default::default()
619            }),
620            ..Default::default()
621        };
622        let plaintext = msg.encode_to_vec();
623        let padded = crate::messages::MessageUtils::pad_message_v2(plaintext);
624
625        // is_from_me = true and DSM is present => valid
626        let result = process_decrypted_plaintext(&padded, 2, true).unwrap();
627        assert!(!result.has_invalid_dsm);
628        assert_eq!(result.message.conversation.as_deref(), Some("self-sent"));
629    }
630}