ping_core/conversation.rs
1//! Conversation state — wraps an OpenMLS `MlsGroup`.
2//!
3//! Each external conversation maps 1:1 to an MLS group whose leaves are devices. The DeviceGroup
4//! (one per user, devices only) is just a special-cased conversation with the same wrapper.
5//!
6//! Persistence: we snapshot the `MlsGroup` after every state-changing operation under
7//! `groups/{conversation_id}` and cache the result in-memory.
8
9use openmls::{
10 framing::{MlsMessageOut, ProcessedMessageContent},
11 group::{GroupContext, MlsGroup, MlsGroupCreateConfig, MlsGroupJoinConfig},
12 prelude::{
13 tls_codec::{Deserialize as TlsDeserialize, Serialize as TlsSerialize},
14 BasicCredential, Capabilities, Ciphersuite, CredentialWithKey, Extension, ExtensionType,
15 Extensions, MlsMessageBodyIn, MlsMessageIn, ProcessedMessage, ProtocolMessage,
16 ProtocolVersion, RequiredCapabilitiesExtension, UnknownExtension,
17 },
18};
19use openmls_basic_credential::SignatureKeyPair;
20use openmls_traits::OpenMlsProvider;
21use ping_mls_store::PersistentMlsProvider;
22use serde::{Deserialize, Serialize};
23use std::collections::BTreeMap;
24use std::sync::Arc;
25use ulid::Ulid;
26use zeroize::Zeroizing;
27
28use crate::{
29 clock::Hlc,
30 codec,
31 device::{DeviceId, GroupSnapshotEntry, GroupStateSnapshot, GROUP_SNAPSHOT_VERSION},
32 error::{Error, Result},
33 identity::UserId,
34 message::{IncomingMessage, MessageEnvelope, MessageKind},
35 storage::Storage,
36 sync::SyncCursor,
37};
38
39const DEFAULT_CIPHERSUITE: Ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
40
41/// Map an OpenMLS `WelcomeError` to a typed [`Error`]. The two variants that
42/// mean "the KeyPackage this Welcome was bound to (or its private init key) is
43/// no longer in our keystore" — i.e. the KeyPackage was already consumed
44/// (single-use reuse) or never persisted — become [`Error::KeyPackageNotFound`]
45/// so the host can top up KeyPackages and retry rather than treating it as an
46/// opaque MLS failure. Everything else stays a generic MLS error.
47fn map_welcome_error<S: std::fmt::Display>(e: openmls::group::WelcomeError<S>) -> Error {
48 use openmls::group::WelcomeError;
49 match e {
50 WelcomeError::NoMatchingKeyPackage | WelcomeError::PrivateInitKeyNotFound => {
51 Error::KeyPackageNotFound
52 }
53 other => Error::mls(other),
54 }
55}
56
57/// MLS GroupContext extension type carrying the human conversation `name`.
58///
59/// Stored in the group context so the name is part of shared MLS group state and
60/// therefore present on EVERY device that holds the group — including a device
61/// that joins via a Welcome. Without this the name was creator-local and a
62/// joiner/linked device saw `None`. A private-use `Unknown` extension type
63/// (not a GREASE `0x?A?A` value, not a registered type 0x0001–0x0005).
64///
65/// It is added WITHOUT a `RequiredCapabilities` extension, so openmls imposes no
66/// per-member capability check — existing KeyPackages keep working and no
67/// re-link is required. The value is UTF-8 bytes of the name.
68const GROUP_NAME_EXTENSION_TYPE: u16 = 0xFF00;
69
70/// Read the conversation `name` from a group's GroupContext extensions, if set.
71fn group_name_from_extensions(extensions: &Extensions<GroupContext>) -> Option<String> {
72 extensions.iter().find_map(|ext| match ext {
73 Extension::Unknown(ext_type, data) if *ext_type == GROUP_NAME_EXTENSION_TYPE => {
74 String::from_utf8(data.0.clone())
75 .ok()
76 .filter(|s| !s.is_empty())
77 }
78 _ => None,
79 })
80}
81
82/// Build the GroupContext extensions carrying `name` (empty when `name` is
83/// `None`/blank), for `MlsGroupCreateConfig::with_group_context_extensions`.
84fn group_context_extensions_for_name(name: Option<&str>) -> Extensions<GroupContext> {
85 match name {
86 // 0.8 made `single` validating + fallible. An Unknown extension is
87 // always valid in a GroupContext, so this cannot fail for our input.
88 Some(n) if !n.is_empty() => Extensions::single(Extension::Unknown(
89 GROUP_NAME_EXTENSION_TYPE,
90 UnknownExtension(n.as_bytes().to_vec()),
91 ))
92 .expect("an Unknown extension is valid in a GroupContext"),
93 _ => Extensions::empty(),
94 }
95}
96
97/// Leaf [`Capabilities`] advertising support for the group-name GroupContext
98/// extension ([`GROUP_NAME_EXTENSION_TYPE`]) ON TOP OF the MLS defaults.
99///
100/// Required so a later [`Conversation::set_name`] (rename / avatar-id change via
101/// `update_group_context_extensions`) passes openmls' GCE-proposal validation:
102/// that path demands every group-context extension be listed in a
103/// `RequiredCapabilities`, and that every member's leaf advertise those
104/// extension types. The CREATE path does not need this (genesis extensions skip
105/// the proposal validator), but a post-create UPDATE does. New KeyPackages and
106/// the creator's own leaf carry this; existing devices must RE-LINK once to pick
107/// it up (a pre-production-acceptable cost, aligned with the shared-identity
108/// re-link).
109pub(crate) fn ping_leaf_capabilities() -> Capabilities {
110 ping_leaf_capabilities_for(false)
111}
112
113/// Leaf capabilities, optionally also advertising the `LastResort` extension.
114///
115/// A KeyPackage marked last-resort (`mark_as_last_resort`) carries the
116/// `LastResort` extension on its leaf; openmls' KeyPackage validation rejects
117/// any leaf whose capabilities do not advertise every extension the leaf
118/// actually carries ("A key package extension is not supported in the leaf's
119/// capabilities"). So last-resort KeyPackages MUST list `LastResort` here, while
120/// ordinary KeyPackages must NOT (they don't carry it).
121pub(crate) fn ping_leaf_capabilities_for(last_resort: bool) -> Capabilities {
122 let extensions = if last_resort {
123 vec![
124 ExtensionType::Unknown(GROUP_NAME_EXTENSION_TYPE),
125 ExtensionType::LastResort,
126 ]
127 } else {
128 vec![ExtensionType::Unknown(GROUP_NAME_EXTENSION_TYPE)]
129 };
130 Capabilities::new(None, None, Some(&extensions), None, None)
131}
132
133/// GroupContext extensions for a NAME UPDATE commit (`set_name`), as opposed to
134/// genesis ([`group_context_extensions_for_name`]). The GCE-proposal validator
135/// requires every group-context extension to appear in a `RequiredCapabilities`,
136/// so we attach one listing [`GROUP_NAME_EXTENSION_TYPE`] alongside the name
137/// extension. Clearing the name (`None`/blank) keeps the `RequiredCapabilities`
138/// (harmless: required caps may list a type that isn't currently present).
139fn group_context_extensions_for_name_update(name: Option<&str>) -> Extensions<GroupContext> {
140 let required = Extension::RequiredCapabilities(RequiredCapabilitiesExtension::new(
141 &[ExtensionType::Unknown(GROUP_NAME_EXTENSION_TYPE)],
142 &[],
143 &[],
144 ));
145 match name {
146 Some(n) if !n.is_empty() => Extensions::from_vec(vec![
147 Extension::Unknown(
148 GROUP_NAME_EXTENSION_TYPE,
149 UnknownExtension(n.as_bytes().to_vec()),
150 ),
151 required,
152 ])
153 // Two distinct extension types — `from_vec` only rejects duplicates.
154 .expect("name + required-capabilities are distinct extension types"),
155 _ => {
156 Extensions::single(required).expect("required-capabilities is valid in a GroupContext")
157 }
158 }
159}
160
161/// 16-byte conversation identifier (ULID encoded). Stable across epochs.
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
163pub struct ConversationId(#[serde(with = "serde_bytes_array16")] pub [u8; 16]);
164
165impl ConversationId {
166 pub fn new() -> Self {
167 Self(Ulid::new().to_bytes())
168 }
169 /// Mint a per-call-group id: a random ULID with the `0xFF 0xCC` sentinel
170 /// prefix, so ANY device recognises it as an ephemeral call group by its id
171 /// alone — no name (the `call:` name is creator-local and never travels in a
172 /// Welcome) and no per-device registry needed. This is the exact parallel of
173 /// the DeviceGroup sentinel `0xFF 0xDC` (see `device_group_id_for`). A normal
174 /// ULID begins with the high byte of a 48-bit millisecond timestamp (`0x01`
175 /// in this era), so it can never collide with the `0xFF` sentinel space.
176 pub fn new_call_group() -> Self {
177 let mut bytes = Ulid::new().to_bytes();
178 bytes[0] = 0xFF;
179 bytes[1] = 0xCC; // "Callgroup" sentinel (cf. 0xFF 0xDC DeviceGroup)
180 Self(bytes)
181 }
182 /// True when this id carries the call-group sentinel prefix (`0xFF 0xCC`).
183 /// Structural — recognisable on every device without a name or registry.
184 pub fn is_call_group(&self) -> bool {
185 self.0[0] == 0xFF && self.0[1] == 0xCC
186 }
187 pub fn as_hex(&self) -> String {
188 hex::encode(self.0)
189 }
190}
191
192impl Default for ConversationId {
193 fn default() -> Self {
194 Self::new()
195 }
196}
197
198mod serde_bytes_array16 {
199 use serde::{Deserializer, Serializer};
200 pub fn serialize<S: Serializer>(b: &[u8; 16], s: S) -> Result<S::Ok, S::Error> {
201 serde_bytes::serialize(b.as_slice(), s)
202 }
203 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 16], D::Error> {
204 let v: Vec<u8> = serde_bytes::deserialize(d)?;
205 v.try_into()
206 .map_err(|_| serde::de::Error::custom("expected 16 bytes"))
207 }
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct ConversationMeta {
212 pub id: ConversationId,
213 pub name: Option<String>,
214 pub epoch: u64,
215 pub member_count: u32,
216 pub is_device_group: bool,
217 pub created_at_ms: u64,
218}
219
220/// One member leaf of a conversation's MLS group: the member's [`UserId`]
221/// (recovered from the leaf's `BasicCredential`) and its ratchet-tree leaf
222/// index. A user with multiple devices appears once **per device leaf** —
223/// callers that want a per-user roster should dedup by `user_id`.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct MemberInfo {
226 pub user_id: UserId,
227 /// SHA-256 of this member's leaf signature public key — identical to the
228 /// value `Client::device_id()` reports for that device. Lets the host tell
229 /// WHICH device owns each leaf, e.g. to skip re-admitting a device that is
230 /// already a member (a plain Add of a duplicate signature key is rejected by
231 /// RFC 9420 leaf validation, so a recovery/re-admit pass must detect it).
232 pub device_id: DeviceId,
233 pub leaf_index: u32,
234}
235
236/// In-memory conversation handle. Holds the OpenMLS group plus our wire-level cursor.
237pub struct Conversation {
238 pub(crate) id: ConversationId,
239 pub(crate) meta: ConversationMeta,
240 pub(crate) group: MlsGroup,
241 pub(crate) crypto: Arc<PersistentMlsProvider>,
242 pub(crate) signing: Arc<SignatureKeyPair>,
243 pub(crate) own_device: DeviceId,
244 pub(crate) seq: u64,
245 pub(crate) hlc: Hlc,
246 pub(crate) cursor: SyncCursor,
247 pub(crate) storage: Arc<dyn Storage>,
248 /// Local device→leaf-index map for [CR-2] revocation.
249 ///
250 /// Populated when this device either (a) admits a peer via [`Self::add_members`] —
251 /// every entry in the `Vec<(DeviceId, KeyPackage)>` is recorded after the commit
252 /// merges — or (b) joins as the receiving device via [`Self::join`], at which point
253 /// we record our own leaf. Pruned when [`Self::remove_members`] is called.
254 ///
255 /// Not authoritative for *peers' devices we didn't admit*: those are visible in
256 /// `group.members()` but their device_ids are opaque to this client. `revoke_device`
257 /// is therefore best-effort across conversations we ourselves invited the device
258 /// into; see [`MessagingClient::revoke_device`] for the documented scope.
259 pub(crate) device_leaves: BTreeMap<DeviceId, u32>,
260}
261
262impl std::fmt::Debug for Conversation {
263 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264 f.debug_struct("Conversation")
265 .field("id", &self.id.as_hex())
266 .field("meta", &self.meta)
267 .finish()
268 }
269}
270
271impl Conversation {
272 pub fn id(&self) -> ConversationId {
273 self.id
274 }
275 pub fn meta(&self) -> &ConversationMeta {
276 &self.meta
277 }
278
279 /// Current member roster, recovered locally from the MLS group's leaf
280 /// credentials — no network and no out-of-band `ping.profile` message.
281 /// Each `BasicCredential` was built from the member's `UserId`
282 /// (`BasicCredential::new(own_user.0.clone())` in [`Self::create`] /
283 /// [`Self::join`]), so we round-trip it back here. Each entry is one
284 /// leaf; a multi-device user appears once per device leaf.
285 pub fn members(&self) -> Vec<MemberInfo> {
286 self.group
287 .members()
288 .filter_map(|m| {
289 // Derive the device id the same way `Client::device_id()` does —
290 // SHA-256 of the (signature) public key — so the host can match a
291 // member against a known device id.
292 let device_id = DeviceId(codec::sha256(m.signature_key.as_slice()).to_vec());
293 let basic = BasicCredential::try_from(m.credential).ok()?;
294 Some(MemberInfo {
295 user_id: UserId(basic.identity().to_vec()),
296 device_id,
297 leaf_index: m.index.u32(),
298 })
299 })
300 .collect()
301 }
302
303 /// Leaf indexes of current members whose MLS signature key EQUALS the
304 /// incoming KeyPackage's — the "phrase-restore" duplicate: a device restored
305 /// from a recovery phrase keeps the SAME long-term signing key, so its dead
306 /// pre-wipe leaf (logout never emits an MLS Remove) still carries that key,
307 /// and RFC 9420 leaf validation would reject an Add that duplicates it. The
308 /// caller ([`Client::re_admit_device`]) evicts these BEFORE the Add.
309 ///
310 /// Read-only. The KeyPackage is validated exactly as [`Self::stage_add_members`]
311 /// validates it (never trust an unvalidated `KeyPackageIn`). Comparing the
312 /// PUBLIC leaf signature keys with `==` is the same content comparison
313 /// [`Self::confirm_staged`] already performs to resolve added leaves — not a
314 /// secret comparison. A healthy SIBLING device has a different signing key, so
315 /// it can never match here and is never evicted.
316 pub(crate) fn duplicate_signature_key_leaves(
317 &self,
318 key_package_bytes: &[u8],
319 ) -> Result<Vec<u32>> {
320 let mls_in = MlsMessageIn::tls_deserialize_exact(key_package_bytes).map_err(Error::mls)?;
321 let kp_in = match mls_in.extract() {
322 MlsMessageBodyIn::KeyPackage(kp) => kp,
323 _ => return Err(Error::Invalid("expected KeyPackage".into())),
324 };
325 let kp = kp_in
326 .validate(self.crypto.crypto(), ProtocolVersion::default())
327 .map_err(Error::mls)?;
328 let new_sig = kp.leaf_node().signature_key().as_slice().to_vec();
329 Ok(self
330 .group
331 .members()
332 .filter(|m| m.signature_key.as_slice() == new_sig.as_slice())
333 .map(|m| m.index.u32())
334 .collect())
335 }
336
337 pub fn epoch(&self) -> u64 {
338 self.group.epoch().as_u64()
339 }
340
341 /// True while the local device is still a member. Becomes false once we
342 /// process a Commit that removes our own leaf — OpenMLS transitions the group
343 /// to `MlsGroupState::Inactive`. The client uses this to tear the group down
344 /// on self-removal so a later re-invite Welcome is a clean first-join.
345 pub(crate) fn is_active(&self) -> bool {
346 self.group.is_active()
347 }
348
349 /// Delete this group's OpenMLS state from persistent storage. Called when the
350 /// local device has been removed from the group, so a subsequent re-invite
351 /// Welcome re-joins from CLEAN storage. OpenMLS keys group state by
352 /// `group_id`; a lingering removed group would (a) make the conversation look
353 /// "already joined" so the host drops the re-invite Welcome, and (b) collide
354 /// with the fresh `StagedWelcome::into_group` on re-join.
355 pub(crate) fn delete_group_state(&mut self) -> Result<()> {
356 self.group
357 .delete(self.crypto.storage())
358 .map_err(|e| Error::Storage(e.to_string()))
359 }
360
361 pub fn cursor(&self) -> &SyncCursor {
362 &self.cursor
363 }
364
365 /// Create a new conversation, with `self` as the only initial member.
366 // 8 args is a lot, but they're all needed for an internal constructor and a builder
367 // would be over-engineered for v0.1.
368 #[allow(clippy::too_many_arguments)]
369 pub(crate) fn create(
370 id: ConversationId,
371 name: Option<String>,
372 own_device: DeviceId,
373 own_user: &UserId,
374 crypto: Arc<PersistentMlsProvider>,
375 signing: Arc<SignatureKeyPair>,
376 storage: Arc<dyn Storage>,
377 now_ms: u64,
378 ) -> Result<Self> {
379 let credential = BasicCredential::new(own_user.0.clone());
380 let credential_with_key = CredentialWithKey {
381 credential: credential.into(),
382 signature_key: signing.public().into(),
383 };
384 // Carry the conversation name in the GroupContext so it travels in the
385 // Welcome to every joiner (see `GROUP_NAME_EXTENSION_TYPE`). No
386 // RequiredCapabilities is added, so this imposes no member capability
387 // check and stays compatible with existing KeyPackages.
388 let cfg = MlsGroupCreateConfig::builder()
389 .ciphersuite(DEFAULT_CIPHERSUITE)
390 .use_ratchet_tree_extension(true)
391 // Advertise the group-name extension capability on the creator's leaf
392 // so a later `set_name` (rename / avatar change) passes the GCE
393 // capability check. See `ping_leaf_capabilities`.
394 .capabilities(ping_leaf_capabilities())
395 // 0.8 made this infallible; it returned `Result` in 0.6.
396 .with_group_context_extensions(group_context_extensions_for_name(name.as_deref()))
397 .build();
398 let group = MlsGroup::new_with_group_id(
399 crypto.as_ref(),
400 signing.as_ref(),
401 &cfg,
402 openmls::group::GroupId::from_slice(&id.0),
403 credential_with_key,
404 )
405 .map_err(Error::mls)?;
406
407 let meta = ConversationMeta {
408 id,
409 name,
410 epoch: 0,
411 member_count: 1,
412 is_device_group: false,
413 created_at_ms: now_ms,
414 };
415 // [CR-2] Group creator is always leaf 0; record so revoke_device can target it.
416 let mut device_leaves = BTreeMap::new();
417 device_leaves.insert(own_device.clone(), group.own_leaf_index().u32());
418 Ok(Self {
419 id,
420 meta,
421 group,
422 crypto,
423 signing,
424 own_device,
425 seq: 0,
426 hlc: Hlc::ZERO.tick(now_ms),
427 cursor: SyncCursor::default(),
428 storage,
429 device_leaves,
430 })
431 }
432
433 /// Join an existing conversation from a Welcome message.
434 ///
435 /// Maps the OpenMLS `WelcomeError` for a missing KeyPackage / init key to
436 /// the typed [`Error::KeyPackageNotFound`] (see `map_welcome_error`) so the
437 /// host can react (top up KeyPackages) instead of treating it as an opaque
438 /// MLS failure.
439 pub(crate) fn join(
440 welcome_bytes: &[u8],
441 own_device: DeviceId,
442 crypto: Arc<PersistentMlsProvider>,
443 signing: Arc<SignatureKeyPair>,
444 storage: Arc<dyn Storage>,
445 now_ms: u64,
446 ) -> Result<Self> {
447 let mls_in = MlsMessageIn::tls_deserialize_exact(welcome_bytes).map_err(Error::mls)?;
448 let welcome = match mls_in.extract() {
449 MlsMessageBodyIn::Welcome(w) => w,
450 _ => return Err(Error::Invalid("expected Welcome".into())),
451 };
452 let cfg = MlsGroupJoinConfig::builder()
453 .use_ratchet_tree_extension(true)
454 .build();
455 let staged =
456 openmls::group::StagedWelcome::new_from_welcome(crypto.as_ref(), &cfg, welcome, None)
457 .map_err(map_welcome_error)?;
458 let group = staged.into_group(crypto.as_ref()).map_err(Error::mls)?;
459
460 let id_bytes: [u8; 16] = group
461 .group_id()
462 .as_slice()
463 .try_into()
464 .map_err(|_| Error::Invalid("group id must be 16 bytes".into()))?;
465 let id = ConversationId(id_bytes);
466 // The creator stamped the name into the GroupContext, which is part of
467 // the shared group state the Welcome reconstructs — so a joining device
468 // recovers the real name here instead of starting with `None`.
469 let name = group_name_from_extensions(group.extensions());
470 let meta = ConversationMeta {
471 id,
472 name,
473 epoch: group.epoch().as_u64(),
474 member_count: group.members().count() as u32,
475 is_device_group: false,
476 created_at_ms: now_ms,
477 };
478
479 // Seed the cursor at the join epoch so subsequent fetches skip pre-join Commits
480 // (notably the Add commit that produced this Welcome — it lives in the conversation
481 // log at `epoch - 1`, which the joiner must not try to apply on top of its
482 // already-advanced group state).
483 let join_epoch = group.epoch().as_u64();
484 // [CR-2] Record our own (device_id → leaf_index) so the host can later revoke us
485 // via the standard `revoke_device` flow. `own_leaf_index()` is stable for the
486 // lifetime of this group membership.
487 let own_leaf = group.own_leaf_index().u32();
488 let mut device_leaves = BTreeMap::new();
489 device_leaves.insert(own_device.clone(), own_leaf);
490 Ok(Self {
491 id,
492 meta,
493 group,
494 crypto,
495 signing,
496 own_device,
497 seq: 0,
498 hlc: Hlc::ZERO.tick(now_ms),
499 cursor: SyncCursor {
500 epoch: join_epoch,
501 ..Default::default()
502 },
503 storage,
504 device_leaves,
505 })
506 }
507
508 /// The conversation name recovered from MLS GroupContext state (set at
509 /// creation, carried in the Welcome to every joiner). `None` if unnamed.
510 pub(crate) fn name_from_group_state(&self) -> Option<String> {
511 group_name_from_extensions(self.group.extensions())
512 }
513
514 /// [CR-4] Rehydrate a previously-persisted conversation on cold restart.
515 ///
516 /// Loads the OpenMLS group state via `MlsGroup::load` (which reads from the
517 /// provider's storage — populated by the SQLite-backed checkpoint on the
518 /// previous run). Pairs the loaded MLS state with the meta + cursor + device→leaf
519 /// map the host-side `Storage` trait kept for us. Returns `Ok(None)` if OpenMLS
520 /// finds no state for `id` — the host's `groups` namespace had a stale entry.
521 #[allow(clippy::too_many_arguments)]
522 pub(crate) fn load(
523 id: ConversationId,
524 meta: ConversationMeta,
525 cursor: SyncCursor,
526 device_leaves: BTreeMap<DeviceId, u32>,
527 own_device: DeviceId,
528 crypto: Arc<PersistentMlsProvider>,
529 signing: Arc<SignatureKeyPair>,
530 storage: Arc<dyn Storage>,
531 now_ms: u64,
532 ) -> Result<Option<Self>> {
533 use openmls::group::GroupId;
534 let group_id = GroupId::from_slice(&id.0);
535 let group = match MlsGroup::load(crypto.storage(), &group_id).map_err(Error::mls)? {
536 Some(g) => g,
537 None => return Ok(None),
538 };
539 // Restore the local outgoing-send counter from the persisted cursor. The cursor
540 // tracks the highest applied (epoch, sender, seq) for every device — including
541 // our own — so we can recover `self.seq` from `cursor.last_seq_per_device[own]`.
542 // Without this, the next `send_application()` re-uses an already-consumed seq
543 // and receivers silently dedupe (cursor.is_new returns false on their side).
544 let seq = cursor
545 .last_seq_per_device
546 .get(&own_device)
547 .copied()
548 .unwrap_or(0);
549 Ok(Some(Self {
550 id,
551 meta,
552 group,
553 crypto,
554 signing,
555 own_device,
556 seq,
557 hlc: Hlc::ZERO.tick(now_ms),
558 cursor,
559 storage,
560 device_leaves,
561 }))
562 }
563
564 /// Encrypt an application message and produce a wire envelope ready for transport.
565 ///
566 /// Uses the [CR-6] plaintext content_hash path: the envelope's `content_hash` is
567 /// `SHA-256(plaintext)`, not the MLS ciphertext. This is what makes rebase clean
568 /// and gives cross-binding hash parity.
569 pub fn send_application(&mut self, plaintext: &[u8], now_ms: u64) -> Result<MessageEnvelope> {
570 let out = self
571 .group
572 .create_message(self.crypto.as_ref(), self.signing.as_ref(), plaintext)
573 .map_err(Error::mls)?;
574
575 self.seq += 1;
576 self.hlc = self.hlc.tick(now_ms);
577 let bytes = out.tls_serialize_detached().map_err(Error::mls)?;
578 let env = MessageEnvelope::new_application(
579 self.id,
580 self.epoch(),
581 self.own_device.clone(),
582 self.seq,
583 self.hlc,
584 bytes,
585 plaintext,
586 );
587 // Advance the local cursor past our own send so a subsequent catch-up sync doesn't
588 // pull this envelope back to us (we've already applied it locally — re-processing
589 // would either fail or duplicate-deliver).
590 self.cursor.advance(
591 env.epoch,
592 self.own_device.clone(),
593 self.seq,
594 self.hlc,
595 now_ms,
596 );
597 Ok(env)
598 }
599
600 /// Add members by KeyPackage. Produces the Commit envelope to broadcast plus the Welcome
601 /// envelope(s) to deliver out-of-band to the newly-added devices.
602 ///
603 /// [CR-2] takes a `Vec<(DeviceId, KeyPackage)>` instead of a bare `Vec<KeyPackage>`. The
604 /// `DeviceId` for each entry is the *caller's* assertion of which device owns that
605 /// KeyPackage — hosts typically get it from the directory service alongside the
606 /// KeyPackage itself. The mapping is persisted per-conversation so [`MessagingClient::revoke_device`]
607 /// can later locate the leaf to remove without a fresh directory lookup. The SDK does
608 /// not cryptographically verify the device claim; that's a host policy concern
609 /// (typically: the directory authenticates the key_package_id → device_id mapping).
610 pub fn add_members(
611 &mut self,
612 entries: Vec<(DeviceId, Vec<u8>)>,
613 now_ms: u64,
614 ) -> Result<AddOutcome> {
615 // All-in-one (stage + immediate merge) for callers that commit and
616 // persist synchronously with NO networked rollback window — e.g. the
617 // device-group / device-linking paths. The networked group path in
618 // `client.rs` instead uses `stage_add_members` + `confirm_staged` /
619 // `abort_staged`, so a Commit the server REJECTS can be rolled back
620 // rather than leaving the local epoch ahead of the server (the desync
621 // that permanently bricks a group: every later Commit then 409s and
622 // peers can't decrypt our epoch).
623 let staged = self.stage_add_members(entries, now_ms)?;
624 self.confirm_staged(&staged, now_ms)?;
625 let StagedCommit {
626 commit, welcome, ..
627 } = staged;
628 let welcome =
629 welcome.ok_or_else(|| Error::Invalid("add_members produced no Welcome".into()))?;
630 Ok(AddOutcome { commit, welcome })
631 }
632
633 /// Stage an add-members Commit WITHOUT merging it — the group keeps a
634 /// *pending* commit and the local epoch is UNCHANGED. Returns the Commit +
635 /// Welcome envelopes to send. The caller MUST follow with exactly one of
636 /// [`Self::confirm_staged`] (server accepted the Commit → merge locally) or
637 /// [`Self::abort_staged`] (server rejected it → discard, epoch never moves).
638 ///
639 /// This is what makes the local epoch advance *only after* the server
640 /// accepts the Commit (send-then-merge), so a rejected/conflicting Commit
641 /// can't desync the group. Safe because the JS worker serializes top-level
642 /// requests — nothing else touches this group during the send round-trip
643 /// between stage and confirm/abort (worker.ts "SERIALIZE … dispatch").
644 pub(crate) fn stage_add_members(
645 &mut self,
646 entries: Vec<(DeviceId, Vec<u8>)>,
647 now_ms: u64,
648 ) -> Result<StagedCommit> {
649 let mut kps = Vec::with_capacity(entries.len());
650 // Track signature_key → device_id so we can resolve leaf indices post-commit.
651 let mut sig_to_device: Vec<(Vec<u8>, DeviceId)> = Vec::with_capacity(entries.len());
652 for (device_id, raw) in &entries {
653 let mls_in = MlsMessageIn::tls_deserialize_exact(raw).map_err(Error::mls)?;
654 let kp_in = match mls_in.extract() {
655 MlsMessageBodyIn::KeyPackage(kp) => kp,
656 _ => return Err(Error::Invalid("expected KeyPackage".into())),
657 };
658 // KeyPackages on the wire are unvalidated (`KeyPackageIn`); validate against the
659 // crypto provider before handing them to OpenMLS.
660 let kp = kp_in
661 .validate(self.crypto.crypto(), ProtocolVersion::default())
662 .map_err(Error::mls)?;
663 let sig_key = kp.leaf_node().signature_key().as_slice().to_vec();
664 sig_to_device.push((sig_key, device_id.clone()));
665 kps.push(kp);
666 }
667
668 // The Commit's wire `epoch` is the *source* epoch (where it was crafted). The
669 // Welcome's `epoch` is the *post-commit* epoch. An MLS Commit advances the epoch by
670 // exactly 1, so we can name the post-commit epoch as `pre + 1` WITHOUT merging.
671 let pre_commit_epoch = self.epoch();
672 let post_commit_epoch = pre_commit_epoch + 1;
673
674 let (commit_out, welcome_out, _gi) = self
675 .group
676 .add_members(self.crypto.as_ref(), self.signing.as_ref(), &kps)
677 .map_err(Error::mls)?;
678 // NB: NO merge here — the pending commit is merged by `confirm_staged`
679 // only once the server has accepted the Commit send.
680
681 let next_seq = self.seq + 1;
682 let next_hlc = self.hlc.tick(now_ms);
683
684 let commit_bytes = mls_message_out_bytes(commit_out)?;
685 let commit_env = MessageEnvelope::new(
686 self.id,
687 pre_commit_epoch,
688 MessageKind::Commit,
689 self.own_device.clone(),
690 next_seq,
691 next_hlc,
692 commit_bytes,
693 );
694
695 let welcome_bytes = mls_message_out_bytes(welcome_out)?;
696 let welcome_env = MessageEnvelope::new(
697 self.id,
698 post_commit_epoch,
699 MessageKind::Welcome,
700 self.own_device.clone(),
701 next_seq,
702 next_hlc,
703 welcome_bytes,
704 );
705
706 Ok(StagedCommit {
707 commit: commit_env,
708 welcome: Some(welcome_env),
709 next_seq,
710 next_hlc,
711 leaf_update: StagedLeafUpdate::Add(sig_to_device),
712 })
713 }
714
715 pub fn remove_members(
716 &mut self,
717 leaf_indexes: Vec<u32>,
718 now_ms: u64,
719 ) -> Result<MessageEnvelope> {
720 // All-in-one (stage + immediate merge). See `add_members` for why the
721 // networked path uses stage/confirm/abort instead.
722 let staged = self.stage_remove_members(leaf_indexes, now_ms)?;
723 self.confirm_staged(&staged, now_ms)?;
724 let StagedCommit { commit, .. } = staged;
725 Ok(commit)
726 }
727
728 /// Stage a remove-members Commit WITHOUT merging it — see
729 /// [`Self::stage_add_members`]. No Welcome (removals don't admit anyone).
730 pub(crate) fn stage_remove_members(
731 &mut self,
732 leaf_indexes: Vec<u32>,
733 now_ms: u64,
734 ) -> Result<StagedCommit> {
735 use openmls::prelude::LeafNodeIndex;
736 let leaves: Vec<LeafNodeIndex> = leaf_indexes
737 .iter()
738 .copied()
739 .map(LeafNodeIndex::new)
740 .collect();
741
742 let pre_commit_epoch = self.epoch();
743
744 let (commit_out, _welcome_opt, _gi) = self
745 .group
746 .remove_members(self.crypto.as_ref(), self.signing.as_ref(), &leaves)
747 .map_err(Error::mls)?;
748 // NB: NO merge here — see `stage_add_members`.
749
750 let next_seq = self.seq + 1;
751 let next_hlc = self.hlc.tick(now_ms);
752 let bytes = mls_message_out_bytes(commit_out)?;
753 let commit_env = MessageEnvelope::new(
754 self.id,
755 pre_commit_epoch,
756 MessageKind::Commit,
757 self.own_device.clone(),
758 next_seq,
759 next_hlc,
760 bytes,
761 );
762
763 let removed: std::collections::HashSet<u32> = leaf_indexes.iter().copied().collect();
764 Ok(StagedCommit {
765 commit: commit_env,
766 welcome: None,
767 next_seq,
768 next_hlc,
769 leaf_update: StagedLeafUpdate::Remove(removed),
770 })
771 }
772
773 /// Create a self-Remove PROPOSAL — "leave the group".
774 ///
775 /// MLS does not let a member commit their OWN removal, so leaving is a
776 /// two-step dance: this member broadcasts a Remove proposal for its own
777 /// leaf, and a DIFFERENT member commits it (see
778 /// [`Self::stage_commit_pending_proposals`]). A proposal does NOT advance
779 /// the epoch; we bump only our own seq/hlc and the caller then deletes the
780 /// conversation locally. Returns the proposal envelope
781 /// (`MessageKind::Proposal`) to broadcast on the conversation channel.
782 pub(crate) fn leave_group(&mut self, now_ms: u64) -> Result<MessageEnvelope> {
783 let proposal_out = self
784 .group
785 .leave_group(self.crypto.as_ref(), self.signing.as_ref())
786 .map_err(Error::mls)?;
787 let next_seq = self.seq + 1;
788 let next_hlc = self.hlc.tick(now_ms);
789 let bytes = mls_message_out_bytes(proposal_out)?;
790 let env = MessageEnvelope::new(
791 self.id,
792 self.epoch(),
793 MessageKind::Proposal,
794 self.own_device.clone(),
795 next_seq,
796 next_hlc,
797 bytes,
798 );
799 self.seq = next_seq;
800 self.hlc = next_hlc;
801 Ok(env)
802 }
803
804 /// True when OpenMLS has buffered pending proposals for this group (e.g. a
805 /// peer's leave proposal awaiting a Commit).
806 pub(crate) fn has_pending_proposals(&self) -> bool {
807 self.group.pending_proposals().next().is_some()
808 }
809
810 /// Stage a Commit covering all buffered pending proposals (the canonical
811 /// case: a peer's "leave" Remove proposal). Returns `None` when there is
812 /// nothing pending. Send-then-merge like add/remove so a server-rejected
813 /// Commit rolls back without desyncing the epoch. The removed leaves are
814 /// read off the pending Remove proposals so the device→leaf map is pruned
815 /// on confirm.
816 pub(crate) fn stage_commit_pending_proposals(
817 &mut self,
818 now_ms: u64,
819 ) -> Result<Option<StagedCommit>> {
820 use openmls::messages::proposals::Proposal;
821 let removed: std::collections::HashSet<u32> = self
822 .group
823 .pending_proposals()
824 .filter_map(|qp| match qp.proposal() {
825 Proposal::Remove(r) => Some(r.removed().u32()),
826 _ => None,
827 })
828 .collect();
829 if self.group.pending_proposals().next().is_none() {
830 return Ok(None);
831 }
832
833 let pre_commit_epoch = self.epoch();
834 let (commit_out, _welcome, _gi) = self
835 .group
836 .commit_to_pending_proposals(self.crypto.as_ref(), self.signing.as_ref())
837 .map_err(Error::mls)?;
838 // NB: NO merge here — confirm_staged merges once the server accepts.
839
840 let next_seq = self.seq + 1;
841 let next_hlc = self.hlc.tick(now_ms);
842 let bytes = mls_message_out_bytes(commit_out)?;
843 let commit_env = MessageEnvelope::new(
844 self.id,
845 pre_commit_epoch,
846 MessageKind::Commit,
847 self.own_device.clone(),
848 next_seq,
849 next_hlc,
850 bytes,
851 );
852
853 Ok(Some(StagedCommit {
854 commit: commit_env,
855 welcome: None,
856 next_seq,
857 next_hlc,
858 leaf_update: StagedLeafUpdate::Remove(removed),
859 }))
860 }
861
862 /// Change the conversation `name` carried in the GroupContext (RFC 9420
863 /// GroupContextExtensions commit). Unlike a hydration broadcast, this rides
864 /// MLS group STATE, so every member — and every future joiner via the
865 /// ratchet-tree/GroupInfo — converges on the new name. Hosts use this to make
866 /// a rename or an embedded avatar-media-id change BULLETPROOF (the name field
867 /// carries the `ping:meta:v1:` blob).
868 ///
869 /// Produces a Commit to broadcast; NO Welcome (membership is unchanged). All
870 /// members must advertise the group-name extension capability
871 /// ([`ping_leaf_capabilities`]) — i.e. have re-linked since that shipped —
872 /// else openmls rejects the commit (`RequiredExtensionNotSupportedByAllMembers`).
873 ///
874 /// All-in-one (stage + immediate merge). The networked group path uses
875 /// [`Self::stage_set_name`] + [`Self::confirm_staged`]/[`Self::abort_staged`]
876 /// so a server-rejected commit can roll back without desyncing the epoch.
877 pub fn set_name(&mut self, name: Option<String>, now_ms: u64) -> Result<MessageEnvelope> {
878 let staged = self.stage_set_name(name, now_ms)?;
879 self.confirm_staged(&staged, now_ms)?;
880 let StagedCommit { commit, .. } = staged;
881 Ok(commit)
882 }
883
884 /// Stage a name-update Commit WITHOUT merging it — see
885 /// [`Self::stage_add_members`]. No Welcome (no membership change). The local
886 /// epoch advances only on [`Self::confirm_staged`].
887 pub(crate) fn stage_set_name(
888 &mut self,
889 name: Option<String>,
890 now_ms: u64,
891 ) -> Result<StagedCommit> {
892 let pre_commit_epoch = self.epoch();
893 let extensions = group_context_extensions_for_name_update(name.as_deref());
894
895 let (commit_out, _welcome_opt, _gi) = self
896 .group
897 .update_group_context_extensions(
898 self.crypto.as_ref(),
899 extensions,
900 self.signing.as_ref(),
901 )
902 .map_err(Error::mls)?;
903 // NB: NO merge here — confirm_staged merges once the server accepts.
904
905 let next_seq = self.seq + 1;
906 let next_hlc = self.hlc.tick(now_ms);
907 let bytes = mls_message_out_bytes(commit_out)?;
908 let commit_env = MessageEnvelope::new(
909 self.id,
910 pre_commit_epoch,
911 MessageKind::Commit,
912 self.own_device.clone(),
913 next_seq,
914 next_hlc,
915 bytes,
916 );
917
918 Ok(StagedCommit {
919 commit: commit_env,
920 welcome: None,
921 next_seq,
922 next_hlc,
923 leaf_update: StagedLeafUpdate::None,
924 })
925 }
926
927 /// Merge a previously [staged](Self::stage_add_members) Commit into the local
928 /// group — call ONLY after the server has accepted the Commit send. Advances
929 /// the epoch, updates the roster + device→leaf map, bumps seq/hlc, and moves
930 /// the sync cursor past our own Commit so catch-up doesn't re-apply it.
931 pub(crate) fn confirm_staged(&mut self, staged: &StagedCommit, now_ms: u64) -> Result<()> {
932 self.group
933 .merge_pending_commit(self.crypto.as_ref())
934 .map_err(Error::mls)?;
935 self.meta.epoch = self.epoch();
936 self.meta.member_count = self.group.members().count() as u32;
937 // A GroupContextExtensions commit (e.g. `set_name`) changes the name
938 // carried in group state — refresh the cached meta name. Harmless for
939 // add/remove commits (the name is unchanged).
940 self.meta.name = self.name_from_group_state();
941
942 match &staged.leaf_update {
943 StagedLeafUpdate::Add(sig_to_device) => {
944 // [CR-2] Resolve leaf indexes for the devices we just added (match by the
945 // per-device MLS signature_key, unique per device).
946 for member in self.group.members() {
947 if let Some((_, device_id)) = sig_to_device
948 .iter()
949 .find(|(sig, _)| sig.as_slice() == member.signature_key.as_slice())
950 {
951 self.device_leaves
952 .insert(device_id.clone(), member.index.u32());
953 }
954 }
955 }
956 StagedLeafUpdate::Remove(removed) => {
957 // [CR-2] Prune the device→leaf map for removed leaves. Other entries' leaf
958 // indexes are stable (OpenMLS reuses blank slots, doesn't reshuffle).
959 self.device_leaves.retain(|_, idx| !removed.contains(idx));
960 }
961 StagedLeafUpdate::None => {
962 // No membership change (name-update commit) — leaf map unchanged.
963 }
964 }
965
966 self.seq = staged.next_seq;
967 self.hlc = staged.next_hlc;
968 self.cursor.advance(
969 self.meta.epoch,
970 self.own_device.clone(),
971 self.seq,
972 self.hlc,
973 now_ms,
974 );
975 Ok(())
976 }
977
978 /// Discard a previously [staged](Self::stage_add_members) Commit — call when
979 /// the server REJECTED the Commit send. Clears the pending commit so the
980 /// local epoch stays exactly where it was (no desync) and the conversation
981 /// is operational again. Idempotent / safe if there is no pending commit.
982 pub(crate) fn abort_staged(&mut self) -> Result<()> {
983 self.group
984 .clear_pending_commit(self.crypto.storage())
985 .map_err(Error::mls)?;
986 Ok(())
987 }
988
989 /// Process an inbound envelope. Returns Some(IncomingMessage) for application traffic.
990 pub fn process(
991 &mut self,
992 env: &MessageEnvelope,
993 now_ms: u64,
994 ) -> Result<Option<IncomingMessage>> {
995 if !self.cursor.is_new(env.epoch, &env.sender_device, env.seq) {
996 return Ok(None); // dedupe: already applied
997 }
998 let mls_in = MlsMessageIn::tls_deserialize_exact(&env.payload).map_err(Error::mls)?;
999
1000 // OpenMLS' `process_message` expects an `impl Into<ProtocolMessage>`. `MlsMessageIn`
1001 // itself doesn't implement that; we have to extract the body and convert the inner
1002 // private/public message. Welcomes are handled at the client level, not here.
1003 let protocol_msg: ProtocolMessage = match mls_in.extract() {
1004 MlsMessageBodyIn::PrivateMessage(m) => m.into(),
1005 MlsMessageBodyIn::PublicMessage(m) => m.into(),
1006 MlsMessageBodyIn::Welcome(_) => {
1007 return Err(Error::Invalid(
1008 "Welcome must be handled at client level, not in-group".into(),
1009 ));
1010 }
1011 _ => return Err(Error::Invalid("unsupported MLS message body".into())),
1012 };
1013
1014 let processed: ProcessedMessage = self
1015 .group
1016 .process_message(self.crypto.as_ref(), protocol_msg)
1017 .map_err(Error::mls)?;
1018
1019 // Recover the sender's account-level `UserId` from their authenticated
1020 // leaf credential BEFORE `into_content()` consumes `processed`. Same
1021 // round-trip as `members()`: the leaf was built as
1022 // `BasicCredential::new(user.0)`, so `identity()` is the `UserId` bytes.
1023 // This lets the host attribute messages to the right account across
1024 // every linked device without any device→account side channel.
1025 let sender_user_id = BasicCredential::try_from(processed.credential().clone())
1026 .map(|c| UserId(c.identity().to_vec()))
1027 .unwrap_or_else(|_| UserId(Vec::new()));
1028
1029 let out = match processed.into_content() {
1030 ProcessedMessageContent::ApplicationMessage(app) => {
1031 let pt = app.into_bytes();
1032 // CR-6: for v=2 application envelopes the wire-contract validator can't
1033 // check `content_hash` (the hash is over plaintext, which it didn't have).
1034 // We can now: verify SHA-256(pt) == env.content_hash and reject mismatches.
1035 // For v=1 envelopes the wire-contract validator already checked the
1036 // ciphertext-based hash, so no extra work here.
1037 if env.v >= 2 {
1038 let computed = crate::message::hash_application_plaintext(&pt);
1039 if computed != env.content_hash {
1040 return Err(Error::Invalid(
1041 "v=2 application content_hash mismatch".into(),
1042 ));
1043 }
1044 }
1045 Some(IncomingMessage {
1046 conversation_id: self.id,
1047 sender_device: env.sender_device.clone(),
1048 sender_user_id,
1049 epoch: env.epoch,
1050 hlc: env.hlc,
1051 plaintext: pt,
1052 content_hash: env.content_hash,
1053 })
1054 }
1055 ProcessedMessageContent::StagedCommitMessage(staged) => {
1056 self.group
1057 .merge_staged_commit(self.crypto.as_ref(), *staged)
1058 .map_err(Error::mls)?;
1059 self.meta.epoch = self.epoch();
1060 self.meta.member_count = self.group.members().count() as u32;
1061 // A remote `set_name` (GroupContextExtensions) commit changes the
1062 // name in group state — refresh the cached meta so this device
1063 // picks up the rename / avatar-id change WITHOUT a side broadcast.
1064 self.meta.name = self.name_from_group_state();
1065 None
1066 }
1067 ProcessedMessageContent::ProposalMessage(qp) => {
1068 // OpenMLS does NOT auto-buffer a processed proposal — the caller
1069 // must explicitly store it, or `pending_proposals()` stays empty
1070 // and `commit_to_pending_proposals` covers nothing. This is the
1071 // mechanism behind "leave": a peer's self-Remove proposal is
1072 // stored here so a remaining member can commit it (evicting the
1073 // leaver) via `commit_pending_proposals`.
1074 self.group
1075 .store_pending_proposal(self.crypto.storage(), *qp)
1076 .map_err(Error::mls)?;
1077 None
1078 }
1079 ProcessedMessageContent::ExternalJoinProposalMessage(_) => {
1080 // External-join proposals are not part of any current flow; drop.
1081 None
1082 }
1083 };
1084
1085 self.cursor.advance(
1086 env.epoch,
1087 env.sender_device.clone(),
1088 env.seq,
1089 env.hlc,
1090 now_ms,
1091 );
1092 Ok(out)
1093 }
1094
1095 /// Export a derived secret keyed to this group's current epoch ([CR-8]).
1096 ///
1097 /// Wraps `MlsGroup::export_secret` (the MLS exporter, RFC 9420 §8.5) and surfaces the
1098 /// bytes in a `Zeroizing<Vec<u8>>` so the local copy is wiped on drop. Used by the host
1099 /// to seed:
1100 /// * the ephemeral channel (`ping/ephemeral`, §5.4 of the architecture)
1101 /// * call media keys (`ping/calls/media/{call_id}`, §7.2)
1102 /// * call-ephemeral framer keys (`ping/calls/ephemeral/{call_id}`, §7.5)
1103 ///
1104 /// `label` should use the documented `ping/*` namespacing convention. There is no
1105 /// runtime enforcement — cross-binding parity is enforced by conformance fixtures
1106 /// pinning specific label strings.
1107 ///
1108 /// Output is the secret. Callers MUST treat the buffer as a secret: never log, never
1109 /// persist unencrypted. The wrapper zeroes our local copy on drop; the caller is
1110 /// responsible for zeroing any copy they make.
1111 pub fn export_secret(
1112 &self,
1113 label: &str,
1114 context: &[u8],
1115 length: usize,
1116 ) -> Result<Zeroizing<Vec<u8>>> {
1117 if length == 0 {
1118 return Err(Error::Invalid("export_secret length must be > 0".into()));
1119 }
1120 // Soft cap to prevent runaway allocations from a malformed caller. Real labels never
1121 // need more than ~64 bytes (AES-256 key + 96-bit nonce + slack); 1 KiB is generous.
1122 if length > 1024 {
1123 return Err(Error::Invalid(
1124 "export_secret length exceeds 1024-byte cap".into(),
1125 ));
1126 }
1127 let bytes = self
1128 .group
1129 .export_secret(self.crypto.crypto(), label, context, length)
1130 .map_err(Error::mls)?;
1131 Ok(Zeroizing::new(bytes))
1132 }
1133
1134 /// [CR-7] Export a portable snapshot of this group's MLS state.
1135 ///
1136 /// Walks the provider's working set, picks every entry whose key references this
1137 /// group's id, and bundles them with format metadata. Returns CBOR-encoded bytes
1138 /// suitable for inclusion in:
1139 /// * `LinkingTicket.catchup_snapshot.conversation_metas[i].group_state_bytes`
1140 /// (via [CR-13] — host calls this and passes the bytes through);
1141 /// * `IdentityBackup.device_group_snapshot` (the Permissive-recovery path per
1142 /// `docs/architecture/recovery.md`).
1143 ///
1144 /// Returns `Err` if the encoded snapshot exceeds [`GROUP_SNAPSHOT_HARD_CAP`].
1145 /// Output is wrapped in `Zeroizing` because the bytes contain past epoch secrets;
1146 /// the caller's copy on the FFI side is the host's responsibility to wipe.
1147 pub fn export_state_snapshot(&self, now_ms: u64) -> Result<Zeroizing<Vec<u8>>> {
1148 let entries = self.crypto.group_scoped_entries(&self.id.0);
1149 let snap = GroupStateSnapshot {
1150 v: GROUP_SNAPSHOT_VERSION,
1151 group_id: self.id,
1152 openmls_storage_version: openmls_traits::storage::CURRENT_VERSION,
1153 snapshot_created_at_ms: now_ms,
1154 entries: entries
1155 .into_iter()
1156 .map(|(key, value)| GroupSnapshotEntry { key, value })
1157 .collect(),
1158 };
1159 Ok(Zeroizing::new(snap.encode()?))
1160 }
1161
1162 /// Look up the leaf index this device controls, if known ([CR-2]).
1163 ///
1164 /// Returns the locally-tracked leaf for `device_id`. Only populated for devices we
1165 /// added via [`Self::add_members`] or for our own leaf via [`Self::create`] /
1166 /// [`Self::join`]. Devices a peer admitted on our behalf are not in this map.
1167 pub fn leaf_index_of(&self, device_id: &DeviceId) -> Option<u32> {
1168 self.device_leaves.get(device_id).copied()
1169 }
1170
1171 /// Synchronously capture everything [`ConversationSnapshot::flush`]
1172 /// needs to persist this conversation, so a caller can DROP the
1173 /// `conversations` lock BEFORE awaiting the async writes.
1174 ///
1175 /// Holding a `parking_lot` guard across `.await` is a latent bug: on
1176 /// the single-threaded wasm worker, a second client call that lands
1177 /// while the first is suspended (a waiting writer + a new reader)
1178 /// makes `parking_lot` try to PARK, and its wasm stub `panic!`s with
1179 /// "Parking not supported on this platform" — poisoning the module.
1180 /// Splitting the synchronous capture (under the lock) from the async
1181 /// flush (lock released) removes that hazard everywhere the snapshot
1182 /// runs for a conversation that lives inside the shared map. The
1183 /// capture is a consistent point-in-time view (cursor + meta + leaves
1184 /// + the Arc'd provider/storage handles).
1185 pub(crate) fn snapshot_inputs(&self) -> Result<ConversationSnapshot> {
1186 // [CR-2] Stable BTreeMap-of-pairs encoding → canonical CBOR so
1187 // every platform decodes identical bytes.
1188 let leaves_vec: Vec<(DeviceId, u32)> = self
1189 .device_leaves
1190 .iter()
1191 .map(|(d, i)| (d.clone(), *i))
1192 .collect();
1193 Ok(ConversationSnapshot {
1194 id: self.id,
1195 crypto: self.crypto.clone(),
1196 storage: self.storage.clone(),
1197 cursor: self.cursor.encode()?,
1198 meta: codec::encode(&self.meta)?,
1199 device_leaves: codec::encode(&leaves_vec)?,
1200 })
1201 }
1202
1203 /// Persist this conversation's state. Convenience wrapper used by
1204 /// call sites that hold an OWNED `Conversation` (not borrowed from the
1205 /// shared map) — e.g. just-created/just-joined conversations before
1206 /// they're inserted, where no lock is held across the await. Map-
1207 /// resident callers MUST instead use `snapshot_inputs()` + drop the
1208 /// guard + `flush().await` (see client.rs) to avoid the wasm parking
1209 /// panic described on `snapshot_inputs`.
1210 pub(crate) async fn snapshot_to_storage(&self) -> Result<()> {
1211 self.snapshot_inputs()?.flush().await
1212 }
1213}
1214
1215/// Point-in-time, lock-free snapshot of a [`Conversation`]'s persistable
1216/// state. Produced synchronously by [`Conversation::snapshot_inputs`] (so
1217/// the `conversations` lock can be dropped) and flushed asynchronously by
1218/// [`Self::flush`].
1219pub(crate) struct ConversationSnapshot {
1220 id: ConversationId,
1221 crypto: Arc<PersistentMlsProvider>,
1222 storage: Arc<dyn Storage>,
1223 cursor: Vec<u8>,
1224 meta: Vec<u8>,
1225 device_leaves: Vec<u8>,
1226}
1227
1228impl ConversationSnapshot {
1229 /// Flush the captured state to storage. Safe to `.await` with NO
1230 /// `conversations` lock held — it only touches the Arc'd provider +
1231 /// storage handles, never the shared map.
1232 pub(crate) async fn flush(self) -> Result<()> {
1233 // CRASH-CONSISTENCY ORDERING. The host `Storage` is a non-transactional
1234 // key-value store (iOS SQLCipher row, web IndexedDB slot, memory), so we
1235 // cannot make these four writes atomic without changing the host API on
1236 // every platform. Instead we order them so a crash between any two
1237 // leaves a SELF-HEALING state, never a permanent gap:
1238 //
1239 // 1. MLS working set (`checkpoint_async`) — the authoritative crypto
1240 // state (epoch, ratchet keys). Written FIRST so the persisted MLS
1241 // state is always >= what the cursor claims we've processed.
1242 // 2. meta (name / member_count) — cosmetic, re-derivable from group state.
1243 // 3. device→leaf map ([CR-2]) — needed by revoke_device after restart.
1244 // 4. cursor — the "processed up to here" gate, written LAST.
1245 //
1246 // Why cursor LAST is the key invariant: the cursor decides which events
1247 // we re-fetch on restart. If it committed BEFORE the MLS checkpoint, a
1248 // crash in between would leave the cursor ahead of the persisted MLS
1249 // state — we'd skip events the group never actually applied, a permanent
1250 // gap (the "stranded" condition). Writing MLS first and the cursor last
1251 // guarantees the cursor is never ahead of durable state: a crash just
1252 // means we re-fetch a few already-applied events, which `process` dedups
1253 // via `SyncCursor::is_new`. meta/device_leaves lagging the cursor is the
1254 // only residual skew and both are re-derivable from MLS group state.
1255 //
1256 // [CR-4] checkpoint MUST happen on every state-changing op so a cold
1257 // restart (iOS NSE, web SW) finds the latest epoch. `checkpoint_async`
1258 // is required for the WASM `IndexedDb` backend (IDB is async-only);
1259 // native Memory / Sqlite await trivially (their I/O is sync internally).
1260 self.crypto
1261 .checkpoint_async()
1262 .await
1263 .map_err(|e| Error::Storage(format!("checkpoint: {e}")))?;
1264
1265 let hex = self.id.as_hex();
1266 self.storage
1267 .put("groups", &format!("{hex}/meta"), self.meta)
1268 .await?;
1269 self.storage
1270 .put("device_leaves", &hex, self.device_leaves)
1271 .await?;
1272 // Cursor written LAST — see the ordering rationale above.
1273 self.storage.put("cursors", &hex, self.cursor).await?;
1274 Ok(())
1275 }
1276}
1277
1278/// Both halves of an Add commit. The Commit goes on the conversation channel; the Welcome is
1279/// delivered to the new members via whatever out-of-band path the host uses (often the same
1280/// transport, addressed to the new device's mailbox).
1281#[derive(Debug, Clone)]
1282pub struct AddOutcome {
1283 pub commit: MessageEnvelope,
1284 pub welcome: MessageEnvelope,
1285}
1286
1287/// The local-state mutation a staged Commit will apply on
1288/// [`Conversation::confirm_staged`]. Captured at stage time so confirm can run
1289/// after the (async) Commit send without re-deriving anything.
1290pub(crate) enum StagedLeafUpdate {
1291 /// Add: signature_key → device_id for each added device, resolved to a leaf
1292 /// index against the merged tree in `confirm_staged`.
1293 Add(Vec<(Vec<u8>, DeviceId)>),
1294 /// Remove: the leaf indexes being dropped from the device→leaf map.
1295 Remove(std::collections::HashSet<u32>),
1296 /// No membership change (e.g. a GroupContextExtensions / name-update commit).
1297 /// The device→leaf map is untouched.
1298 None,
1299}
1300
1301/// A Commit produced but NOT yet merged (see [`Conversation::stage_add_members`]).
1302/// Held by the client across the Commit send; merged via
1303/// [`Conversation::confirm_staged`] on success or discarded via
1304/// [`Conversation::abort_staged`] on a server rejection. This is the unit of the
1305/// send-then-merge protocol that keeps the local epoch from ever running ahead of
1306/// the server.
1307pub(crate) struct StagedCommit {
1308 pub commit: MessageEnvelope,
1309 pub welcome: Option<MessageEnvelope>,
1310 next_seq: u64,
1311 next_hlc: Hlc,
1312 leaf_update: StagedLeafUpdate,
1313}
1314
1315fn mls_message_out_bytes(m: MlsMessageOut) -> Result<Vec<u8>> {
1316 m.tls_serialize_detached().map_err(Error::mls)
1317}