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