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/// The sealed-envelope types, re-exported so that every path into them keeps
8/// working and there is still exactly one definition of each.
9///
10/// They live in `offline-protocol-sealed` because a leaf node encodes and
11/// decodes the same envelope this crate does, and cannot link this crate:
12/// this one is built on OpenMLS, which needs `std`. See ADR 0022.
13pub use offline_protocol_sealed::{EncryptedMessage, GroupId, MlsMessageType, WelcomeMessage};
14
15/// Role a user holds within a group.
16#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum GroupRole {
19    /// Group administrator — can invite/remove members and change roles.
20    Admin,
21    /// Regular group member (also the fallback for unknown future variants).
22    #[default]
23    #[serde(other)]
24    Member,
25}
26
27impl fmt::Display for GroupRole {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        match self {
30            Self::Admin => write!(f, "admin"),
31            Self::Member => write!(f, "member"),
32        }
33    }
34}
35
36/// Error returned when parsing a [`GroupRole`] from a string.
37#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
38#[error("Invalid role '{0}', must be 'admin' or 'member'")]
39pub struct ParseGroupRoleError(pub String);
40
41impl FromStr for GroupRole {
42    type Err = ParseGroupRoleError;
43
44    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
45        match s.to_ascii_lowercase().as_str() {
46            "admin" => Ok(Self::Admin),
47            "member" => Ok(Self::Member),
48            _ => Err(ParseGroupRoleError(s.to_string())),
49        }
50    }
51}
52
53/// A bundle containing a key package and metadata for distribution.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct KeyPackageBundle {
56    /// Unique identifier for this key package.
57    pub package_id: String,
58
59    /// User ID this key package belongs to.
60    pub user_id: String,
61
62    /// Serialized MLS KeyPackage bytes.
63    pub key_package_data: Vec<u8>,
64
65    /// Timestamp when the key package was created (milliseconds since epoch).
66    pub created_at_ms: u64,
67
68    /// Timestamp when the key package expires (milliseconds since epoch).
69    pub expires_at_ms: u64,
70
71    /// Whether this key package has been uploaded to a server.
72    pub synced: bool,
73
74    /// Whether this package is reserved for a publication slot.
75    ///
76    /// An MLS key package's init key is consumed by the first peer who uses it,
77    /// so a package standing in a published record must not also be handed to a
78    /// peer over the push path: the second user of it builds a Welcome that can
79    /// never be processed. Reserved packages are therefore invisible to both
80    /// entry points that hand packages out —
81    /// [`MlsManager::take_push_key_package`](crate::MlsManager::take_push_key_package)
82    /// (the push path) and
83    /// [`MlsManager::get_or_create_key_package`](crate::MlsManager::get_or_create_key_package)
84    /// (the peer-less one).
85    ///
86    /// Additive and defaulted, so packages written before this field existed
87    /// deserialize as unreserved — which is what they are.
88    #[serde(default)]
89    pub reserved_for_publication: bool,
90
91    /// The peer this package has been handed to over the push path, if any.
92    ///
93    /// This is what keeps the push path from advertising one init key to
94    /// everybody: a package is claimed by the first peer it is pushed to and
95    /// afterwards only ever re-handed to *that* peer, so a repeat push costs no
96    /// new key material while two peers never share one. RFC 9420 §16.8 asks
97    /// for a key package to be rotated as soon as possible after use; the
98    /// rotation trigger is consumption, which
99    /// [`MlsManager::key_package_by_id`](crate::MlsManager::key_package_by_id)
100    /// already reports, and a consumed package's successor is minted on the
101    /// next push to that peer.
102    ///
103    /// `None` means unclaimed — either minted outside the push path (the
104    /// peer-less FFI entry point) or written before this field existed. Those
105    /// are claimable, so upgrading does not strand the package already in
106    /// storage.
107    ///
108    /// Local bookkeeping only: never serialized onto the wire, and never part
109    /// of the FFI record.
110    #[serde(default)]
111    pub assigned_peer: Option<String>,
112
113    /// The TLS-serialized OpenMLS hash reference this package's private init
114    /// key is stored under in the provider, stamped at mint time.
115    ///
116    /// A cache: deriving the reference from `key_package_data` costs a TLS
117    /// parse plus a signature validation, and the push path's pool scan checks
118    /// every stored package's usability on every push — without this it pays
119    /// that per package per push. Records written before the field existed
120    /// deserialize as `None` and are backfilled on their first load.
121    ///
122    /// Local bookkeeping only, like [`Self::assigned_peer`]: never on the
123    /// wire, never in the FFI record.
124    #[serde(default)]
125    pub provider_hash_ref: Option<Vec<u8>>,
126}
127
128impl KeyPackageBundle {
129    /// Creates a new key package bundle.
130    pub fn new(
131        package_id: String,
132        user_id: String,
133        key_package_data: Vec<u8>,
134        lifetime_secs: u64,
135    ) -> Self {
136        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
137        Self {
138            package_id,
139            user_id,
140            key_package_data,
141            created_at_ms: now_ms,
142            expires_at_ms: now_ms + (lifetime_secs * 1000),
143            synced: false,
144            reserved_for_publication: false,
145            assigned_peer: None,
146            provider_hash_ref: None,
147        }
148    }
149
150    /// Checks if the key package has expired (local device's own packages only).
151    ///
152    /// This compares against the local clock and is valid because `created_at_ms`
153    /// and `expires_at_ms` were set on this same device.
154    pub fn is_expired(&self) -> bool {
155        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
156        now_ms >= self.expires_at_ms
157    }
158
159    /// Whether the package expired more than `grace_secs` ago.
160    ///
161    /// Expiry and the destruction of the private init key are deliberately two
162    /// different moments. An expired package is no longer advertised, but a peer
163    /// holding a copy handed out just before expiry may still Welcome us — and
164    /// that Welcome is only processable while the init key is in provider
165    /// storage. The grace window is how long that stays true; past it the key
166    /// material is destroyed rather than left resident for the life of the
167    /// install.
168    pub fn expired_past_grace(&self, grace_secs: u64) -> bool {
169        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
170        now_ms
171            >= self
172                .expires_at_ms
173                .saturating_add(grace_secs.saturating_mul(1000))
174    }
175
176    /// Returns the remaining valid lifetime in milliseconds.
177    ///
178    /// Used when transmitting key packages to peers: the receiver applies
179    /// this duration relative to their own clock, eliminating cross-device
180    /// clock skew from expiry calculations.
181    pub fn remaining_lifetime_ms(&self) -> u64 {
182        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
183        self.expires_at_ms.saturating_sub(now_ms)
184    }
185
186    /// Creates a bundle from received transfer data, computing local expiry
187    /// from the sender-provided remaining lifetime.
188    pub fn from_transfer(
189        package_id: String,
190        user_id: String,
191        key_package_data: Vec<u8>,
192        remaining_lifetime_ms: u64,
193    ) -> Self {
194        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
195        Self {
196            package_id,
197            user_id,
198            key_package_data,
199            created_at_ms: now_ms,
200            expires_at_ms: now_ms.saturating_add(remaining_lifetime_ms),
201            synced: false,
202            // A peer's package, never one of our publication slots.
203            reserved_for_publication: false,
204            // Nor one of ours to hand out — the push-path assignment is about
205            // packages this device minted.
206            assigned_peer: None,
207            // Provider refs describe our own private material; a peer's
208            // package has none here.
209            provider_hash_ref: None,
210        }
211    }
212}
213
214/// Information about an MLS group.
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct GroupInfo {
217    /// Unique group identifier.
218    pub group_id: GroupId,
219
220    /// Human-readable group name (for multi-party groups).
221    pub name: Option<String>,
222
223    /// List of member user IDs.
224    pub members: Vec<String>,
225
226    /// Count of members in the group.
227    pub members_count: u32,
228
229    /// Current epoch number.
230    pub epoch: u64,
231
232    /// Whether this is a 1:1 session (2-person group).
233    pub is_session: bool,
234
235    /// Timestamp when the group was created.
236    pub created_at_ms: u64,
237
238    /// Timestamp of the last activity.
239    pub last_activity_ms: u64,
240
241    /// How many leaves the roster read *skipped* because they do not carry the
242    /// address their own signature key derives to.
243    ///
244    /// Normally zero: the Welcome and commit gates refuse such a leaf before it
245    /// can enter local group state, so a non-zero count means the state was
246    /// written behind the SDK's back — a direct write to the install-scoped
247    /// provider store, or a group joined by a build predating those gates.
248    ///
249    /// A count, not the identities. The claimed credentials are attacker-chosen
250    /// strings, and the whole reason [`crate::group::GroupManager::get_group_info`]
251    /// filters them out is that the roster must not carry an identity nobody
252    /// proved; handing the same string back through a second field would return
253    /// it by another door. It is also not what an app should act on: the leaf
254    /// holds live group secrets and reads everything sent to the group, so the
255    /// remedy is to abandon the group, not to evict one member of it.
256    ///
257    /// Additive with `#[serde(default)]` so records written before this field
258    /// existed still load.
259    #[serde(default)]
260    pub unproven_members: u32,
261}
262
263/// Metadata about an MLS group (stored separately from MLS state).
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct GroupMetadata {
266    /// Human-readable group name.
267    pub name: Option<String>,
268
269    /// Timestamp when the group was created (milliseconds since epoch).
270    pub created_at_ms: u64,
271
272    /// Timestamp of the last activity (milliseconds since epoch).
273    pub last_activity_ms: u64,
274
275    /// Custom application-specific metadata.
276    #[serde(default)]
277    pub custom: std::collections::HashMap<String, String>,
278
279    /// User ID of the group creator.
280    #[serde(default)]
281    pub created_by: Option<String>,
282
283    /// Per-member role assignments.
284    /// Deserialization falls back to migrating legacy `"role:*"` keys from `custom`.
285    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
286    pub roles: std::collections::HashMap<String, GroupRole>,
287}
288
289impl GroupMetadata {
290    /// Prefix previously used for role entries in the `custom` map (legacy).
291    pub const LEGACY_ROLE_KEY_PREFIX: &'static str = "role:";
292
293    /// Creates new group metadata with the given name and creator.
294    pub fn new(name: Option<String>) -> Self {
295        let now_ms = chrono::Utc::now().timestamp_millis() as u64;
296        Self {
297            name,
298            created_at_ms: now_ms,
299            last_activity_ms: now_ms,
300            custom: std::collections::HashMap::new(),
301            created_by: None,
302            roles: std::collections::HashMap::new(),
303        }
304    }
305
306    /// Creates new group metadata with a creator.
307    pub fn new_with_creator(name: Option<String>, creator_id: &str) -> Self {
308        let mut meta = Self::new(name);
309        meta.created_by = Some(creator_id.to_string());
310        meta.set_role(creator_id, GroupRole::Admin);
311        meta
312    }
313
314    /// Migrates any legacy `"role:*"` keys from the `custom` map into the
315    /// dedicated `roles` field. Call after deserialization for
316    /// backwards-compatibility with older group metadata.
317    pub fn migrate_legacy_roles(&mut self) {
318        let legacy_keys: Vec<String> = self
319            .custom
320            .keys()
321            .filter(|k| k.starts_with(Self::LEGACY_ROLE_KEY_PREFIX))
322            .cloned()
323            .collect();
324        for key in legacy_keys {
325            if let Some(uid) = key.strip_prefix(Self::LEGACY_ROLE_KEY_PREFIX) {
326                if !self.roles.contains_key(uid) {
327                    let role = self
328                        .custom
329                        .get(&key)
330                        .and_then(|v| v.parse().ok())
331                        .unwrap_or_default();
332                    self.roles.insert(uid.to_string(), role);
333                }
334            }
335            self.custom.remove(&key);
336        }
337    }
338
339    /// Updates the last activity timestamp to now.
340    pub fn touch(&mut self) {
341        self.last_activity_ms = chrono::Utc::now().timestamp_millis() as u64;
342    }
343
344    /// Gets the role for a user, defaulting to [`GroupRole::Member`] if not set.
345    pub fn get_role(&self, user_id: &str) -> GroupRole {
346        self.roles.get(user_id).copied().unwrap_or_default()
347    }
348
349    /// Sets the role for a user.
350    pub fn set_role(&mut self, user_id: &str, role: GroupRole) {
351        self.roles.insert(user_id.to_string(), role);
352    }
353
354    /// Removes role metadata for a user (on removal from group).
355    pub fn remove_role(&mut self, user_id: &str) {
356        self.roles.remove(user_id);
357    }
358
359    /// Returns true if any admin role is stored in this metadata.
360    pub fn has_any_admin(&self) -> bool {
361        self.roles.values().any(|r| *r == GroupRole::Admin)
362    }
363
364    /// Returns all `user_id -> role` mappings.
365    pub fn get_all_roles(&self) -> std::collections::HashMap<String, GroupRole> {
366        self.roles.clone()
367    }
368}
369
370/// Storage key types for organizing MLS data.
371#[derive(Debug, Clone, Copy, PartialEq, Eq)]
372pub enum StorageKeyType {
373    /// Identity/signature key pair.
374    Identity,
375
376    /// Key packages for receiving Welcome messages.
377    KeyPackage,
378
379    /// MLS group state.
380    GroupState,
381
382    /// Epoch secrets for a group.
383    EpochSecrets,
384
385    /// Credential data.
386    Credential,
387
388    /// Contact's key packages.
389    ContactKeyPackage,
390
391    /// Group metadata (name, timestamps).
392    GroupMetadata,
393
394    /// Pending welcome messages awaiting delivery.
395    PendingWelcome,
396}
397
398impl StorageKeyType {
399    /// Returns the string representation for storage.
400    pub fn as_str(&self) -> &'static str {
401        match self {
402            Self::Identity => "identity",
403            Self::KeyPackage => "key_package",
404            Self::GroupState => "group_state",
405            Self::EpochSecrets => "epoch_secrets",
406            Self::Credential => "credential",
407            Self::ContactKeyPackage => "contact_key_package",
408            Self::GroupMetadata => "group_metadata",
409            Self::PendingWelcome => "pending_welcome",
410        }
411    }
412}
413
414impl std::fmt::Display for StorageKeyType {
415    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
416        write!(f, "{}", self.as_str())
417    }
418}