Skip to main content

offline_protocol_sealed/
envelope.rs

1//! The sealed envelope: what a payload looks like on the wire, both ways.
2//!
3//! Every encrypted payload in the protocol is an [`EncryptedMessage`], in one
4//! of two interchangeable encodings. The compact binary codec
5//! ([`EncryptedMessage::to_bytes`]) is what peers that negotiated it use; JSON
6//! is the permanent floor, because a peer that advertised no capability at all
7//! must still be able to read a frame.
8//!
9//! Both encodings are here, in a crate a leaf node can link, because the phone
10//! and the leaf are the two ends of the same envelope: a second
11//! implementation of this codec is a second wire format the moment either side
12//! is edited alone.
13
14use crate::{Result, SealedError};
15use alloc::format;
16use alloc::string::{String, ToString};
17use alloc::vec::Vec;
18use core::fmt;
19use serde::{Deserialize, Serialize};
20
21/// Maximum length of a string field (`group_id`, `sender_id`) in the compact
22/// binary wire format.
23///
24/// It bounds allocation from a crafted payload, but its load-bearing job is
25/// disambiguation. A JSON envelope starts with `{"`, which the compact parser
26/// reads as the little-endian `u32` 0x0000_227B (8827): above this cap, so a
27/// JSON body is deterministically rejected by
28/// [`EncryptedMessage::from_bytes`] and falls through to the JSON parser
29/// rather than being half-decoded. Raising it past 8827 would make the two
30/// encodings ambiguous.
31const MAX_STRING_FIELD_LEN: usize = 4096;
32
33/// Unique identifier for an MLS group.
34///
35/// Group ids flow from the wire into `MlsStorage` as raw `key_id` storage
36/// keys, so they are validated at construction: one or more non-empty
37/// colon-separated segments (`:` is the namespace separator used by
38/// `session:<a>:<b>` and `group:<uuid>` ids), each segment subject to the
39/// same storage-key policy as `UserId`/`AppId` (no path-traversal components,
40/// control characters, `/`, or `\`), with a total length cap of
41/// [`GroupId::MAX_LEN`].
42#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
43pub struct GroupId(String);
44
45impl GroupId {
46    /// Maximum accepted group id length in bytes.
47    ///
48    /// This *is* the compact wire format's string-field cap, not a copy of it:
49    /// a group id that could not survive a round trip through the envelope
50    /// must not be constructible in the first place.
51    pub const MAX_LEN: usize = MAX_STRING_FIELD_LEN;
52
53    /// Creates a new group ID, rejecting storage-hostile values.
54    pub fn new(id: impl Into<String>) -> Result<Self> {
55        let id = id.into();
56        Self::validate(&id)?;
57        Ok(Self(id))
58    }
59
60    fn validate(id: &str) -> Result<()> {
61        if id.is_empty() {
62            return Err(SealedError::InvalidGroupId(
63                "Group ID cannot be empty".to_string(),
64            ));
65        }
66        if id.len() > Self::MAX_LEN {
67            return Err(SealedError::InvalidGroupId(format!(
68                "Group ID length {} exceeds maximum {}",
69                id.len(),
70                Self::MAX_LEN
71            )));
72        }
73        for segment in id.split(':') {
74            offline_protocol_core::validate_id_chars(segment, "Group ID segment")
75                .map_err(|e| SealedError::InvalidGroupId(e.to_string()))?;
76        }
77        Ok(())
78    }
79
80    /// Returns the group ID as a string slice.
81    pub fn as_str(&self) -> &str {
82        &self.0
83    }
84
85    /// Builds the deterministic group ID for a 1:1 session, rejecting
86    /// storage-hostile user ids (the id becomes a raw storage key).
87    ///
88    /// Both peers must land on the same string, so the two ids are put in a
89    /// canonical order. When both are addresses that order is
90    /// [`Address`](offline_protocol_core::Address) order, the underlying hash
91    /// bytes, because the bech32 charset is not ASCII-monotonic (value 4
92    /// renders as `y` 0x79, value 5 as `9` 0x39), so sorting the rendered
93    /// strings would disagree with every other address comparison in the
94    /// protocol. Ids that are not both addresses fall back to string order,
95    /// which is all a nickname id has.
96    ///
97    /// Either way the result is symmetric in its arguments, which is the
98    /// property peers actually depend on.
99    pub fn for_session(user_a: &str, user_b: &str) -> Result<Self> {
100        use offline_protocol_core::Address;
101
102        let ordered = match (user_a.parse::<Address>(), user_b.parse::<Address>()) {
103            (Ok(addr_a), Ok(addr_b)) if addr_b < addr_a => [user_b, user_a],
104            (Ok(_), Ok(_)) => [user_a, user_b],
105            _ => {
106                let mut users = [user_a, user_b];
107                users.sort();
108                users
109            }
110        };
111
112        Self::new(format!("session:{}:{}", ordered[0], ordered[1]))
113    }
114}
115
116impl fmt::Display for GroupId {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        write!(f, "{}", self.0)
119    }
120}
121
122impl<'de> Deserialize<'de> for GroupId {
123    fn deserialize<D: serde::Deserializer<'de>>(
124        deserializer: D,
125    ) -> core::result::Result<Self, D::Error> {
126        let s = String::deserialize(deserializer)?;
127        GroupId::new(s).map_err(serde::de::Error::custom)
128    }
129}
130
131/// Type of MLS message.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
133pub enum MlsMessageType {
134    /// Application message (encrypted content).
135    Application,
136
137    /// Welcome message for new group members.
138    Welcome,
139
140    /// Commit message for group state changes.
141    Commit,
142
143    /// Proposal message.
144    Proposal,
145}
146
147impl MlsMessageType {
148    /// Returns a stable string representation for serialization/FFI.
149    pub fn as_str(&self) -> &'static str {
150        match self {
151            Self::Application => "Application",
152            Self::Welcome => "Welcome",
153            Self::Commit => "Commit",
154            Self::Proposal => "Proposal",
155        }
156    }
157
158    /// Parses a string into an MlsMessageType, returning None for unknown values.
159    pub fn from_str_opt(s: &str) -> Option<Self> {
160        match s {
161            "Application" => Some(Self::Application),
162            "Welcome" => Some(Self::Welcome),
163            "Commit" => Some(Self::Commit),
164            "Proposal" => Some(Self::Proposal),
165            _ => None,
166        }
167    }
168
169    /// Returns a stable single-byte tag for the compact binary wire format.
170    pub fn as_u8(&self) -> u8 {
171        match self {
172            Self::Application => 0,
173            Self::Welcome => 1,
174            Self::Commit => 2,
175            Self::Proposal => 3,
176        }
177    }
178
179    /// Parses a wire-format tag, returning None for unknown values.
180    pub fn from_u8(tag: u8) -> Option<Self> {
181        match tag {
182            0 => Some(Self::Application),
183            1 => Some(Self::Welcome),
184            2 => Some(Self::Commit),
185            3 => Some(Self::Proposal),
186            _ => None,
187        }
188    }
189}
190
191impl fmt::Display for MlsMessageType {
192    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193        f.write_str(self.as_str())
194    }
195}
196
197/// An encrypted MLS message ready for transport.
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct EncryptedMessage {
200    /// The group ID this message belongs to.
201    pub group_id: GroupId,
202
203    /// Type of MLS message.
204    pub message_type: MlsMessageType,
205
206    /// Current epoch when the message was created.
207    pub epoch: u64,
208
209    /// Serialized MLS message bytes.
210    pub ciphertext: Vec<u8>,
211
212    /// Sender's user ID.
213    pub sender_id: String,
214
215    /// Timestamp when the message was created.
216    pub timestamp_ms: u64,
217}
218
219impl EncryptedMessage {
220    /// Encodes the encrypted message to base64 for transport.
221    pub fn to_base64(&self) -> Result<String> {
222        let json =
223            serde_json::to_vec(self).map_err(|e| SealedError::Serialization(e.to_string()))?;
224        Ok(base64::Engine::encode(
225            &base64::engine::general_purpose::STANDARD,
226            &json,
227        ))
228    }
229
230    /// Decodes an encrypted message from base64.
231    pub fn from_base64(encoded: &str) -> Result<Self> {
232        let json = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encoded)
233            .map_err(|e| SealedError::Deserialization(e.to_string()))?;
234        serde_json::from_slice(&json).map_err(|e| SealedError::Deserialization(e.to_string()))
235    }
236
237    /// Maximum allowed ciphertext length in the compact binary wire format.
238    const MAX_CIPHERTEXT_LEN: usize = 128 * 1024 * 1024; // 128 MB
239
240    /// Serializes to a compact binary format, avoiding base64/JSON overhead.
241    ///
242    /// JSON renders `ciphertext` as an integer array (~3.7x inflation), which
243    /// is unacceptable for large payloads such as encrypted file chunks.
244    ///
245    /// Wire format (all multi-byte integers are little-endian):
246    /// ```text
247    /// [group_id_len:4][group_id][sender_len:4][sender_id]
248    /// [message_type:1][epoch:8][timestamp_ms:8]
249    /// [ciphertext_len:4][ciphertext]
250    /// ```
251    pub fn to_bytes(&self) -> Vec<u8> {
252        let group_id = self.group_id.as_str().as_bytes();
253        let sender_id = self.sender_id.as_bytes();
254
255        let capacity =
256            4 + group_id.len() + 4 + sender_id.len() + 1 + 8 + 8 + 4 + self.ciphertext.len();
257        let mut buf = Vec::with_capacity(capacity);
258
259        buf.extend_from_slice(&(group_id.len() as u32).to_le_bytes());
260        buf.extend_from_slice(group_id);
261        buf.extend_from_slice(&(sender_id.len() as u32).to_le_bytes());
262        buf.extend_from_slice(sender_id);
263        buf.push(self.message_type.as_u8());
264        buf.extend_from_slice(&self.epoch.to_le_bytes());
265        buf.extend_from_slice(&self.timestamp_ms.to_le_bytes());
266        buf.extend_from_slice(&(self.ciphertext.len() as u32).to_le_bytes());
267        buf.extend_from_slice(&self.ciphertext);
268
269        buf
270    }
271
272    /// Deserializes from the compact binary format produced by `to_bytes`.
273    pub fn from_bytes(data: &[u8]) -> Result<Self> {
274        let err = SealedError::Deserialization;
275        let mut pos = 0;
276
277        let read_u32 = |pos: &mut usize| -> Result<u32> {
278            if *pos + 4 > data.len() {
279                return Err(err("unexpected end of data reading u32".to_string()));
280            }
281            let mut bytes = [0u8; 4];
282            bytes.copy_from_slice(&data[*pos..*pos + 4]);
283            let val = u32::from_le_bytes(bytes);
284            *pos += 4;
285            Ok(val)
286        };
287
288        let read_u64 = |pos: &mut usize| -> Result<u64> {
289            if *pos + 8 > data.len() {
290                return Err(err("unexpected end of data reading u64".to_string()));
291            }
292            let mut bytes = [0u8; 8];
293            bytes.copy_from_slice(&data[*pos..*pos + 8]);
294            let val = u64::from_le_bytes(bytes);
295            *pos += 8;
296            Ok(val)
297        };
298
299        let read_bytes = |pos: &mut usize, len: usize| -> Result<Vec<u8>> {
300            if *pos + len > data.len() {
301                return Err(err("unexpected end of data reading bytes".to_string()));
302            }
303            let val = data[*pos..*pos + len].to_vec();
304            *pos += len;
305            Ok(val)
306        };
307
308        let group_id_len = read_u32(&mut pos)? as usize;
309        if group_id_len > MAX_STRING_FIELD_LEN {
310            return Err(err(format!(
311                "group_id_len {} exceeds maximum {}",
312                group_id_len, MAX_STRING_FIELD_LEN
313            )));
314        }
315        let group_id = String::from_utf8(read_bytes(&mut pos, group_id_len)?)
316            .map_err(|e| err(format!("invalid group_id UTF-8: {}", e)))?;
317
318        let sender_len = read_u32(&mut pos)? as usize;
319        if sender_len > MAX_STRING_FIELD_LEN {
320            return Err(err(format!(
321                "sender_len {} exceeds maximum {}",
322                sender_len, MAX_STRING_FIELD_LEN
323            )));
324        }
325        let sender_id = String::from_utf8(read_bytes(&mut pos, sender_len)?)
326            .map_err(|e| err(format!("invalid sender_id UTF-8: {}", e)))?;
327
328        if pos + 1 > data.len() {
329            return Err(err(
330                "unexpected end of data reading message_type".to_string()
331            ));
332        }
333        let message_type = MlsMessageType::from_u8(data[pos])
334            .ok_or_else(|| err(format!("unknown message_type tag {}", data[pos])))?;
335        pos += 1;
336
337        let epoch = read_u64(&mut pos)?;
338        let timestamp_ms = read_u64(&mut pos)?;
339
340        let ciphertext_len = read_u32(&mut pos)? as usize;
341        if ciphertext_len > Self::MAX_CIPHERTEXT_LEN {
342            return Err(err(format!(
343                "ciphertext_len {} exceeds maximum {}",
344                ciphertext_len,
345                Self::MAX_CIPHERTEXT_LEN
346            )));
347        }
348        let ciphertext = read_bytes(&mut pos, ciphertext_len)?;
349
350        Ok(Self {
351            group_id: GroupId::new(group_id)?,
352            message_type,
353            epoch,
354            ciphertext,
355            sender_id,
356            timestamp_ms,
357        })
358    }
359}
360
361/// A Welcome message for inviting users to a group.
362///
363/// A leaf node joins its pair from one of these, which is why it lives beside
364/// the envelope rather than with the group-management types in the MLS crate.
365#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct WelcomeMessage {
367    /// The group ID the user is being invited to.
368    pub group_id: GroupId,
369
370    /// Serialized MLS Welcome message bytes.
371    pub welcome_data: Vec<u8>,
372
373    /// The inviter's user ID.
374    pub inviter_id: String,
375
376    /// Optional group name for display.
377    pub group_name: Option<String>,
378
379    /// Timestamp when the welcome was created.
380    pub timestamp_ms: u64,
381}
382
383impl WelcomeMessage {
384    /// Encodes the welcome message to base64 for transport.
385    pub fn to_base64(&self) -> Result<String> {
386        let json =
387            serde_json::to_vec(self).map_err(|e| SealedError::Serialization(e.to_string()))?;
388        Ok(base64::Engine::encode(
389            &base64::engine::general_purpose::STANDARD,
390            &json,
391        ))
392    }
393
394    /// Decodes a welcome message from base64.
395    pub fn from_base64(encoded: &str) -> Result<Self> {
396        let json = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encoded)
397            .map_err(|e| SealedError::Deserialization(e.to_string()))?;
398        serde_json::from_slice(&json).map_err(|e| SealedError::Deserialization(e.to_string()))
399    }
400}
401
402#[cfg(all(test, feature = "std"))]
403mod tests {
404    use super::*;
405    use offline_protocol_core::Address;
406
407    /// Two addresses whose hash-byte order is the reverse of their rendered
408    /// string order.
409    ///
410    /// Hand-constructed rather than searched for, so the disagreement is
411    /// checkable by eye: the data part's second character encodes the version
412    /// byte's low three bits (`0b001`) followed by the hash's top two bits, so
413    /// `hash[0] = 0x00` yields charset value 4 to `y` (0x79) and
414    /// `hash[0] = 0x40` yields value 5 to `9` (0x39). Byte order puts the
415    /// `0x00` address first; ASCII order puts it second.
416    fn disagreeing_addresses() -> (Address, Address) {
417        let mut low_bytes = [0u8; Address::HASH_LEN];
418        low_bytes[0] = 0x00;
419        let mut high_bytes = [0u8; Address::HASH_LEN];
420        high_bytes[0] = 0x40;
421        (
422            Address::from_hash_bytes(low_bytes),
423            Address::from_hash_bytes(high_bytes),
424        )
425    }
426
427    #[test]
428    fn session_id_orders_addresses_by_hash_not_by_string() {
429        let (low, high) = disagreeing_addresses();
430
431        // The premise: the two orders genuinely disagree for this pair.
432        assert!(low < high, "hash-byte order");
433        assert!(
434            low.to_string() > high.to_string(),
435            "rendered-string order must be the opposite, or this test proves nothing"
436        );
437
438        let expected = format!("session:{}:{}", low, high);
439        assert_eq!(
440            GroupId::for_session(&low.to_string(), &high.to_string())
441                .unwrap()
442                .as_str(),
443            expected,
444            "address slots must follow Address order, not string order"
445        );
446    }
447
448    #[test]
449    fn session_id_is_symmetric_for_addresses() {
450        let (low, high) = disagreeing_addresses();
451        let (a, b) = (low.to_string(), high.to_string());
452
453        assert_eq!(
454            GroupId::for_session(&a, &b).unwrap(),
455            GroupId::for_session(&b, &a).unwrap(),
456            "both peers must derive the same slot regardless of argument order"
457        );
458    }
459
460    /// A pair that is not two addresses has no hash order to use, so it keeps
461    /// the string ordering it has always had.
462    #[test]
463    fn session_id_falls_back_to_string_order_for_non_addresses() {
464        assert_eq!(
465            GroupId::for_session("bob", "alice").unwrap().as_str(),
466            "session:alice:bob"
467        );
468
469        let (address, _) = disagreeing_addresses();
470        let mixed = GroupId::for_session("alice", &address.to_string()).unwrap();
471        let mixed_reversed = GroupId::for_session(&address.to_string(), "alice").unwrap();
472        assert_eq!(mixed, mixed_reversed, "mixed pairs must stay symmetric");
473    }
474
475    fn sample_encrypted_message() -> EncryptedMessage {
476        EncryptedMessage {
477            group_id: GroupId::for_session("alice", "bob").unwrap(),
478            message_type: MlsMessageType::Application,
479            epoch: 42,
480            ciphertext: vec![0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01],
481            sender_id: "alice".to_string(),
482            timestamp_ms: 1_700_000_000_123,
483        }
484    }
485
486    #[test]
487    fn test_encrypted_message_bytes_roundtrip() {
488        let msg = sample_encrypted_message();
489        let bytes = msg.to_bytes();
490        let decoded = EncryptedMessage::from_bytes(&bytes).unwrap();
491
492        assert_eq!(decoded.group_id, msg.group_id);
493        assert_eq!(decoded.message_type, msg.message_type);
494        assert_eq!(decoded.epoch, msg.epoch);
495        assert_eq!(decoded.ciphertext, msg.ciphertext);
496        assert_eq!(decoded.sender_id, msg.sender_id);
497        assert_eq!(decoded.timestamp_ms, msg.timestamp_ms);
498    }
499
500    #[test]
501    fn test_encrypted_message_bytes_roundtrip_empty_ciphertext() {
502        let mut msg = sample_encrypted_message();
503        msg.ciphertext = Vec::new();
504        let decoded = EncryptedMessage::from_bytes(&msg.to_bytes()).unwrap();
505        assert!(decoded.ciphertext.is_empty());
506    }
507
508    #[test]
509    fn test_encrypted_message_from_bytes_truncated() {
510        let bytes = sample_encrypted_message().to_bytes();
511        // Every strict prefix must fail cleanly, never panic.
512        for len in 0..bytes.len() {
513            assert!(
514                EncryptedMessage::from_bytes(&bytes[..len]).is_err(),
515                "truncation at {} unexpectedly succeeded",
516                len
517            );
518        }
519    }
520
521    #[test]
522    fn test_encrypted_message_from_bytes_oversized_group_id_rejected() {
523        // Claim a group_id length far beyond the cap without supplying data.
524        let mut bytes = Vec::new();
525        bytes.extend_from_slice(&(u32::MAX).to_le_bytes());
526        let err = EncryptedMessage::from_bytes(&bytes).unwrap_err();
527        assert!(err.to_string().contains("group_id_len"));
528    }
529
530    /// The sibling of the group-id cap, which had no test of its own before
531    /// this codec moved: a crafted `sender_len` must be refused by the cap
532    /// rather than by running off the end of the buffer.
533    #[test]
534    fn test_encrypted_message_from_bytes_oversized_sender_id_rejected() {
535        let mut bytes = Vec::new();
536        let group_id = b"session:alice:bob";
537        bytes.extend_from_slice(&(group_id.len() as u32).to_le_bytes());
538        bytes.extend_from_slice(group_id);
539        bytes.extend_from_slice(&(u32::MAX).to_le_bytes());
540        let err = EncryptedMessage::from_bytes(&bytes).unwrap_err();
541        assert!(
542            err.to_string().contains("sender_len"),
543            "expected the sender_len cap to refuse it, got: {err}"
544        );
545    }
546
547    #[test]
548    fn test_encrypted_message_from_bytes_oversized_ciphertext_rejected() {
549        let mut msg = sample_encrypted_message();
550        msg.ciphertext = vec![0u8; 4];
551        let mut bytes = msg.to_bytes();
552        // Overwrite the ciphertext length prefix (last 4 + 4 bytes from the end).
553        let ct_len_offset = bytes.len() - 4 - 4;
554        bytes[ct_len_offset..ct_len_offset + 4].copy_from_slice(&(u32::MAX).to_le_bytes());
555        let err = EncryptedMessage::from_bytes(&bytes).unwrap_err();
556        assert!(err.to_string().contains("ciphertext_len"));
557    }
558
559    #[test]
560    fn test_encrypted_message_from_bytes_unknown_message_type_rejected() {
561        let msg = sample_encrypted_message();
562        let mut bytes = msg.to_bytes();
563        let tag_offset = 4 + msg.group_id.as_str().len() + 4 + msg.sender_id.len();
564        bytes[tag_offset] = 0xFF;
565        let err = EncryptedMessage::from_bytes(&bytes).unwrap_err();
566        assert!(err.to_string().contains("message_type"));
567    }
568
569    /// The property that lets one parser accept both encodings: a JSON body
570    /// must be refused by the compact parser rather than half-decoded, so the
571    /// caller can fall through to JSON. `{"` reads as the little-endian u32
572    /// 8827, which is over the cap.
573    ///
574    /// The engine pins the fall-through itself; this pins the half of it that
575    /// lives in the codec, where the cap that makes it work is defined.
576    #[test]
577    fn json_bodies_are_refused_by_the_compact_parser() {
578        let json = serde_json::to_vec(&sample_encrypted_message()).unwrap();
579        assert!(json.starts_with(b"{\""), "premise: JSON starts with {{\"");
580
581        let err = EncryptedMessage::from_bytes(&json).unwrap_err();
582        assert!(
583            err.to_string().contains("group_id_len"),
584            "a JSON body must be refused by the string-field cap, got: {err}"
585        );
586
587        let claimed = u32::from_le_bytes([json[0], json[1], json[2], json[3]]) as usize;
588        assert!(
589            claimed > MAX_STRING_FIELD_LEN,
590            "the cap only disambiguates while it stays below {claimed}"
591        );
592    }
593
594    #[test]
595    fn test_group_id_accepts_legitimate_formats() {
596        assert!(GroupId::new("group:0bd6e5f2-3a70-4a4e-9c3f-1c1f2a3b4c5d").is_ok());
597        assert!(GroupId::new("session:alice:bob").is_ok());
598        assert!(GroupId::new("plain-segment_1.x@y").is_ok());
599    }
600
601    #[test]
602    fn test_group_id_rejects_storage_hostile_chars() {
603        assert!(GroupId::new("").is_err());
604        assert!(GroupId::new("group/evil").is_err());
605        assert!(GroupId::new("group\\evil").is_err());
606        assert!(GroupId::new("group\0evil").is_err());
607        assert!(GroupId::new("group\nevil").is_err());
608        assert!(GroupId::new("group\x7Fevil").is_err()); // DEL
609        assert!(GroupId::new(".").is_err());
610        assert!(GroupId::new("..").is_err());
611        // Path traversal hidden inside a segment
612        assert!(GroupId::new("session:..:bob").is_err());
613        assert!(GroupId::new("group:../../etc").is_err());
614        // Empty segments (leading/trailing/doubled colons)
615        assert!(GroupId::new("session:").is_err());
616        assert!(GroupId::new(":session").is_err());
617        assert!(GroupId::new("a::b").is_err());
618    }
619
620    #[test]
621    fn test_group_id_length_cap() {
622        assert!(GroupId::new("a".repeat(GroupId::MAX_LEN)).is_ok());
623        assert!(GroupId::new("a".repeat(GroupId::MAX_LEN + 1)).is_err());
624    }
625
626    #[test]
627    fn test_group_id_deserialize_rejects_hostile() {
628        assert!(serde_json::from_str::<GroupId>(r#""evil/path""#).is_err());
629        assert!(serde_json::from_str::<GroupId>(r#""..""#).is_err());
630        assert!(serde_json::from_str::<GroupId>(r#""session:alice:bob""#).is_ok());
631    }
632
633    #[test]
634    fn test_encrypted_message_from_bytes_rejects_hostile_group_id() {
635        // Hand-craft wire bytes: the typed constructor can no longer produce
636        // an invalid group id, but a malicious peer can still send one.
637        let mut bytes = Vec::new();
638        let hostile = b"evil/../path";
639        bytes.extend_from_slice(&(hostile.len() as u32).to_le_bytes());
640        bytes.extend_from_slice(hostile);
641        let sender = b"alice";
642        bytes.extend_from_slice(&(sender.len() as u32).to_le_bytes());
643        bytes.extend_from_slice(sender);
644        bytes.push(0); // Application
645        bytes.extend_from_slice(&42u64.to_le_bytes()); // epoch
646        bytes.extend_from_slice(&1u64.to_le_bytes()); // timestamp_ms
647        bytes.extend_from_slice(&0u32.to_le_bytes()); // empty ciphertext
648        let err = EncryptedMessage::from_bytes(&bytes).unwrap_err();
649        assert!(matches!(err, SealedError::InvalidGroupId(_)));
650    }
651
652    #[test]
653    fn test_mls_message_type_u8_roundtrip() {
654        for msg_type in [
655            MlsMessageType::Application,
656            MlsMessageType::Welcome,
657            MlsMessageType::Commit,
658            MlsMessageType::Proposal,
659        ] {
660            assert_eq!(MlsMessageType::from_u8(msg_type.as_u8()), Some(msg_type));
661        }
662        assert_eq!(MlsMessageType::from_u8(4), None);
663    }
664}