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