Skip to main content

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}