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 pub(crate) async fn snapshot_to_storage(&self) -> Result<()> {
688 let blob = self
689 .group
690 .export_secret(self.crypto.as_ref(), "ping-snapshot-marker", &[], 32)
691 .ok();
692 // OpenMLS persists the group via its own keystore inside `crypto`. We only need to
693 // record meta + cursor here; the group itself is recovered by re-opening with the
694 // same provider on next launch.
695 let _ = blob; // intentionally unused — present for future binary-snapshot path
696
697 // [CR-4] Flush the MLS working set to the configured backend (no-op for
698 // `StorageBackend::Memory`). MUST happen on every state-changing op so a cold
699 // restart — iOS NSE, web SW — finds the latest epoch on disk.
700 //
701 // `checkpoint_async` is required for the WASM `IndexedDb` backend (IDB is
702 // async-only); native Memory / Sqlite paths await trivially since their
703 // I/O is sync internally.
704 self.crypto
705 .checkpoint_async()
706 .await
707 .map_err(|e| Error::Storage(format!("checkpoint: {e}")))?;
708
709 let cursor = self.cursor.encode()?;
710 self.storage
711 .put("cursors", &self.id.as_hex(), cursor)
712 .await?;
713 let meta = codec::encode(&self.meta)?;
714 self.storage
715 .put("groups", &format!("{}/meta", self.id.as_hex()), meta)
716 .await?;
717 // [CR-2] Persist the device→leaf map so revoke_device works after a cold restart.
718 // Use a stable BTreeMap-of-pairs encoding to guarantee canonical CBOR — every
719 // platform that decodes this hits identical bytes.
720 let leaves_vec: Vec<(DeviceId, u32)> = self
721 .device_leaves
722 .iter()
723 .map(|(d, i)| (d.clone(), *i))
724 .collect();
725 let leaves_bytes = codec::encode(&leaves_vec)?;
726 self.storage
727 .put("device_leaves", &self.id.as_hex(), leaves_bytes)
728 .await?;
729 Ok(())
730 }
731}
732
733/// Both halves of an Add commit. The Commit goes on the conversation channel; the Welcome is
734/// delivered to the new members via whatever out-of-band path the host uses (often the same
735/// transport, addressed to the new device's mailbox).
736#[derive(Debug, Clone)]
737pub struct AddOutcome {
738 pub commit: MessageEnvelope,
739 pub welcome: MessageEnvelope,
740}
741
742fn mls_message_out_bytes(m: MlsMessageOut) -> Result<Vec<u8>> {
743 m.tls_serialize_detached().map_err(Error::mls)
744}