Skip to main content

offline_protocol_mls/
types.rs

1//! Types for MLS operations.
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5use std::str::FromStr;
6
7/// Unique identifier for an MLS group.
8///
9/// Group ids flow from the wire into [`crate::storage::MlsStorage`] as raw
10/// `key_id` storage keys, so they are validated at construction: one or more
11/// non-empty colon-separated segments (`:` is the namespace separator used by
12/// `session:<a>:<b>` and `group:<uuid>` ids), each segment subject to the
13/// same storage-key policy as `UserId`/`AppId` (no path-traversal components,
14/// control characters, `/`, or `\`), with a total length cap of
15/// [`GroupId::MAX_LEN`].
16#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
17pub struct GroupId(String);
18
19impl GroupId {
20    /// Maximum accepted group id length in bytes.
21    ///
22    /// Matches the `EncryptedMessage` compact wire format's string-field cap.
23    pub const MAX_LEN: usize = 4096;
24
25    /// Creates a new group ID, rejecting storage-hostile values.
26    pub fn new(id: impl Into<String>) -> crate::error::Result<Self> {
27        let id = id.into();
28        Self::validate(&id)?;
29        Ok(Self(id))
30    }
31
32    fn validate(id: &str) -> crate::error::Result<()> {
33        if id.is_empty() {
34            return Err(crate::MlsError::InvalidGroupId(
35                "Group ID cannot be empty".to_string(),
36            ));
37        }
38        if id.len() > Self::MAX_LEN {
39            return Err(crate::MlsError::InvalidGroupId(format!(
40                "Group ID length {} exceeds maximum {}",
41                id.len(),
42                Self::MAX_LEN
43            )));
44        }
45        for segment in id.split(':') {
46            offline_protocol_core::validate_id_chars(segment, "Group ID segment")
47                .map_err(|e| crate::MlsError::InvalidGroupId(e.to_string()))?;
48        }
49        Ok(())
50    }
51
52    /// Returns the group ID as a string slice.
53    pub fn as_str(&self) -> &str {
54        &self.0
55    }
56
57    /// Builds the deterministic group ID for a 1:1 session, rejecting
58    /// storage-hostile user ids (the id becomes a raw storage key).
59    ///
60    /// Both peers must land on the same string, so the two ids are put in a
61    /// canonical order. When both are addresses that order is
62    /// [`Address`](offline_protocol_core::Address) order — the underlying hash
63    /// bytes — because the bech32 charset is not ASCII-monotonic (value 4
64    /// renders as `y` 0x79, value 5 as `9` 0x39), so sorting the rendered
65    /// strings would disagree with every other address comparison in the
66    /// protocol. Ids that are not both addresses fall back to string order,
67    /// which is all a nickname id has.
68    ///
69    /// Either way the result is symmetric in its arguments, which is the
70    /// property peers actually depend on.
71    pub fn for_session(user_a: &str, user_b: &str) -> crate::error::Result<Self> {
72        use offline_protocol_core::Address;
73
74        let ordered = match (user_a.parse::<Address>(), user_b.parse::<Address>()) {
75            (Ok(addr_a), Ok(addr_b)) if addr_b < addr_a => [user_b, user_a],
76            (Ok(_), Ok(_)) => [user_a, user_b],
77            _ => {
78                let mut users = [user_a, user_b];
79                users.sort();
80                users
81            }
82        };
83
84        Self::new(format!("session:{}:{}", ordered[0], ordered[1]))
85    }
86}
87
88impl std::fmt::Display for GroupId {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        write!(f, "{}", self.0)
91    }
92}
93
94impl<'de> Deserialize<'de> for GroupId {
95    fn deserialize<D: serde::Deserializer<'de>>(
96        deserializer: D,
97    ) -> std::result::Result<Self, D::Error> {
98        let s = String::deserialize(deserializer)?;
99        GroupId::new(s).map_err(serde::de::Error::custom)
100    }
101}
102
103/// Role a user holds within a group.
104#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
105#[serde(rename_all = "lowercase")]
106pub enum GroupRole {
107    /// Group administrator — can invite/remove members and change roles.
108    Admin,
109    /// Regular group member (also the fallback for unknown future variants).
110    #[default]
111    #[serde(other)]
112    Member,
113}
114
115impl fmt::Display for GroupRole {
116    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117        match self {
118            Self::Admin => write!(f, "admin"),
119            Self::Member => write!(f, "member"),
120        }
121    }
122}
123
124/// Error returned when parsing a [`GroupRole`] from a string.
125#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
126#[error("Invalid role '{0}', must be 'admin' or 'member'")]
127pub struct ParseGroupRoleError(pub String);
128
129impl FromStr for GroupRole {
130    type Err = ParseGroupRoleError;
131
132    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
133        match s.to_ascii_lowercase().as_str() {
134            "admin" => Ok(Self::Admin),
135            "member" => Ok(Self::Member),
136            _ => Err(ParseGroupRoleError(s.to_string())),
137        }
138    }
139}
140
141/// A bundle containing a key package and metadata for distribution.
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct KeyPackageBundle {
144    /// Unique identifier for this key package.
145    pub package_id: String,
146
147    /// User ID this key package belongs to.
148    pub user_id: String,
149
150    /// Serialized MLS KeyPackage bytes.
151    pub key_package_data: Vec<u8>,
152
153    /// Timestamp when the key package was created (milliseconds since epoch).
154    pub created_at_ms: u64,
155
156    /// Timestamp when the key package expires (milliseconds since epoch).
157    pub expires_at_ms: u64,
158
159    /// Whether this key package has been uploaded to a server.
160    pub synced: bool,
161
162    /// Whether this package is reserved for a publication slot.
163    ///
164    /// An MLS key package's init key is consumed by the first peer who uses it,
165    /// so a package standing in a published record must not also be handed to a
166    /// peer over the push path: the second user of it builds a Welcome that can
167    /// never be processed. Reserved packages are therefore invisible to both
168    /// entry points that hand packages out —
169    /// [`MlsManager::take_push_key_package`](crate::MlsManager::take_push_key_package)
170    /// (the push path) and
171    /// [`MlsManager::get_or_create_key_package`](crate::MlsManager::get_or_create_key_package)
172    /// (the peer-less one).
173    ///
174    /// Additive and defaulted, so packages written before this field existed
175    /// deserialize as unreserved — which is what they are.
176    #[serde(default)]
177    pub reserved_for_publication: bool,
178
179    /// The peer this package has been handed to over the push path, if any.
180    ///
181    /// This is what keeps the push path from advertising one init key to
182    /// everybody: a package is claimed by the first peer it is pushed to and
183    /// afterwards only ever re-handed to *that* peer, so a repeat push costs no
184    /// new key material while two peers never share one. RFC 9420 §16.8 asks
185    /// for a key package to be rotated as soon as possible after use; the
186    /// rotation trigger is consumption, which
187    /// [`MlsManager::key_package_by_id`](crate::MlsManager::key_package_by_id)
188    /// already reports, and a consumed package's successor is minted on the
189    /// next push to that peer.
190    ///
191    /// `None` means unclaimed — either minted outside the push path (the
192    /// peer-less FFI entry point) or written before this field existed. Those
193    /// are claimable, so upgrading does not strand the package already in
194    /// storage.
195    ///
196    /// Local bookkeeping only: never serialized onto the wire, and never part
197    /// of the FFI record.
198    #[serde(default)]
199    pub assigned_peer: Option<String>,
200
201    /// The TLS-serialized OpenMLS hash reference this package's private init
202    /// key is stored under in the provider, stamped at mint time.
203    ///
204    /// A cache: deriving the reference from `key_package_data` costs a TLS
205    /// parse plus a signature validation, and the push path's pool scan checks
206    /// every stored package's usability on every push — without this it pays
207    /// that per package per push. Records written before the field existed
208    /// deserialize as `None` and are backfilled on their first load.
209    ///
210    /// Local bookkeeping only, like [`Self::assigned_peer`]: never on the
211    /// wire, never in the FFI record.
212    #[serde(default)]
213    pub provider_hash_ref: Option<Vec<u8>>,
214}
215
216impl KeyPackageBundle {
217    /// Creates a new key package bundle.
218    pub fn new(
219        package_id: String,
220        user_id: String,
221        key_package_data: Vec<u8>,
222        lifetime_secs: u64,
223    ) -> Self {
224        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
225        Self {
226            package_id,
227            user_id,
228            key_package_data,
229            created_at_ms: now_ms,
230            expires_at_ms: now_ms + (lifetime_secs * 1000),
231            synced: false,
232            reserved_for_publication: false,
233            assigned_peer: None,
234            provider_hash_ref: None,
235        }
236    }
237
238    /// Checks if the key package has expired (local device's own packages only).
239    ///
240    /// This compares against the local clock and is valid because `created_at_ms`
241    /// and `expires_at_ms` were set on this same device.
242    pub fn is_expired(&self) -> bool {
243        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
244        now_ms >= self.expires_at_ms
245    }
246
247    /// Whether the package expired more than `grace_secs` ago.
248    ///
249    /// Expiry and the destruction of the private init key are deliberately two
250    /// different moments. An expired package is no longer advertised, but a peer
251    /// holding a copy handed out just before expiry may still Welcome us — and
252    /// that Welcome is only processable while the init key is in provider
253    /// storage. The grace window is how long that stays true; past it the key
254    /// material is destroyed rather than left resident for the life of the
255    /// install.
256    pub fn expired_past_grace(&self, grace_secs: u64) -> bool {
257        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
258        now_ms
259            >= self
260                .expires_at_ms
261                .saturating_add(grace_secs.saturating_mul(1000))
262    }
263
264    /// Returns the remaining valid lifetime in milliseconds.
265    ///
266    /// Used when transmitting key packages to peers: the receiver applies
267    /// this duration relative to their own clock, eliminating cross-device
268    /// clock skew from expiry calculations.
269    pub fn remaining_lifetime_ms(&self) -> u64 {
270        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
271        self.expires_at_ms.saturating_sub(now_ms)
272    }
273
274    /// Creates a bundle from received transfer data, computing local expiry
275    /// from the sender-provided remaining lifetime.
276    pub fn from_transfer(
277        package_id: String,
278        user_id: String,
279        key_package_data: Vec<u8>,
280        remaining_lifetime_ms: u64,
281    ) -> Self {
282        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
283        Self {
284            package_id,
285            user_id,
286            key_package_data,
287            created_at_ms: now_ms,
288            expires_at_ms: now_ms.saturating_add(remaining_lifetime_ms),
289            synced: false,
290            // A peer's package, never one of our publication slots.
291            reserved_for_publication: false,
292            // Nor one of ours to hand out — the push-path assignment is about
293            // packages this device minted.
294            assigned_peer: None,
295            // Provider refs describe our own private material; a peer's
296            // package has none here.
297            provider_hash_ref: None,
298        }
299    }
300}
301
302/// Information about an MLS group.
303#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct GroupInfo {
305    /// Unique group identifier.
306    pub group_id: GroupId,
307
308    /// Human-readable group name (for multi-party groups).
309    pub name: Option<String>,
310
311    /// List of member user IDs.
312    pub members: Vec<String>,
313
314    /// Count of members in the group.
315    pub members_count: u32,
316
317    /// Current epoch number.
318    pub epoch: u64,
319
320    /// Whether this is a 1:1 session (2-person group).
321    pub is_session: bool,
322
323    /// Timestamp when the group was created.
324    pub created_at_ms: u64,
325
326    /// Timestamp of the last activity.
327    pub last_activity_ms: u64,
328
329    /// How many leaves the roster read *skipped* because they do not carry the
330    /// address their own signature key derives to.
331    ///
332    /// Normally zero: the Welcome and commit gates refuse such a leaf before it
333    /// can enter local group state, so a non-zero count means the state was
334    /// written behind the SDK's back — a direct write to the install-scoped
335    /// provider store, or a group joined by a build predating those gates.
336    ///
337    /// A count, not the identities. The claimed credentials are attacker-chosen
338    /// strings, and the whole reason [`crate::group::GroupManager::get_group_info`]
339    /// filters them out is that the roster must not carry an identity nobody
340    /// proved; handing the same string back through a second field would return
341    /// it by another door. It is also not what an app should act on: the leaf
342    /// holds live group secrets and reads everything sent to the group, so the
343    /// remedy is to abandon the group, not to evict one member of it.
344    ///
345    /// Additive with `#[serde(default)]` so records written before this field
346    /// existed still load.
347    #[serde(default)]
348    pub unproven_members: u32,
349}
350
351/// Type of MLS message.
352#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
353pub enum MlsMessageType {
354    /// Application message (encrypted content).
355    Application,
356
357    /// Welcome message for new group members.
358    Welcome,
359
360    /// Commit message for group state changes.
361    Commit,
362
363    /// Proposal message.
364    Proposal,
365}
366
367impl MlsMessageType {
368    /// Returns a stable string representation for serialization/FFI.
369    pub fn as_str(&self) -> &'static str {
370        match self {
371            Self::Application => "Application",
372            Self::Welcome => "Welcome",
373            Self::Commit => "Commit",
374            Self::Proposal => "Proposal",
375        }
376    }
377
378    /// Parses a string into an MlsMessageType, returning None for unknown values.
379    pub fn from_str_opt(s: &str) -> Option<Self> {
380        match s {
381            "Application" => Some(Self::Application),
382            "Welcome" => Some(Self::Welcome),
383            "Commit" => Some(Self::Commit),
384            "Proposal" => Some(Self::Proposal),
385            _ => None,
386        }
387    }
388
389    /// Returns a stable single-byte tag for the compact binary wire format.
390    pub fn as_u8(&self) -> u8 {
391        match self {
392            Self::Application => 0,
393            Self::Welcome => 1,
394            Self::Commit => 2,
395            Self::Proposal => 3,
396        }
397    }
398
399    /// Parses a wire-format tag, returning None for unknown values.
400    pub fn from_u8(tag: u8) -> Option<Self> {
401        match tag {
402            0 => Some(Self::Application),
403            1 => Some(Self::Welcome),
404            2 => Some(Self::Commit),
405            3 => Some(Self::Proposal),
406            _ => None,
407        }
408    }
409}
410
411impl std::fmt::Display for MlsMessageType {
412    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
413        f.write_str(self.as_str())
414    }
415}
416
417/// An encrypted MLS message ready for transport.
418#[derive(Debug, Clone, Serialize, Deserialize)]
419pub struct EncryptedMessage {
420    /// The group ID this message belongs to.
421    pub group_id: GroupId,
422
423    /// Type of MLS message.
424    pub message_type: MlsMessageType,
425
426    /// Current epoch when the message was created.
427    pub epoch: u64,
428
429    /// Serialized MLS message bytes.
430    pub ciphertext: Vec<u8>,
431
432    /// Sender's user ID.
433    pub sender_id: String,
434
435    /// Timestamp when the message was created.
436    pub timestamp_ms: u64,
437}
438
439impl EncryptedMessage {
440    /// Encodes the encrypted message to base64 for transport.
441    pub fn to_base64(&self) -> Result<String, crate::MlsError> {
442        let json =
443            serde_json::to_vec(self).map_err(|e| crate::MlsError::Serialization(e.to_string()))?;
444        Ok(base64::Engine::encode(
445            &base64::engine::general_purpose::STANDARD,
446            &json,
447        ))
448    }
449
450    /// Decodes an encrypted message from base64.
451    pub fn from_base64(encoded: &str) -> Result<Self, crate::MlsError> {
452        let json = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encoded)
453            .map_err(|e| crate::MlsError::Deserialization(e.to_string()))?;
454        serde_json::from_slice(&json).map_err(|e| crate::MlsError::Deserialization(e.to_string()))
455    }
456
457    /// Maximum allowed length for a string field (group_id, sender_id) in the
458    /// compact binary wire format. Prevents allocation bombs from crafted payloads.
459    const MAX_STRING_FIELD_LEN: usize = 4096;
460    /// Maximum allowed ciphertext length in the compact binary wire format.
461    const MAX_CIPHERTEXT_LEN: usize = 128 * 1024 * 1024; // 128 MB
462
463    /// Serializes to a compact binary format, avoiding base64/JSON overhead.
464    ///
465    /// JSON renders `ciphertext` as an integer array (~3.7x inflation), which
466    /// is unacceptable for large payloads such as encrypted file chunks.
467    ///
468    /// Wire format (all multi-byte integers are little-endian):
469    /// ```text
470    /// [group_id_len:4][group_id][sender_len:4][sender_id]
471    /// [message_type:1][epoch:8][timestamp_ms:8]
472    /// [ciphertext_len:4][ciphertext]
473    /// ```
474    pub fn to_bytes(&self) -> Vec<u8> {
475        let group_id = self.group_id.as_str().as_bytes();
476        let sender_id = self.sender_id.as_bytes();
477
478        let capacity =
479            4 + group_id.len() + 4 + sender_id.len() + 1 + 8 + 8 + 4 + self.ciphertext.len();
480        let mut buf = Vec::with_capacity(capacity);
481
482        buf.extend_from_slice(&(group_id.len() as u32).to_le_bytes());
483        buf.extend_from_slice(group_id);
484        buf.extend_from_slice(&(sender_id.len() as u32).to_le_bytes());
485        buf.extend_from_slice(sender_id);
486        buf.push(self.message_type.as_u8());
487        buf.extend_from_slice(&self.epoch.to_le_bytes());
488        buf.extend_from_slice(&self.timestamp_ms.to_le_bytes());
489        buf.extend_from_slice(&(self.ciphertext.len() as u32).to_le_bytes());
490        buf.extend_from_slice(&self.ciphertext);
491
492        buf
493    }
494
495    /// Deserializes from the compact binary format produced by `to_bytes`.
496    pub fn from_bytes(data: &[u8]) -> Result<Self, crate::MlsError> {
497        let err = |msg: String| crate::MlsError::Deserialization(msg);
498        let mut pos = 0;
499
500        let read_u32 = |pos: &mut usize| -> Result<u32, crate::MlsError> {
501            if *pos + 4 > data.len() {
502                return Err(err("unexpected end of data reading u32".to_string()));
503            }
504            let mut bytes = [0u8; 4];
505            bytes.copy_from_slice(&data[*pos..*pos + 4]);
506            let val = u32::from_le_bytes(bytes);
507            *pos += 4;
508            Ok(val)
509        };
510
511        let read_u64 = |pos: &mut usize| -> Result<u64, crate::MlsError> {
512            if *pos + 8 > data.len() {
513                return Err(err("unexpected end of data reading u64".to_string()));
514            }
515            let mut bytes = [0u8; 8];
516            bytes.copy_from_slice(&data[*pos..*pos + 8]);
517            let val = u64::from_le_bytes(bytes);
518            *pos += 8;
519            Ok(val)
520        };
521
522        let read_bytes = |pos: &mut usize, len: usize| -> Result<Vec<u8>, crate::MlsError> {
523            if *pos + len > data.len() {
524                return Err(err("unexpected end of data reading bytes".to_string()));
525            }
526            let val = data[*pos..*pos + len].to_vec();
527            *pos += len;
528            Ok(val)
529        };
530
531        let group_id_len = read_u32(&mut pos)? as usize;
532        if group_id_len > Self::MAX_STRING_FIELD_LEN {
533            return Err(err(format!(
534                "group_id_len {} exceeds maximum {}",
535                group_id_len,
536                Self::MAX_STRING_FIELD_LEN
537            )));
538        }
539        let group_id = String::from_utf8(read_bytes(&mut pos, group_id_len)?)
540            .map_err(|e| err(format!("invalid group_id UTF-8: {}", e)))?;
541
542        let sender_len = read_u32(&mut pos)? as usize;
543        if sender_len > Self::MAX_STRING_FIELD_LEN {
544            return Err(err(format!(
545                "sender_len {} exceeds maximum {}",
546                sender_len,
547                Self::MAX_STRING_FIELD_LEN
548            )));
549        }
550        let sender_id = String::from_utf8(read_bytes(&mut pos, sender_len)?)
551            .map_err(|e| err(format!("invalid sender_id UTF-8: {}", e)))?;
552
553        if pos + 1 > data.len() {
554            return Err(err(
555                "unexpected end of data reading message_type".to_string()
556            ));
557        }
558        let message_type = MlsMessageType::from_u8(data[pos])
559            .ok_or_else(|| err(format!("unknown message_type tag {}", data[pos])))?;
560        pos += 1;
561
562        let epoch = read_u64(&mut pos)?;
563        let timestamp_ms = read_u64(&mut pos)?;
564
565        let ciphertext_len = read_u32(&mut pos)? as usize;
566        if ciphertext_len > Self::MAX_CIPHERTEXT_LEN {
567            return Err(err(format!(
568                "ciphertext_len {} exceeds maximum {}",
569                ciphertext_len,
570                Self::MAX_CIPHERTEXT_LEN
571            )));
572        }
573        let ciphertext = read_bytes(&mut pos, ciphertext_len)?;
574
575        Ok(Self {
576            group_id: GroupId::new(group_id)?,
577            message_type,
578            epoch,
579            ciphertext,
580            sender_id,
581            timestamp_ms,
582        })
583    }
584}
585
586/// A Welcome message for inviting users to a group.
587#[derive(Debug, Clone, Serialize, Deserialize)]
588pub struct WelcomeMessage {
589    /// The group ID the user is being invited to.
590    pub group_id: GroupId,
591
592    /// Serialized MLS Welcome message bytes.
593    pub welcome_data: Vec<u8>,
594
595    /// The inviter's user ID.
596    pub inviter_id: String,
597
598    /// Optional group name for display.
599    pub group_name: Option<String>,
600
601    /// Timestamp when the welcome was created.
602    pub timestamp_ms: u64,
603}
604
605impl WelcomeMessage {
606    /// Encodes the welcome message to base64 for transport.
607    pub fn to_base64(&self) -> Result<String, crate::MlsError> {
608        let json =
609            serde_json::to_vec(self).map_err(|e| crate::MlsError::Serialization(e.to_string()))?;
610        Ok(base64::Engine::encode(
611            &base64::engine::general_purpose::STANDARD,
612            &json,
613        ))
614    }
615
616    /// Decodes a welcome message from base64.
617    pub fn from_base64(encoded: &str) -> Result<Self, crate::MlsError> {
618        let json = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, encoded)
619            .map_err(|e| crate::MlsError::Deserialization(e.to_string()))?;
620        serde_json::from_slice(&json).map_err(|e| crate::MlsError::Deserialization(e.to_string()))
621    }
622}
623
624/// Metadata about an MLS group (stored separately from MLS state).
625#[derive(Debug, Clone, Serialize, Deserialize)]
626pub struct GroupMetadata {
627    /// Human-readable group name.
628    pub name: Option<String>,
629
630    /// Timestamp when the group was created (milliseconds since epoch).
631    pub created_at_ms: u64,
632
633    /// Timestamp of the last activity (milliseconds since epoch).
634    pub last_activity_ms: u64,
635
636    /// Custom application-specific metadata.
637    #[serde(default)]
638    pub custom: std::collections::HashMap<String, String>,
639
640    /// User ID of the group creator.
641    #[serde(default)]
642    pub created_by: Option<String>,
643
644    /// Per-member role assignments.
645    /// Deserialization falls back to migrating legacy `"role:*"` keys from `custom`.
646    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
647    pub roles: std::collections::HashMap<String, GroupRole>,
648}
649
650impl GroupMetadata {
651    /// Prefix previously used for role entries in the `custom` map (legacy).
652    pub const LEGACY_ROLE_KEY_PREFIX: &'static str = "role:";
653
654    /// Creates new group metadata with the given name and creator.
655    pub fn new(name: Option<String>) -> Self {
656        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
657        Self {
658            name,
659            created_at_ms: now_ms,
660            last_activity_ms: now_ms,
661            custom: std::collections::HashMap::new(),
662            created_by: None,
663            roles: std::collections::HashMap::new(),
664        }
665    }
666
667    /// Creates new group metadata with a creator.
668    pub fn new_with_creator(name: Option<String>, creator_id: &str) -> Self {
669        let mut meta = Self::new(name);
670        meta.created_by = Some(creator_id.to_string());
671        meta.set_role(creator_id, GroupRole::Admin);
672        meta
673    }
674
675    /// Migrates any legacy `"role:*"` keys from the `custom` map into the
676    /// dedicated `roles` field. Call after deserialization for
677    /// backwards-compatibility with older group metadata.
678    pub fn migrate_legacy_roles(&mut self) {
679        let legacy_keys: Vec<String> = self
680            .custom
681            .keys()
682            .filter(|k| k.starts_with(Self::LEGACY_ROLE_KEY_PREFIX))
683            .cloned()
684            .collect();
685        for key in legacy_keys {
686            if let Some(uid) = key.strip_prefix(Self::LEGACY_ROLE_KEY_PREFIX) {
687                if !self.roles.contains_key(uid) {
688                    let role = self
689                        .custom
690                        .get(&key)
691                        .and_then(|v| v.parse().ok())
692                        .unwrap_or_default();
693                    self.roles.insert(uid.to_string(), role);
694                }
695            }
696            self.custom.remove(&key);
697        }
698    }
699
700    /// Updates the last activity timestamp to now.
701    pub fn touch(&mut self) {
702        self.last_activity_ms = chrono::Utc::now().timestamp_millis() as u64;
703    }
704
705    /// Gets the role for a user, defaulting to [`GroupRole::Member`] if not set.
706    pub fn get_role(&self, user_id: &str) -> GroupRole {
707        self.roles.get(user_id).copied().unwrap_or_default()
708    }
709
710    /// Sets the role for a user.
711    pub fn set_role(&mut self, user_id: &str, role: GroupRole) {
712        self.roles.insert(user_id.to_string(), role);
713    }
714
715    /// Removes role metadata for a user (on removal from group).
716    pub fn remove_role(&mut self, user_id: &str) {
717        self.roles.remove(user_id);
718    }
719
720    /// Returns true if any admin role is stored in this metadata.
721    pub fn has_any_admin(&self) -> bool {
722        self.roles.values().any(|r| *r == GroupRole::Admin)
723    }
724
725    /// Returns all `user_id -> role` mappings.
726    pub fn get_all_roles(&self) -> std::collections::HashMap<String, GroupRole> {
727        self.roles.clone()
728    }
729}
730
731/// Storage key types for organizing MLS data.
732#[derive(Debug, Clone, Copy, PartialEq, Eq)]
733pub enum StorageKeyType {
734    /// Identity/signature key pair.
735    Identity,
736
737    /// Key packages for receiving Welcome messages.
738    KeyPackage,
739
740    /// MLS group state.
741    GroupState,
742
743    /// Epoch secrets for a group.
744    EpochSecrets,
745
746    /// Credential data.
747    Credential,
748
749    /// Contact's key packages.
750    ContactKeyPackage,
751
752    /// Group metadata (name, timestamps).
753    GroupMetadata,
754
755    /// Pending welcome messages awaiting delivery.
756    PendingWelcome,
757}
758
759impl StorageKeyType {
760    /// Returns the string representation for storage.
761    pub fn as_str(&self) -> &'static str {
762        match self {
763            Self::Identity => "identity",
764            Self::KeyPackage => "key_package",
765            Self::GroupState => "group_state",
766            Self::EpochSecrets => "epoch_secrets",
767            Self::Credential => "credential",
768            Self::ContactKeyPackage => "contact_key_package",
769            Self::GroupMetadata => "group_metadata",
770            Self::PendingWelcome => "pending_welcome",
771        }
772    }
773}
774
775impl std::fmt::Display for StorageKeyType {
776    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
777        write!(f, "{}", self.as_str())
778    }
779}
780
781#[cfg(test)]
782mod tests {
783    use super::*;
784    use offline_protocol_core::Address;
785
786    /// Two addresses whose hash-byte order is the reverse of their rendered
787    /// string order.
788    ///
789    /// Hand-constructed rather than searched for, so the disagreement is
790    /// checkable by eye: the data part's second character encodes the version
791    /// byte's low three bits (`0b001`) followed by the hash's top two bits, so
792    /// `hash[0] = 0x00` yields charset value 4 → `y` (0x79) and
793    /// `hash[0] = 0x40` yields value 5 → `9` (0x39). Byte order puts the
794    /// `0x00` address first; ASCII order puts it second.
795    fn disagreeing_addresses() -> (Address, Address) {
796        let mut low_bytes = [0u8; Address::HASH_LEN];
797        low_bytes[0] = 0x00;
798        let mut high_bytes = [0u8; Address::HASH_LEN];
799        high_bytes[0] = 0x40;
800        (
801            Address::from_hash_bytes(low_bytes),
802            Address::from_hash_bytes(high_bytes),
803        )
804    }
805
806    #[test]
807    fn session_id_orders_addresses_by_hash_not_by_string() {
808        let (low, high) = disagreeing_addresses();
809
810        // The premise: the two orders genuinely disagree for this pair.
811        assert!(low < high, "hash-byte order");
812        assert!(
813            low.to_string() > high.to_string(),
814            "rendered-string order must be the opposite, or this test proves nothing"
815        );
816
817        let expected = format!("session:{}:{}", low, high);
818        assert_eq!(
819            GroupId::for_session(&low.to_string(), &high.to_string())
820                .unwrap()
821                .as_str(),
822            expected,
823            "address slots must follow Address order, not string order"
824        );
825    }
826
827    #[test]
828    fn session_id_is_symmetric_for_addresses() {
829        let (low, high) = disagreeing_addresses();
830        let (a, b) = (low.to_string(), high.to_string());
831
832        assert_eq!(
833            GroupId::for_session(&a, &b).unwrap(),
834            GroupId::for_session(&b, &a).unwrap(),
835            "both peers must derive the same slot regardless of argument order"
836        );
837    }
838
839    /// A pair that is not two addresses has no hash order to use, so it keeps
840    /// the string ordering it has always had.
841    #[test]
842    fn session_id_falls_back_to_string_order_for_non_addresses() {
843        assert_eq!(
844            GroupId::for_session("bob", "alice").unwrap().as_str(),
845            "session:alice:bob"
846        );
847
848        let (address, _) = disagreeing_addresses();
849        let mixed = GroupId::for_session("alice", &address.to_string()).unwrap();
850        let mixed_reversed = GroupId::for_session(&address.to_string(), "alice").unwrap();
851        assert_eq!(mixed, mixed_reversed, "mixed pairs must stay symmetric");
852    }
853
854    fn sample_encrypted_message() -> EncryptedMessage {
855        EncryptedMessage {
856            group_id: GroupId::for_session("alice", "bob").unwrap(),
857            message_type: MlsMessageType::Application,
858            epoch: 42,
859            ciphertext: vec![0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01],
860            sender_id: "alice".to_string(),
861            timestamp_ms: 1_700_000_000_123,
862        }
863    }
864
865    #[test]
866    fn test_encrypted_message_bytes_roundtrip() {
867        let msg = sample_encrypted_message();
868        let bytes = msg.to_bytes();
869        let decoded = EncryptedMessage::from_bytes(&bytes).unwrap();
870
871        assert_eq!(decoded.group_id, msg.group_id);
872        assert_eq!(decoded.message_type, msg.message_type);
873        assert_eq!(decoded.epoch, msg.epoch);
874        assert_eq!(decoded.ciphertext, msg.ciphertext);
875        assert_eq!(decoded.sender_id, msg.sender_id);
876        assert_eq!(decoded.timestamp_ms, msg.timestamp_ms);
877    }
878
879    #[test]
880    fn test_encrypted_message_bytes_roundtrip_empty_ciphertext() {
881        let mut msg = sample_encrypted_message();
882        msg.ciphertext = Vec::new();
883        let decoded = EncryptedMessage::from_bytes(&msg.to_bytes()).unwrap();
884        assert!(decoded.ciphertext.is_empty());
885    }
886
887    #[test]
888    fn test_encrypted_message_from_bytes_truncated() {
889        let bytes = sample_encrypted_message().to_bytes();
890        // Every strict prefix must fail cleanly, never panic.
891        for len in 0..bytes.len() {
892            assert!(
893                EncryptedMessage::from_bytes(&bytes[..len]).is_err(),
894                "truncation at {} unexpectedly succeeded",
895                len
896            );
897        }
898    }
899
900    #[test]
901    fn test_encrypted_message_from_bytes_oversized_group_id_rejected() {
902        // Claim a group_id length far beyond the cap without supplying data.
903        let mut bytes = Vec::new();
904        bytes.extend_from_slice(&(u32::MAX).to_le_bytes());
905        let err = EncryptedMessage::from_bytes(&bytes).unwrap_err();
906        assert!(err.to_string().contains("group_id_len"));
907    }
908
909    #[test]
910    fn test_encrypted_message_from_bytes_oversized_ciphertext_rejected() {
911        let mut msg = sample_encrypted_message();
912        msg.ciphertext = vec![0u8; 4];
913        let mut bytes = msg.to_bytes();
914        // Overwrite the ciphertext length prefix (last 4 + 4 bytes from the end).
915        let ct_len_offset = bytes.len() - 4 - 4;
916        bytes[ct_len_offset..ct_len_offset + 4].copy_from_slice(&(u32::MAX).to_le_bytes());
917        let err = EncryptedMessage::from_bytes(&bytes).unwrap_err();
918        assert!(err.to_string().contains("ciphertext_len"));
919    }
920
921    #[test]
922    fn test_encrypted_message_from_bytes_unknown_message_type_rejected() {
923        let msg = sample_encrypted_message();
924        let mut bytes = msg.to_bytes();
925        let tag_offset = 4 + msg.group_id.as_str().len() + 4 + msg.sender_id.len();
926        bytes[tag_offset] = 0xFF;
927        let err = EncryptedMessage::from_bytes(&bytes).unwrap_err();
928        assert!(err.to_string().contains("message_type"));
929    }
930
931    #[test]
932    fn test_group_id_accepts_legitimate_formats() {
933        assert!(GroupId::new("group:0bd6e5f2-3a70-4a4e-9c3f-1c1f2a3b4c5d").is_ok());
934        assert!(GroupId::new("session:alice:bob").is_ok());
935        assert!(GroupId::new("plain-segment_1.x@y").is_ok());
936    }
937
938    #[test]
939    fn test_group_id_rejects_storage_hostile_chars() {
940        assert!(GroupId::new("").is_err());
941        assert!(GroupId::new("group/evil").is_err());
942        assert!(GroupId::new("group\\evil").is_err());
943        assert!(GroupId::new("group\0evil").is_err());
944        assert!(GroupId::new("group\nevil").is_err());
945        assert!(GroupId::new("group\x7Fevil").is_err()); // DEL
946        assert!(GroupId::new(".").is_err());
947        assert!(GroupId::new("..").is_err());
948        // Path traversal hidden inside a segment
949        assert!(GroupId::new("session:..:bob").is_err());
950        assert!(GroupId::new("group:../../etc").is_err());
951        // Empty segments (leading/trailing/doubled colons)
952        assert!(GroupId::new("session:").is_err());
953        assert!(GroupId::new(":session").is_err());
954        assert!(GroupId::new("a::b").is_err());
955    }
956
957    #[test]
958    fn test_group_id_length_cap() {
959        assert!(GroupId::new("a".repeat(GroupId::MAX_LEN)).is_ok());
960        assert!(GroupId::new("a".repeat(GroupId::MAX_LEN + 1)).is_err());
961    }
962
963    #[test]
964    fn test_group_id_deserialize_rejects_hostile() {
965        assert!(serde_json::from_str::<GroupId>(r#""evil/path""#).is_err());
966        assert!(serde_json::from_str::<GroupId>(r#""..""#).is_err());
967        assert!(serde_json::from_str::<GroupId>(r#""session:alice:bob""#).is_ok());
968    }
969
970    #[test]
971    fn test_encrypted_message_from_bytes_rejects_hostile_group_id() {
972        // Hand-craft wire bytes: the typed constructor can no longer produce
973        // an invalid group id, but a malicious peer can still send one.
974        let mut bytes = Vec::new();
975        let hostile = b"evil/../path";
976        bytes.extend_from_slice(&(hostile.len() as u32).to_le_bytes());
977        bytes.extend_from_slice(hostile);
978        let sender = b"alice";
979        bytes.extend_from_slice(&(sender.len() as u32).to_le_bytes());
980        bytes.extend_from_slice(sender);
981        bytes.push(0); // Application
982        bytes.extend_from_slice(&42u64.to_le_bytes()); // epoch
983        bytes.extend_from_slice(&1u64.to_le_bytes()); // timestamp_ms
984        bytes.extend_from_slice(&0u32.to_le_bytes()); // empty ciphertext
985        let err = EncryptedMessage::from_bytes(&bytes).unwrap_err();
986        assert!(matches!(err, crate::MlsError::InvalidGroupId(_)));
987    }
988
989    #[test]
990    fn test_mls_message_type_u8_roundtrip() {
991        for msg_type in [
992            MlsMessageType::Application,
993            MlsMessageType::Welcome,
994            MlsMessageType::Commit,
995            MlsMessageType::Proposal,
996        ] {
997            assert_eq!(MlsMessageType::from_u8(msg_type.as_u8()), Some(msg_type));
998        }
999        assert_eq!(MlsMessageType::from_u8(4), None);
1000    }
1001}