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}