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, Ciphersuite, CredentialWithKey, MlsMessageBodyIn, MlsMessageIn,
15        ProcessedMessage, ProtocolMessage, ProtocolVersion,
16    },
17};
18use openmls_basic_credential::SignatureKeyPair;
19use openmls_traits::OpenMlsProvider;
20use ping_mls_store::PersistentMlsProvider;
21use serde::{Deserialize, Serialize};
22use std::collections::BTreeMap;
23use std::sync::Arc;
24use ulid::Ulid;
25use zeroize::Zeroizing;
26
27use crate::{
28    clock::Hlc,
29    codec,
30    device::{DeviceId, GroupSnapshotEntry, GroupStateSnapshot, GROUP_SNAPSHOT_VERSION},
31    error::{Error, Result},
32    identity::UserId,
33    message::{IncomingMessage, MessageEnvelope, MessageKind},
34    storage::Storage,
35    sync::SyncCursor,
36};
37
38const DEFAULT_CIPHERSUITE: Ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
39
40/// 16-byte conversation identifier (ULID encoded). Stable across epochs.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
42pub struct ConversationId(#[serde(with = "serde_bytes_array16")] pub [u8; 16]);
43
44impl ConversationId {
45    pub fn new() -> Self {
46        Self(Ulid::new().to_bytes())
47    }
48    pub fn as_hex(&self) -> String {
49        hex::encode(self.0)
50    }
51}
52
53impl Default for ConversationId {
54    fn default() -> Self {
55        Self::new()
56    }
57}
58
59mod serde_bytes_array16 {
60    use serde::{Deserializer, Serializer};
61    pub fn serialize<S: Serializer>(b: &[u8; 16], s: S) -> Result<S::Ok, S::Error> {
62        serde_bytes::serialize(b.as_slice(), s)
63    }
64    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 16], D::Error> {
65        let v: Vec<u8> = serde_bytes::deserialize(d)?;
66        v.try_into()
67            .map_err(|_| serde::de::Error::custom("expected 16 bytes"))
68    }
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct ConversationMeta {
73    pub id: ConversationId,
74    pub name: Option<String>,
75    pub epoch: u64,
76    pub member_count: u32,
77    pub is_device_group: bool,
78    pub created_at_ms: u64,
79}
80
81/// One member leaf of a conversation's MLS group: the member's [`UserId`]
82/// (recovered from the leaf's `BasicCredential`) and its ratchet-tree leaf
83/// index. A user with multiple devices appears once **per device leaf** —
84/// callers that want a per-user roster should dedup by `user_id`.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct MemberInfo {
87    pub user_id: UserId,
88    pub leaf_index: u32,
89}
90
91/// In-memory conversation handle. Holds the OpenMLS group plus our wire-level cursor.
92pub struct Conversation {
93    pub(crate) id: ConversationId,
94    pub(crate) meta: ConversationMeta,
95    pub(crate) group: MlsGroup,
96    pub(crate) crypto: Arc<PersistentMlsProvider>,
97    pub(crate) signing: Arc<SignatureKeyPair>,
98    pub(crate) own_device: DeviceId,
99    pub(crate) seq: u64,
100    pub(crate) hlc: Hlc,
101    pub(crate) cursor: SyncCursor,
102    pub(crate) storage: Arc<dyn Storage>,
103    /// Local device→leaf-index map for [CR-2] revocation.
104    ///
105    /// Populated when this device either (a) admits a peer via [`Self::add_members`] —
106    /// every entry in the `Vec<(DeviceId, KeyPackage)>` is recorded after the commit
107    /// merges — or (b) joins as the receiving device via [`Self::join`], at which point
108    /// we record our own leaf. Pruned when [`Self::remove_members`] is called.
109    ///
110    /// Not authoritative for *peers' devices we didn't admit*: those are visible in
111    /// `group.members()` but their device_ids are opaque to this client. `revoke_device`
112    /// is therefore best-effort across conversations we ourselves invited the device
113    /// into; see [`MessagingClient::revoke_device`] for the documented scope.
114    pub(crate) device_leaves: BTreeMap<DeviceId, u32>,
115}
116
117impl std::fmt::Debug for Conversation {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        f.debug_struct("Conversation")
120            .field("id", &self.id.as_hex())
121            .field("meta", &self.meta)
122            .finish()
123    }
124}
125
126impl Conversation {
127    pub fn id(&self) -> ConversationId {
128        self.id
129    }
130    pub fn meta(&self) -> &ConversationMeta {
131        &self.meta
132    }
133
134    /// Current member roster, recovered locally from the MLS group's leaf
135    /// credentials — no network and no out-of-band `ping.profile` message.
136    /// Each `BasicCredential` was built from the member's `UserId`
137    /// (`BasicCredential::new(own_user.0.clone())` in [`Self::create`] /
138    /// [`Self::join`]), so we round-trip it back here. Each entry is one
139    /// leaf; a multi-device user appears once per device leaf.
140    pub fn members(&self) -> Vec<MemberInfo> {
141        self.group
142            .members()
143            .filter_map(|m| {
144                let basic = BasicCredential::try_from(m.credential).ok()?;
145                Some(MemberInfo {
146                    user_id: UserId(basic.identity().to_vec()),
147                    leaf_index: m.index.u32(),
148                })
149            })
150            .collect()
151    }
152
153    pub fn epoch(&self) -> u64 {
154        self.group.epoch().as_u64()
155    }
156    pub fn cursor(&self) -> &SyncCursor {
157        &self.cursor
158    }
159
160    /// Create a new conversation, with `self` as the only initial member.
161    // 8 args is a lot, but they're all needed for an internal constructor and a builder
162    // would be over-engineered for v0.1.
163    #[allow(clippy::too_many_arguments)]
164    pub(crate) fn create(
165        id: ConversationId,
166        name: Option<String>,
167        own_device: DeviceId,
168        own_user: &UserId,
169        crypto: Arc<PersistentMlsProvider>,
170        signing: Arc<SignatureKeyPair>,
171        storage: Arc<dyn Storage>,
172        now_ms: u64,
173    ) -> Result<Self> {
174        let credential = BasicCredential::new(own_user.0.clone());
175        let credential_with_key = CredentialWithKey {
176            credential: credential.into(),
177            signature_key: signing.public().into(),
178        };
179        let cfg = MlsGroupCreateConfig::builder()
180            .ciphersuite(DEFAULT_CIPHERSUITE)
181            .use_ratchet_tree_extension(true)
182            .build();
183        let group = MlsGroup::new_with_group_id(
184            crypto.as_ref(),
185            signing.as_ref(),
186            &cfg,
187            openmls::group::GroupId::from_slice(&id.0),
188            credential_with_key,
189        )
190        .map_err(Error::mls)?;
191
192        let meta = ConversationMeta {
193            id,
194            name,
195            epoch: 0,
196            member_count: 1,
197            is_device_group: false,
198            created_at_ms: now_ms,
199        };
200        // [CR-2] Group creator is always leaf 0; record so revoke_device can target it.
201        let mut device_leaves = BTreeMap::new();
202        device_leaves.insert(own_device.clone(), group.own_leaf_index().u32());
203        Ok(Self {
204            id,
205            meta,
206            group,
207            crypto,
208            signing,
209            own_device,
210            seq: 0,
211            hlc: Hlc::ZERO.tick(now_ms),
212            cursor: SyncCursor::default(),
213            storage,
214            device_leaves,
215        })
216    }
217
218    /// Join an existing conversation from a Welcome message.
219    pub(crate) fn join(
220        welcome_bytes: &[u8],
221        own_device: DeviceId,
222        crypto: Arc<PersistentMlsProvider>,
223        signing: Arc<SignatureKeyPair>,
224        storage: Arc<dyn Storage>,
225        now_ms: u64,
226    ) -> Result<Self> {
227        let mls_in = MlsMessageIn::tls_deserialize_exact(welcome_bytes).map_err(Error::mls)?;
228        let welcome = match mls_in.extract() {
229            MlsMessageBodyIn::Welcome(w) => w,
230            _ => return Err(Error::Invalid("expected Welcome".into())),
231        };
232        let cfg = MlsGroupJoinConfig::builder()
233            .use_ratchet_tree_extension(true)
234            .build();
235        let staged =
236            openmls::group::StagedWelcome::new_from_welcome(crypto.as_ref(), &cfg, welcome, None)
237                .map_err(Error::mls)?;
238        let group = staged.into_group(crypto.as_ref()).map_err(Error::mls)?;
239
240        let id_bytes: [u8; 16] = group
241            .group_id()
242            .as_slice()
243            .try_into()
244            .map_err(|_| Error::Invalid("group id must be 16 bytes".into()))?;
245        let id = ConversationId(id_bytes);
246        let meta = ConversationMeta {
247            id,
248            name: None,
249            epoch: group.epoch().as_u64(),
250            member_count: group.members().count() as u32,
251            is_device_group: false,
252            created_at_ms: now_ms,
253        };
254
255        // Seed the cursor at the join epoch so subsequent fetches skip pre-join Commits
256        // (notably the Add commit that produced this Welcome — it lives in the conversation
257        // log at `epoch - 1`, which the joiner must not try to apply on top of its
258        // already-advanced group state).
259        let join_epoch = group.epoch().as_u64();
260        // [CR-2] Record our own (device_id → leaf_index) so the host can later revoke us
261        // via the standard `revoke_device` flow. `own_leaf_index()` is stable for the
262        // lifetime of this group membership.
263        let own_leaf = group.own_leaf_index().u32();
264        let mut device_leaves = BTreeMap::new();
265        device_leaves.insert(own_device.clone(), own_leaf);
266        Ok(Self {
267            id,
268            meta,
269            group,
270            crypto,
271            signing,
272            own_device,
273            seq: 0,
274            hlc: Hlc::ZERO.tick(now_ms),
275            cursor: SyncCursor {
276                epoch: join_epoch,
277                ..Default::default()
278            },
279            storage,
280            device_leaves,
281        })
282    }
283
284    /// [CR-4] Rehydrate a previously-persisted conversation on cold restart.
285    ///
286    /// Loads the OpenMLS group state via `MlsGroup::load` (which reads from the
287    /// provider's storage — populated by the SQLite-backed checkpoint on the
288    /// previous run). Pairs the loaded MLS state with the meta + cursor + device→leaf
289    /// map the host-side `Storage` trait kept for us. Returns `Ok(None)` if OpenMLS
290    /// finds no state for `id` — the host's `groups` namespace had a stale entry.
291    #[allow(clippy::too_many_arguments)]
292    pub(crate) fn load(
293        id: ConversationId,
294        meta: ConversationMeta,
295        cursor: SyncCursor,
296        device_leaves: BTreeMap<DeviceId, u32>,
297        own_device: DeviceId,
298        crypto: Arc<PersistentMlsProvider>,
299        signing: Arc<SignatureKeyPair>,
300        storage: Arc<dyn Storage>,
301        now_ms: u64,
302    ) -> Result<Option<Self>> {
303        use openmls::group::GroupId;
304        let group_id = GroupId::from_slice(&id.0);
305        let group = match MlsGroup::load(crypto.storage(), &group_id).map_err(Error::mls)? {
306            Some(g) => g,
307            None => return Ok(None),
308        };
309        // Restore the local outgoing-send counter from the persisted cursor. The cursor
310        // tracks the highest applied (epoch, sender, seq) for every device — including
311        // our own — so we can recover `self.seq` from `cursor.last_seq_per_device[own]`.
312        // Without this, the next `send_application()` re-uses an already-consumed seq
313        // and receivers silently dedupe (cursor.is_new returns false on their side).
314        let seq = cursor
315            .last_seq_per_device
316            .get(&own_device)
317            .copied()
318            .unwrap_or(0);
319        Ok(Some(Self {
320            id,
321            meta,
322            group,
323            crypto,
324            signing,
325            own_device,
326            seq,
327            hlc: Hlc::ZERO.tick(now_ms),
328            cursor,
329            storage,
330            device_leaves,
331        }))
332    }
333
334    /// Encrypt an application message and produce a wire envelope ready for transport.
335    ///
336    /// Uses the [CR-6] plaintext content_hash path: the envelope's `content_hash` is
337    /// `SHA-256(plaintext)`, not the MLS ciphertext. This is what makes rebase clean
338    /// and gives cross-binding hash parity.
339    pub fn send_application(&mut self, plaintext: &[u8], now_ms: u64) -> Result<MessageEnvelope> {
340        let out = self
341            .group
342            .create_message(self.crypto.as_ref(), self.signing.as_ref(), plaintext)
343            .map_err(Error::mls)?;
344
345        self.seq += 1;
346        self.hlc = self.hlc.tick(now_ms);
347        let bytes = out.tls_serialize_detached().map_err(Error::mls)?;
348        let env = MessageEnvelope::new_application(
349            self.id,
350            self.epoch(),
351            self.own_device.clone(),
352            self.seq,
353            self.hlc,
354            bytes,
355            plaintext,
356        );
357        // Advance the local cursor past our own send so a subsequent catch-up sync doesn't
358        // pull this envelope back to us (we've already applied it locally — re-processing
359        // would either fail or duplicate-deliver).
360        self.cursor.advance(
361            env.epoch,
362            self.own_device.clone(),
363            self.seq,
364            self.hlc,
365            now_ms,
366        );
367        Ok(env)
368    }
369
370    /// Add members by KeyPackage. Produces the Commit envelope to broadcast plus the Welcome
371    /// envelope(s) to deliver out-of-band to the newly-added devices.
372    ///
373    /// [CR-2] takes a `Vec<(DeviceId, KeyPackage)>` instead of a bare `Vec<KeyPackage>`. The
374    /// `DeviceId` for each entry is the *caller's* assertion of which device owns that
375    /// KeyPackage — hosts typically get it from the directory service alongside the
376    /// KeyPackage itself. The mapping is persisted per-conversation so [`MessagingClient::revoke_device`]
377    /// can later locate the leaf to remove without a fresh directory lookup. The SDK does
378    /// not cryptographically verify the device claim; that's a host policy concern
379    /// (typically: the directory authenticates the key_package_id → device_id mapping).
380    pub fn add_members(
381        &mut self,
382        entries: Vec<(DeviceId, Vec<u8>)>,
383        now_ms: u64,
384    ) -> Result<AddOutcome> {
385        let mut kps = Vec::with_capacity(entries.len());
386        // Track signature_key → device_id so we can resolve leaf indices post-commit.
387        let mut sig_to_device: Vec<(Vec<u8>, DeviceId)> = Vec::with_capacity(entries.len());
388        for (device_id, raw) in &entries {
389            let mls_in = MlsMessageIn::tls_deserialize_exact(raw).map_err(Error::mls)?;
390            let kp_in = match mls_in.extract() {
391                MlsMessageBodyIn::KeyPackage(kp) => kp,
392                _ => return Err(Error::Invalid("expected KeyPackage".into())),
393            };
394            // KeyPackages on the wire are unvalidated (`KeyPackageIn`); validate against the
395            // crypto provider before handing them to OpenMLS.
396            let kp = kp_in
397                .validate(self.crypto.crypto(), ProtocolVersion::default())
398                .map_err(Error::mls)?;
399            let sig_key = kp.leaf_node().signature_key().as_slice().to_vec();
400            sig_to_device.push((sig_key, device_id.clone()));
401            kps.push(kp);
402        }
403
404        // The Commit's wire `epoch` field is the *source* epoch (the epoch the Commit was
405        // crafted in, matching the epoch embedded in the inner MLS message bytes). The
406        // Welcome's `epoch` is the *post-commit* epoch (it carries the new group state).
407        // This split is what lets a joiner's sync cursor correctly filter pre-join Commits.
408        let pre_commit_epoch = self.epoch();
409
410        let (commit_out, welcome_out, _gi) = self
411            .group
412            .add_members(self.crypto.as_ref(), self.signing.as_ref(), &kps)
413            .map_err(Error::mls)?;
414
415        self.group
416            .merge_pending_commit(self.crypto.as_ref())
417            .map_err(Error::mls)?;
418        self.meta.epoch = self.epoch();
419        self.meta.member_count = self.group.members().count() as u32;
420
421        // [CR-2] Resolve leaf indexes for the devices we just added. The Commit merged each
422        // new KeyPackage's leaf into the tree; match by signature_key (unique per device's
423        // MLS signing keypair) to recover the index.
424        for member in self.group.members() {
425            if let Some((_, device_id)) = sig_to_device
426                .iter()
427                .find(|(sig, _)| sig.as_slice() == member.signature_key.as_slice())
428            {
429                self.device_leaves
430                    .insert(device_id.clone(), member.index.u32());
431            }
432        }
433
434        self.seq += 1;
435        self.hlc = self.hlc.tick(now_ms);
436
437        let commit_bytes = mls_message_out_bytes(commit_out)?;
438        let commit_env = MessageEnvelope::new(
439            self.id,
440            pre_commit_epoch,
441            MessageKind::Commit,
442            self.own_device.clone(),
443            self.seq,
444            self.hlc,
445            commit_bytes,
446        );
447
448        let welcome_bytes = mls_message_out_bytes(welcome_out)?;
449        let welcome_env = MessageEnvelope::new(
450            self.id,
451            self.meta.epoch,
452            MessageKind::Welcome,
453            self.own_device.clone(),
454            self.seq,
455            self.hlc,
456            welcome_bytes,
457        );
458
459        // Advance the local cursor past our own Commit (at the post-commit epoch, since
460        // we've already merged it locally) so catch-up sync doesn't try to re-apply it.
461        self.cursor.advance(
462            self.meta.epoch,
463            self.own_device.clone(),
464            self.seq,
465            self.hlc,
466            now_ms,
467        );
468
469        Ok(AddOutcome {
470            commit: commit_env,
471            welcome: welcome_env,
472        })
473    }
474
475    pub fn remove_members(
476        &mut self,
477        leaf_indexes: Vec<u32>,
478        now_ms: u64,
479    ) -> Result<MessageEnvelope> {
480        use openmls::prelude::LeafNodeIndex;
481        let leaves: Vec<LeafNodeIndex> = leaf_indexes
482            .iter()
483            .copied()
484            .map(LeafNodeIndex::new)
485            .collect();
486
487        // Capture the source epoch before merge — see add_members for the rationale.
488        let pre_commit_epoch = self.epoch();
489
490        let (commit_out, _welcome_opt, _gi) = self
491            .group
492            .remove_members(self.crypto.as_ref(), self.signing.as_ref(), &leaves)
493            .map_err(Error::mls)?;
494        self.group
495            .merge_pending_commit(self.crypto.as_ref())
496            .map_err(Error::mls)?;
497        self.meta.epoch = self.epoch();
498        self.meta.member_count = self.group.members().count() as u32;
499
500        // [CR-2] Prune the device→leaf map for any devices we just removed. Other
501        // entries' leaf indexes are stable across MLS adds/removes (OpenMLS reuses
502        // blank slots, doesn't reshuffle live leaves).
503        let removed: std::collections::HashSet<u32> = leaf_indexes.iter().copied().collect();
504        self.device_leaves.retain(|_, idx| !removed.contains(idx));
505
506        self.seq += 1;
507        self.hlc = self.hlc.tick(now_ms);
508        let bytes = mls_message_out_bytes(commit_out)?;
509        let env = MessageEnvelope::new(
510            self.id,
511            pre_commit_epoch,
512            MessageKind::Commit,
513            self.own_device.clone(),
514            self.seq,
515            self.hlc,
516            bytes,
517        );
518        // Advance the local cursor past our own Commit (at the post-commit epoch we've just
519        // merged into) so catch-up sync doesn't try to re-apply it.
520        self.cursor.advance(
521            self.meta.epoch,
522            self.own_device.clone(),
523            self.seq,
524            self.hlc,
525            now_ms,
526        );
527        Ok(env)
528    }
529
530    /// Process an inbound envelope. Returns Some(IncomingMessage) for application traffic.
531    pub fn process(
532        &mut self,
533        env: &MessageEnvelope,
534        now_ms: u64,
535    ) -> Result<Option<IncomingMessage>> {
536        if !self.cursor.is_new(env.epoch, &env.sender_device, env.seq) {
537            return Ok(None); // dedupe: already applied
538        }
539        let mls_in = MlsMessageIn::tls_deserialize_exact(&env.payload).map_err(Error::mls)?;
540
541        // OpenMLS' `process_message` expects an `impl Into<ProtocolMessage>`. `MlsMessageIn`
542        // itself doesn't implement that; we have to extract the body and convert the inner
543        // private/public message. Welcomes are handled at the client level, not here.
544        let protocol_msg: ProtocolMessage = match mls_in.extract() {
545            MlsMessageBodyIn::PrivateMessage(m) => m.into(),
546            MlsMessageBodyIn::PublicMessage(m) => m.into(),
547            MlsMessageBodyIn::Welcome(_) => {
548                return Err(Error::Invalid(
549                    "Welcome must be handled at client level, not in-group".into(),
550                ));
551            }
552            _ => return Err(Error::Invalid("unsupported MLS message body".into())),
553        };
554
555        let processed: ProcessedMessage = self
556            .group
557            .process_message(self.crypto.as_ref(), protocol_msg)
558            .map_err(Error::mls)?;
559
560        let out = match processed.into_content() {
561            ProcessedMessageContent::ApplicationMessage(app) => {
562                let pt = app.into_bytes();
563                // CR-6: for v=2 application envelopes the wire-contract validator can't
564                // check `content_hash` (the hash is over plaintext, which it didn't have).
565                // We can now: verify SHA-256(pt) == env.content_hash and reject mismatches.
566                // For v=1 envelopes the wire-contract validator already checked the
567                // ciphertext-based hash, so no extra work here.
568                if env.v >= 2 {
569                    let computed = crate::message::hash_application_plaintext(&pt);
570                    if computed != env.content_hash {
571                        return Err(Error::Invalid(
572                            "v=2 application content_hash mismatch".into(),
573                        ));
574                    }
575                }
576                Some(IncomingMessage {
577                    conversation_id: self.id,
578                    sender_device: env.sender_device.clone(),
579                    epoch: env.epoch,
580                    hlc: env.hlc,
581                    plaintext: pt,
582                    content_hash: env.content_hash,
583                })
584            }
585            ProcessedMessageContent::StagedCommitMessage(staged) => {
586                self.group
587                    .merge_staged_commit(self.crypto.as_ref(), *staged)
588                    .map_err(Error::mls)?;
589                self.meta.epoch = self.epoch();
590                self.meta.member_count = self.group.members().count() as u32;
591                None
592            }
593            ProcessedMessageContent::ProposalMessage(_)
594            | ProcessedMessageContent::ExternalJoinProposalMessage(_) => {
595                // Proposals are buffered by OpenMLS until the next Commit; nothing to surface
596                // to the application.
597                None
598            }
599        };
600
601        self.cursor.advance(
602            env.epoch,
603            env.sender_device.clone(),
604            env.seq,
605            env.hlc,
606            now_ms,
607        );
608        Ok(out)
609    }
610
611    /// Export a derived secret keyed to this group's current epoch ([CR-8]).
612    ///
613    /// Wraps `MlsGroup::export_secret` (the MLS exporter, RFC 9420 §8.5) and surfaces the
614    /// bytes in a `Zeroizing<Vec<u8>>` so the local copy is wiped on drop. Used by the host
615    /// to seed:
616    ///   * the ephemeral channel (`ping/ephemeral`, §5.4 of the architecture)
617    ///   * call media keys (`ping/calls/media/{call_id}`, §7.2)
618    ///   * call-ephemeral framer keys (`ping/calls/ephemeral/{call_id}`, §7.5)
619    ///
620    /// `label` should use the documented `ping/*` namespacing convention. There is no
621    /// runtime enforcement — cross-binding parity is enforced by conformance fixtures
622    /// pinning specific label strings.
623    ///
624    /// Output is the secret. Callers MUST treat the buffer as a secret: never log, never
625    /// persist unencrypted. The wrapper zeroes our local copy on drop; the caller is
626    /// responsible for zeroing any copy they make.
627    pub fn export_secret(
628        &self,
629        label: &str,
630        context: &[u8],
631        length: usize,
632    ) -> Result<Zeroizing<Vec<u8>>> {
633        if length == 0 {
634            return Err(Error::Invalid("export_secret length must be > 0".into()));
635        }
636        // Soft cap to prevent runaway allocations from a malformed caller. Real labels never
637        // need more than ~64 bytes (AES-256 key + 96-bit nonce + slack); 1 KiB is generous.
638        if length > 1024 {
639            return Err(Error::Invalid(
640                "export_secret length exceeds 1024-byte cap".into(),
641            ));
642        }
643        let bytes = self
644            .group
645            .export_secret(self.crypto.as_ref(), label, context, length)
646            .map_err(Error::mls)?;
647        Ok(Zeroizing::new(bytes))
648    }
649
650    /// [CR-7] Export a portable snapshot of this group's MLS state.
651    ///
652    /// Walks the provider's working set, picks every entry whose key references this
653    /// group's id, and bundles them with format metadata. Returns CBOR-encoded bytes
654    /// suitable for inclusion in:
655    ///   * `LinkingTicket.catchup_snapshot.conversation_metas[i].group_state_bytes`
656    ///     (via [CR-13] — host calls this and passes the bytes through);
657    ///   * `IdentityBackup.device_group_snapshot` (the Permissive-recovery path per
658    ///     `docs/architecture/recovery.md`).
659    ///
660    /// Returns `Err` if the encoded snapshot exceeds [`GROUP_SNAPSHOT_HARD_CAP`].
661    /// Output is wrapped in `Zeroizing` because the bytes contain past epoch secrets;
662    /// the caller's copy on the FFI side is the host's responsibility to wipe.
663    pub fn export_state_snapshot(&self, now_ms: u64) -> Result<Zeroizing<Vec<u8>>> {
664        let entries = self.crypto.group_scoped_entries(&self.id.0);
665        let snap = GroupStateSnapshot {
666            v: GROUP_SNAPSHOT_VERSION,
667            group_id: self.id,
668            openmls_storage_version: openmls_traits::storage::CURRENT_VERSION,
669            snapshot_created_at_ms: now_ms,
670            entries: entries
671                .into_iter()
672                .map(|(key, value)| GroupSnapshotEntry { key, value })
673                .collect(),
674        };
675        Ok(Zeroizing::new(snap.encode()?))
676    }
677
678    /// Look up the leaf index this device controls, if known ([CR-2]).
679    ///
680    /// Returns the locally-tracked leaf for `device_id`. Only populated for devices we
681    /// added via [`Self::add_members`] or for our own leaf via [`Self::create`] /
682    /// [`Self::join`]. Devices a peer admitted on our behalf are not in this map.
683    pub fn leaf_index_of(&self, device_id: &DeviceId) -> Option<u32> {
684        self.device_leaves.get(device_id).copied()
685    }
686
687    /// Synchronously capture everything [`ConversationSnapshot::flush`]
688    /// needs to persist this conversation, so a caller can DROP the
689    /// `conversations` lock BEFORE awaiting the async writes.
690    ///
691    /// Holding a `parking_lot` guard across `.await` is a latent bug: on
692    /// the single-threaded wasm worker, a second client call that lands
693    /// while the first is suspended (a waiting writer + a new reader)
694    /// makes `parking_lot` try to PARK, and its wasm stub `panic!`s with
695    /// "Parking not supported on this platform" — poisoning the module.
696    /// Splitting the synchronous capture (under the lock) from the async
697    /// flush (lock released) removes that hazard everywhere the snapshot
698    /// runs for a conversation that lives inside the shared map. The
699    /// capture is a consistent point-in-time view (cursor + meta + leaves
700    /// + the Arc'd provider/storage handles).
701    pub(crate) fn snapshot_inputs(&self) -> Result<ConversationSnapshot> {
702        // [CR-2] Stable BTreeMap-of-pairs encoding → canonical CBOR so
703        // every platform decodes identical bytes.
704        let leaves_vec: Vec<(DeviceId, u32)> = self
705            .device_leaves
706            .iter()
707            .map(|(d, i)| (d.clone(), *i))
708            .collect();
709        Ok(ConversationSnapshot {
710            id: self.id,
711            crypto: self.crypto.clone(),
712            storage: self.storage.clone(),
713            cursor: self.cursor.encode()?,
714            meta: codec::encode(&self.meta)?,
715            device_leaves: codec::encode(&leaves_vec)?,
716        })
717    }
718
719    /// Persist this conversation's state. Convenience wrapper used by
720    /// call sites that hold an OWNED `Conversation` (not borrowed from the
721    /// shared map) — e.g. just-created/just-joined conversations before
722    /// they're inserted, where no lock is held across the await. Map-
723    /// resident callers MUST instead use `snapshot_inputs()` + drop the
724    /// guard + `flush().await` (see client.rs) to avoid the wasm parking
725    /// panic described on `snapshot_inputs`.
726    pub(crate) async fn snapshot_to_storage(&self) -> Result<()> {
727        self.snapshot_inputs()?.flush().await
728    }
729}
730
731/// Point-in-time, lock-free snapshot of a [`Conversation`]'s persistable
732/// state. Produced synchronously by [`Conversation::snapshot_inputs`] (so
733/// the `conversations` lock can be dropped) and flushed asynchronously by
734/// [`Self::flush`].
735pub(crate) struct ConversationSnapshot {
736    id: ConversationId,
737    crypto: Arc<PersistentMlsProvider>,
738    storage: Arc<dyn Storage>,
739    cursor: Vec<u8>,
740    meta: Vec<u8>,
741    device_leaves: Vec<u8>,
742}
743
744impl ConversationSnapshot {
745    /// Flush the captured state to storage. Safe to `.await` with NO
746    /// `conversations` lock held — it only touches the Arc'd provider +
747    /// storage handles, never the shared map.
748    pub(crate) async fn flush(self) -> Result<()> {
749        // [CR-4] Flush the MLS working set to the configured backend (no-op
750        // for `StorageBackend::Memory`). MUST happen on every state-changing
751        // op so a cold restart — iOS NSE, web SW — finds the latest epoch on
752        // disk. `checkpoint_async` is required for the WASM `IndexedDb`
753        // backend (IDB is async-only); native Memory / Sqlite paths await
754        // trivially since their I/O is sync internally.
755        self.crypto
756            .checkpoint_async()
757            .await
758            .map_err(|e| Error::Storage(format!("checkpoint: {e}")))?;
759
760        let hex = self.id.as_hex();
761        self.storage.put("cursors", &hex, self.cursor).await?;
762        self.storage
763            .put("groups", &format!("{hex}/meta"), self.meta)
764            .await?;
765        // [CR-2] device→leaf map, persisted alongside meta + cursor so
766        // revoke_device works after a cold restart.
767        self.storage
768            .put("device_leaves", &hex, self.device_leaves)
769            .await?;
770        Ok(())
771    }
772}
773
774/// Both halves of an Add commit. The Commit goes on the conversation channel; the Welcome is
775/// delivered to the new members via whatever out-of-band path the host uses (often the same
776/// transport, addressed to the new device's mailbox).
777#[derive(Debug, Clone)]
778pub struct AddOutcome {
779    pub commit: MessageEnvelope,
780    pub welcome: MessageEnvelope,
781}
782
783fn mls_message_out_bytes(m: MlsMessageOut) -> Result<Vec<u8>> {
784    m.tls_serialize_detached().map_err(Error::mls)
785}