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}