ping_core/client.rs
1//! `MessagingClient` — top-level handle. Owns the OpenMLS provider, identity, local device,
2//! and the set of open conversations.
3//!
4//! All operations are `async`. The intent is that the FFI generators emit Swift `async`,
5//! Kotlin `suspend`, and the WASM glue exposes Promises.
6
7use openmls::framing::MlsMessageOut;
8use openmls::prelude::{
9 tls_codec::Serialize as TlsSerialize, BasicCredential, Ciphersuite, CredentialWithKey,
10 KeyPackageBuilder,
11};
12use openmls_basic_credential::SignatureKeyPair;
13use openmls_traits::OpenMlsProvider;
14use parking_lot::RwLock;
15use ping_mls_store::{PersistentMlsProvider, StorageBackend};
16use std::collections::HashMap;
17use std::sync::Arc;
18use zeroize::Zeroizing;
19
20use crate::{
21 codec,
22 conversation::{Conversation, ConversationId, ConversationMeta, MemberInfo},
23 device::{
24 CatchupAppEventEntry, CatchupConversationEntry, CatchupSnapshot, DeviceId, DeviceInfo,
25 LinkingTicket, LocalDevice, CATCHUP_SNAPSHOT_SOFT_CAP, CATCHUP_SNAPSHOT_VERSION,
26 },
27 error::{Error, Result},
28 identity::{Identity, UserId},
29 message::{IncomingMessage, MessageEnvelope, MessageKind},
30 storage::Storage,
31 sync::SyncCursor,
32 transport::Transport,
33};
34
35const DEFAULT_CIPHERSUITE: Ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
36
37/// Whether a transport send failure is a DEFINITE server rejection (the server
38/// returned an HTTP error response) rather than an ambiguous network failure
39/// where the server may actually have applied the message.
40///
41/// This decides whether a staged Commit can be safely rolled back: only a
42/// definite rejection guarantees the server did NOT apply it. The host transport
43/// embeds the HTTP status in the error string (e.g. "network: http 409"); 4xx +
44/// known server error codes are definite, while timeouts / "fetch failed" / 5xx
45/// are ambiguous (could be a masked success). When unsure we treat it as
46/// ambiguous (return false) — merging on a masked success is recoverable, but
47/// rolling back a Commit the server DID apply strands us a step behind forever.
48fn is_definite_rejection(err: &Error) -> bool {
49 let Error::Transport(s) = err else {
50 return false;
51 };
52 let s = s.to_ascii_lowercase();
53 s.contains("http 4")
54 || s.contains("epoch_advanced")
55 || s.contains("invalid_request")
56 || s.contains("not_found")
57 || s.contains("conflict")
58 || s.contains("forbidden")
59 || s.contains("unauthorized")
60}
61
62/// Per-chat result reported by [`MessagingClient::admit_device_to_chats`].
63#[derive(Debug, Clone)]
64pub struct AdmitChatOutcome {
65 pub conversation_id: ConversationId,
66 pub status: AdmitChatStatus,
67}
68
69#[derive(Debug, Clone)]
70pub enum AdmitChatStatus {
71 /// The new device is now an MLS leaf in this chat. Both the Commit
72 /// and the addressed Welcome have been sent.
73 Admitted,
74 /// We chose not to admit (e.g. the conversation is a DeviceGroup,
75 /// which was already handled at linking-ticket build time).
76 Skipped { reason: String },
77 /// MLS or transport rejected the admission. `error` is the underlying
78 /// message — typically a `transport error: ...` or an OpenMLS error.
79 Failed { error: String },
80}
81
82#[derive(Debug)]
83pub struct ClientConfig {
84 pub identity: Identity,
85 pub device_label: String,
86 pub storage: Arc<dyn Storage>,
87 pub transport: Arc<dyn Transport>,
88 /// Wall clock in ms. Pulled from the host so we can use a synthetic clock in tests.
89 pub now_ms: u64,
90 /// [CR-4] OpenMLS-provider backend. Defaults to in-memory; iOS NSE and web SW
91 /// cold-start paths MUST pass `StorageBackend::Sqlite { path, encryption_key }`
92 /// (native) or `StorageBackend::IndexedDb { db_name }` (WASM, when that lands).
93 /// See `docs/design/CR4_CR7_PERSISTENCE.md`.
94 pub storage_backend: StorageBackend,
95 /// Optional 32-byte Ed25519 secret key the SDK should use as the
96 /// device signing key. When set AND no `LocalDevice` is yet
97 /// persisted in `storage`, the SDK constructs its first
98 /// `LocalDevice` from this key instead of generating a fresh
99 /// random one — so `device_id = SHA-256(public_key_of(secret))`
100 /// is fully determined by what the host provided.
101 ///
102 /// Use case: align the SDK's `device_id` (which it stamps into
103 /// every envelope's `sender_device` field) with an externally-
104 /// computed device id — typically `SHA-256(device_signing_pubkey)`
105 /// in the host's auth layer, where the JWT carries that same
106 /// value as its `device_id` claim. Without this alignment, a
107 /// server that validates `envelope.sender_device ==
108 /// jwt.device_id` would reject every send.
109 ///
110 /// Ignored on re-init (when storage already has a persisted
111 /// `LocalDevice`) so the device identity remains stable across
112 /// restarts.
113 pub device_signing_secret_key: Option<[u8; 32]>,
114}
115
116impl ClientConfig {
117 /// Construct a config with `StorageBackend::Memory` — convenient for tests and
118 /// the existing v0.1 in-memory flow.
119 pub fn new_in_memory(
120 identity: Identity,
121 device_label: String,
122 storage: Arc<dyn Storage>,
123 transport: Arc<dyn Transport>,
124 now_ms: u64,
125 ) -> Self {
126 Self {
127 identity,
128 device_label,
129 storage,
130 transport,
131 now_ms,
132 storage_backend: StorageBackend::Memory,
133 device_signing_secret_key: None,
134 }
135 }
136}
137
138pub struct MessagingClient {
139 pub(crate) identity: Identity,
140 pub(crate) local_device: LocalDevice,
141 pub(crate) crypto: Arc<PersistentMlsProvider>,
142 pub(crate) signing: Arc<SignatureKeyPair>,
143 pub(crate) storage: Arc<dyn Storage>,
144 pub(crate) transport: Arc<dyn Transport>,
145 conversations: RwLock<HashMap<ConversationId, Conversation>>,
146 /// Conversations detected as STRANDED during catch-up: a full page of
147 /// events was fetched but nothing could be applied (every envelope failed
148 /// to decrypt / was wrong-epoch), meaning a Commit was missed and the group
149 /// can no longer advance from local state. The host polls
150 /// [`MessagingClient::stranded_conversations`] after a sync and recovers
151 /// each (re-Welcome / same-user state snapshot). Cleared automatically once
152 /// the conversation makes progress again.
153 stranded: RwLock<std::collections::HashSet<ConversationId>>,
154}
155
156impl std::fmt::Debug for MessagingClient {
157 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158 f.debug_struct("MessagingClient")
159 .field("user_id", &self.identity.user_id().as_hex())
160 .field("device_id", &self.local_device.device_id.as_hex())
161 .field("conversation_count", &self.conversations.read().len())
162 .finish()
163 }
164}
165
166impl MessagingClient {
167 /// Initialise. Creates a new local device if none is recorded in storage; otherwise rehydrates.
168 pub async fn init(cfg: ClientConfig) -> Result<Arc<Self>> {
169 // [CR-4] OpenMLS provider is now pluggable. For `StorageBackend::Memory` this
170 // behaves like the old `OpenMlsRustCrypto::default()`. For `Sqlite`, the
171 // working set is hydrated from the on-disk blob; subsequent `checkpoint` calls
172 // flush it back. iOS NSE / web SW cold-start lives here.
173 //
174 // Use `open_async` so the WASM `StorageBackend::IndexedDb` variant can read
175 // its snapshot blob through the host-supplied `AsyncBlobStore` before
176 // returning — without this, the provider's `MemoryStorage` would be empty
177 // and `MlsGroup::load` would silently return `None` for every group on
178 // cold restart, breaking chat persistence across reloads. Native targets
179 // (Memory + Sqlite) delegate to the sync path under the hood, so the
180 // `.await` is free there.
181 let crypto = PersistentMlsProvider::open_async(cfg.storage_backend.clone())
182 .await
183 .map_err(|e| Error::Storage(format!("provider open: {e}")))?;
184 let local_device = match cfg.storage.get("device", "local").await? {
185 Some(bytes) => decode_local_device(&bytes, cfg.identity.user_id().clone())?,
186 None => {
187 // First-init path. If the host supplied a signing secret
188 // (typically to align the device_id with their auth
189 // layer), use it; otherwise mint a fresh random key.
190 // Either way, the constructed `LocalDevice` is
191 // immediately persisted so future inits load from
192 // storage without consulting the override again.
193 let dev = match cfg.device_signing_secret_key.as_ref() {
194 Some(secret) => LocalDevice::from_signing_secret(
195 cfg.identity.user_id().clone(),
196 cfg.device_label,
197 cfg.now_ms,
198 secret,
199 ),
200 None => LocalDevice::generate(
201 cfg.identity.user_id().clone(),
202 cfg.device_label,
203 cfg.now_ms,
204 ),
205 };
206 let bytes = encode_local_device(&dev)?;
207 cfg.storage.put("device", "local", bytes).await?;
208 dev
209 }
210 };
211
212 // [CR-4] MLS signing keypair MUST be stable across cold restarts — otherwise the
213 // leaf-key stored on disk no longer matches the per-client key on re-init, and any
214 // send-after-restart silently misroutes. We derive deterministically from the
215 // already-persistent `LocalDevice::signing` (Ed25519, 32 raw bytes), and the
216 // ciphersuite's signature scheme is Ed25519 too — so the device signing key and the
217 // MLS leaf signing key are the same bytes. The MLS storage provider also receives
218 // a copy via `store()` so OpenMLS-internal lookups (process_message, etc.) succeed.
219 let signing = {
220 let sk_bytes = local_device.signing.to_bytes().to_vec();
221 let pk_bytes = local_device.signing.verifying_key().to_bytes().to_vec();
222 let kp = SignatureKeyPair::from_raw(
223 DEFAULT_CIPHERSUITE.signature_algorithm(),
224 sk_bytes,
225 pk_bytes,
226 );
227 kp.store(crypto.storage()).map_err(Error::mls)?;
228 Arc::new(kp)
229 };
230
231 let client = Arc::new(Self {
232 identity: cfg.identity,
233 local_device,
234 crypto,
235 signing,
236 storage: cfg.storage,
237 transport: cfg.transport,
238 conversations: RwLock::new(HashMap::new()),
239 stranded: RwLock::new(std::collections::HashSet::new()),
240 });
241
242 client.rehydrate_conversations(cfg.now_ms).await?;
243
244 // [CR-10] Ensure the DeviceGroup exists at init, not lazily inside
245 // build_linking_ticket. Single-device users need somewhere to write
246 // personal events (drafts, read pointers, notes, vault wrapper)
247 // even before they pair a second device. Lazy creation in
248 // build_linking_ticket left them with no DG → no place for
249 // personal state to land.
250 //
251 // Idempotent — re-init after a cold restart finds the DG via
252 // rehydrate_conversations and this becomes a no-op.
253 client.ensure_device_group(cfg.now_ms).await?;
254
255 Ok(client)
256 }
257
258 /// Open a client that can DECRYPT but never persists — the notification
259 /// preview path.
260 ///
261 /// The motivating case is the iOS Notification Service Extension: a second
262 /// process, woken per push, that has to turn one inbound envelope into a
263 /// sender and a message preview for the lock screen, while the main app
264 /// keeps running and keeps writing.
265 ///
266 /// [`init`](Self::init) cannot be used for that, and not merely as a matter
267 /// of taste. Persistence here checkpoints the ENTIRE MLS working set as one
268 /// blob (`ping_mls_store`), so two writers do not merge — whichever flushes
269 /// second replaces everything the other did, discarding epochs and ratchet
270 /// state wholesale. `init` also *creates* state (minting a `LocalDevice`,
271 /// ensuring the DeviceGroup exists), which is exactly the behaviour a
272 /// read-only opener must not have.
273 ///
274 /// So this constructor is the same hydration with every writing path
275 /// removed:
276 /// * the storage backend MUST be read-only, checked here rather than
277 /// trusted, so a caller cannot reach this code with a writable store;
278 /// * a missing `LocalDevice` is an ERROR, never a fresh identity — this
279 /// process joins an existing installation or it does nothing;
280 /// * the DeviceGroup is not created. A reader that finds none simply has
281 /// no DeviceGroup, which costs it nothing (personal-state sync is the
282 /// app's job, not a notification's).
283 ///
284 /// The MLS signing keypair is still `store`d, but only into the in-memory
285 /// `MemoryStorage` the provider hands OpenMLS; with a read-only backend
286 /// that never reaches disk.
287 ///
288 /// Pair with [`preview_envelope`](Self::preview_envelope), and give it a
289 /// read-only host `Storage` too — this guards the MLS blob, not the host's
290 /// own key-value writes.
291 pub async fn open_read_only(cfg: ClientConfig) -> Result<Arc<Self>> {
292 let crypto = PersistentMlsProvider::open_async(cfg.storage_backend.clone())
293 .await
294 .map_err(|e| Error::Storage(format!("provider open: {e}")))?;
295 if !crypto.is_read_only() {
296 return Err(Error::Invalid(
297 "open_read_only requires StorageBackend::SqliteReadOnly".into(),
298 ));
299 }
300
301 let local_device = match cfg.storage.get("device", "local").await? {
302 Some(bytes) => decode_local_device(&bytes, cfg.identity.user_id().clone())?,
303 None => {
304 return Err(Error::Storage(
305 "open_read_only: no LocalDevice in storage; \
306 a read-only client never mints one"
307 .into(),
308 ))
309 }
310 };
311
312 let signing = {
313 let sk_bytes = local_device.signing.to_bytes().to_vec();
314 let pk_bytes = local_device.signing.verifying_key().to_bytes().to_vec();
315 let kp = SignatureKeyPair::from_raw(
316 DEFAULT_CIPHERSUITE.signature_algorithm(),
317 sk_bytes,
318 pk_bytes,
319 );
320 kp.store(crypto.storage()).map_err(Error::mls)?;
321 Arc::new(kp)
322 };
323
324 let client = Arc::new(Self {
325 identity: cfg.identity,
326 local_device,
327 crypto,
328 signing,
329 storage: cfg.storage,
330 transport: cfg.transport,
331 conversations: RwLock::new(HashMap::new()),
332 stranded: RwLock::new(std::collections::HashSet::new()),
333 });
334
335 client.rehydrate_conversations(cfg.now_ms).await?;
336 Ok(client)
337 }
338
339 /// Decrypt ONE application envelope for display, changing nothing durable.
340 ///
341 /// The counterpart to [`open_read_only`](Self::open_read_only): applies the
342 /// envelope to this process's private copy of the MLS state and returns the
343 /// plaintext, with no flush of either the MLS blob or the host's key-value
344 /// store. The ratchet advance is deliberately thrown away — the owning
345 /// process still holds the un-advanced state and decrypts the same message
346 /// again, independently, when it next runs. Two readers of one snapshot;
347 /// neither can desynchronise the other.
348 ///
349 /// Handshake traffic returns `Ok(None)` without being processed. A Commit
350 /// moves the group to a new epoch and a Welcome joins one, and both are
351 /// state changes whose whole point is to be durable — applying either in a
352 /// process that discards its writes would burn the message for a preview
353 /// nobody could act on, and a self-removing Commit would tear down group
354 /// state the owner still needs. There is nothing to show for them anyway:
355 /// they carry no user-visible text.
356 ///
357 /// `Ok(None)` also covers "already applied" (the hydrated cursor rejects a
358 /// replay) and "not a conversation this device knows".
359 pub fn preview_envelope(
360 &self,
361 env: &MessageEnvelope,
362 now_ms: u64,
363 ) -> Result<Option<IncomingMessage>> {
364 debug_assert!(
365 self.crypto.is_read_only(),
366 "preview_envelope is only sound on a read-only provider"
367 );
368 if env.kind != MessageKind::Application {
369 return Ok(None);
370 }
371 if !self.conversations.read().contains_key(&env.conversation_id) {
372 return Ok(None);
373 }
374 let (out, _removed) = self.apply_envelope_in_memory(env, now_ms)?;
375 Ok(out)
376 }
377
378 /// [CR-10] Idempotently ensures this user's DeviceGroup exists in
379 /// `self.conversations`. Called from `init` (so single-device users
380 /// have a DG immediately) and from `build_linking_ticket` (the legacy
381 /// lazy path; still safe to call when the DG already exists, since
382 /// rehydrate_conversations would have re-attached it before init
383 /// returned).
384 ///
385 /// The DeviceGroup is a one-leaf MLS group at creation time —
386 /// `add_members` (called by `build_linking_ticket` when a second
387 /// device pairs in) is what grows it. We persist the snapshot so a
388 /// cold restart picks it up before this function runs again.
389 pub(crate) async fn ensure_device_group(self: &Arc<Self>, now_ms: u64) -> Result<()> {
390 let dg_id = device_group_id_for(self.identity.user_id());
391 if self.conversations.read().contains_key(&dg_id) {
392 return Ok(());
393 }
394 let mut new_dg = Conversation::create(
395 dg_id,
396 Some("device-group".into()),
397 self.local_device.device_id.clone(),
398 self.identity.user_id(),
399 self.crypto.clone(),
400 self.signing.clone(),
401 self.storage.clone(),
402 now_ms,
403 )?;
404 new_dg.meta.is_device_group = true;
405 new_dg.snapshot_to_storage().await?;
406 self.conversations.write().insert(dg_id, new_dg);
407 Ok(())
408 }
409
410 pub fn user_id(&self) -> UserId {
411 self.identity.user_id().clone()
412 }
413 /// Export this client's account identity (the Ed25519 seed, CBOR-wrapped —
414 /// same format `Identity::import` / `MessagingClient::init(identity_export)`
415 /// accept). SECRET. Hosts use this to TRANSFER the account identity to a
416 /// newly-linked device over the sealed linking channel, so every linked
417 /// device shares ONE `user_id` and `IncomingMessage.sender_user_id` equals
418 /// the local `user_id()` for any of the account's own devices — the basis
419 /// for cross-device self-attribution. Never log or persist in cleartext.
420 pub fn export_identity(&self) -> Zeroizing<Vec<u8>> {
421 self.identity.export()
422 }
423 pub fn device_id(&self) -> DeviceId {
424 self.local_device.device_id.clone()
425 }
426 pub fn device_info(&self, now_ms: u64) -> DeviceInfo {
427 self.local_device.info(now_ms)
428 }
429
430 /// Generate a fresh KeyPackage to publish to the directory. Hosts call this when registering
431 /// a device or topping up the directory.
432 ///
433 /// `build()` writes the private init + encryption keys into the storage
434 /// provider's working set, but ON ITS OWN that write is NOT durable: on the
435 /// WASM/AsyncBlob backend the working set only reaches IndexedDB at the next
436 /// `checkpoint_async`, so a page reload before the next state-changing op
437 /// loses the private keys while the PUBLIC KeyPackage has already been
438 /// published. Any Welcome later bound to that KeyPackage then fails with
439 /// "No matching key package was found in the key store" (breaking calls and
440 /// every invite to this device). So we checkpoint HERE, before returning the
441 /// bytes the host will publish — the published KeyPackage is durable the
442 /// instant it leaves this function. Hence `async`.
443 pub async fn fresh_key_package(&self) -> Result<Vec<u8>> {
444 self.build_key_package(false).await
445 }
446
447 /// Generate a fresh LAST-RESORT KeyPackage.
448 ///
449 /// A normal KeyPackage is single-use: once a Welcome consumes it, the
450 /// private init key is deleted and the directory entry is burned. When a
451 /// device's published pool runs dry, every invite to that device hard-fails
452 /// ("user unavailable") until it comes online and tops up — the classic
453 /// "I added them but they never got it" complaint.
454 ///
455 /// A last-resort KeyPackage (RFC 9420 §10) carries the `LastResort`
456 /// extension, signalling the server it may serve this KeyPackage MORE THAN
457 /// ONCE when no single-use KeyPackages remain. The host publishes exactly
458 /// one per device; the server keeps it as the always-available fallback so
459 /// an invite never fails purely because the pool emptied. Forward secrecy
460 /// for the joining epoch is slightly weaker (the init key is reused until
461 /// replenishment), which is the accepted RFC trade-off for availability.
462 pub async fn fresh_last_resort_key_package(&self) -> Result<Vec<u8>> {
463 self.build_key_package(true).await
464 }
465
466 async fn build_key_package(&self, last_resort: bool) -> Result<Vec<u8>> {
467 let credential_with_key = CredentialWithKey {
468 credential: BasicCredential::new(self.identity.user_id().0.clone()).into(),
469 signature_key: self.signing.public().to_vec().into(),
470 };
471 let mut builder = KeyPackageBuilder::new()
472 // Advertise the group-name extension capability so a later
473 // `set_name` (rename / avatar change via GroupContextExtensions)
474 // passes openmls' per-member capability check on every group this
475 // device joins. For a last-resort KeyPackage the leaf must also
476 // advertise the LastResort extension it carries, or openmls
477 // validation rejects the KeyPackage at add-member time. See
478 // `conversation::ping_leaf_capabilities_for`.
479 .leaf_node_capabilities(crate::conversation::ping_leaf_capabilities_for(last_resort));
480 if last_resort {
481 builder = builder.mark_as_last_resort();
482 }
483 let bundle = builder
484 .build(
485 DEFAULT_CIPHERSUITE,
486 self.crypto.as_ref(),
487 self.signing.as_ref(),
488 credential_with_key,
489 )
490 .map_err(Error::mls)?;
491 // Durably persist the freshly-generated private keys BEFORE the public
492 // KeyPackage is handed to the host to publish (see doc comment).
493 self.crypto
494 .checkpoint_async()
495 .await
496 .map_err(|e| Error::Storage(format!("key package checkpoint: {e}")))?;
497 // KeyPackages are serialized as MlsMessage(KeyPackage) per the MLS framing spec.
498 let msg: MlsMessageOut = bundle.key_package().clone().into();
499 msg.tls_serialize_detached().map_err(Error::mls)
500 }
501
502 /// Create a new conversation owned by this client (and seeded with a single member: this device).
503 pub async fn create_conversation(
504 self: &Arc<Self>,
505 name: Option<String>,
506 now_ms: u64,
507 ) -> Result<ConversationId> {
508 self.create_conversation_with_id(ConversationId::new(), name, now_ms)
509 .await
510 }
511
512 /// Create an ephemeral per-call MLS group. Identical to
513 /// [`create_conversation`] except the id carries the `0xFF 0xCC` call-group
514 /// sentinel (see [`ConversationId::new_call_group`]), so every device — the
515 /// callee joining via a name-stripped Welcome, a freshly-linked sibling —
516 /// recognises it as a call group by its id alone and keeps it out of the chat
517 /// list, with no dependence on the (creator-local) `call:` name or a
518 /// per-device registry. Hosts MUST mint call groups via this method rather
519 /// than `create_conversation(name: "call:…")` to get the structural guarantee.
520 pub async fn create_call_conversation(
521 self: &Arc<Self>,
522 name: Option<String>,
523 now_ms: u64,
524 ) -> Result<ConversationId> {
525 self.create_conversation_with_id(ConversationId::new_call_group(), name, now_ms)
526 .await
527 }
528
529 async fn create_conversation_with_id(
530 self: &Arc<Self>,
531 id: ConversationId,
532 name: Option<String>,
533 now_ms: u64,
534 ) -> Result<ConversationId> {
535 let convo = Conversation::create(
536 id,
537 name,
538 self.local_device.device_id.clone(),
539 self.identity.user_id(),
540 self.crypto.clone(),
541 self.signing.clone(),
542 self.storage.clone(),
543 now_ms,
544 )?;
545 convo.snapshot_to_storage().await?;
546 self.conversations.write().insert(id, convo);
547 Ok(id)
548 }
549
550 /// Join via a Welcome bundled in a [`MessageEnvelope`] of kind `Welcome`.
551 pub async fn join_conversation(
552 self: &Arc<Self>,
553 welcome_envelope: &MessageEnvelope,
554 now_ms: u64,
555 ) -> Result<ConversationId> {
556 if welcome_envelope.kind != MessageKind::Welcome {
557 return Err(Error::Invalid("expected Welcome envelope".into()));
558 }
559 let convo = Conversation::join(
560 &welcome_envelope.payload,
561 self.local_device.device_id.clone(),
562 self.crypto.clone(),
563 self.signing.clone(),
564 self.storage.clone(),
565 now_ms,
566 )?;
567 let id = convo.id();
568 convo.snapshot_to_storage().await?;
569 self.conversations.write().insert(id, convo);
570 // Joining (re-Welcome) recovers a previously-stranded conversation.
571 self.stranded.write().remove(&id);
572 Ok(id)
573 }
574
575 /// Conversations detected as STRANDED during catch-up — a Commit was missed
576 /// and the group can no longer advance from local state. The host should
577 /// recover each (re-Welcome from a peer, or a same-user state-snapshot
578 /// import) so messages start delivering again. The set self-clears as a
579 /// conversation makes progress or is re-joined.
580 pub fn stranded_conversations(&self) -> Vec<ConversationId> {
581 let mut ids: Vec<ConversationId> = self.stranded.read().iter().copied().collect();
582 ids.sort_by_key(|a| a.0);
583 ids
584 }
585
586 pub fn list_conversations(&self) -> Vec<ConversationMeta> {
587 self.conversations
588 .read()
589 .values()
590 .map(|c| c.meta.clone())
591 .collect()
592 }
593
594 /// Member roster for a conversation, recovered locally from the MLS
595 /// group's leaf credentials. Empty if the conversation is unknown to
596 /// this client. Lets any device (including one that just joined via a
597 /// linking Welcome) resolve a 1:1 peer's `UserId` without the
598 /// out-of-band `ping.profile` re-send.
599 pub fn members(&self, conv_id: ConversationId) -> Vec<MemberInfo> {
600 self.conversations
601 .read()
602 .get(&conv_id)
603 .map(|c| c.members())
604 .unwrap_or_default()
605 }
606
607 /// Send an application message. Returns once the envelope has been handed to the transport.
608 pub async fn send(
609 &self,
610 conv_id: ConversationId,
611 plaintext: Vec<u8>,
612 now_ms: u64,
613 ) -> Result<MessageEnvelope> {
614 // FAST-FAIL on a stranded conversation. A missed Commit forked our epoch,
615 // so anything we encrypt now is at a stale epoch: the server either 409s
616 // it (epoch occupied) or accepts a frame the peers can never decrypt — a
617 // durable-but-undelivered message. Surface `EpochStranded` immediately so
618 // the host renders "needs repair" (and recovers via
619 // `stranded_conversations()` → re-Welcome / snapshot import, which clears
620 // the mark) instead of silently 409-looping every send. Without this the
621 // SDK knew the conversation was forked but sent anyway.
622 if self.stranded.read().contains(&conv_id) {
623 return Err(Error::EpochStranded(conv_id.as_hex()));
624 }
625 let envelope = {
626 let mut guard = self.conversations.write();
627 let convo = guard
628 .get_mut(&conv_id)
629 .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
630 convo.send_application(&plaintext, now_ms)?
631 };
632 self.transport.send(envelope.clone()).await?;
633 // The OpenMLS sender ratchet advances on every Application message — `seq` + `hlc`
634 // are bumped on the conversation, and the underlying group keystore stores new
635 // generation keys. Without a checkpoint here, a reload rolls back to the pre-send
636 // state and the next send re-uses an already-consumed generation that receivers
637 // silently drop. Mirrors the snapshot calls after every Commit/Welcome op.
638 //
639 // Capture the snapshot inputs UNDER the read guard, then DROP the
640 // guard (end of the `let` statement) before the async flush — never
641 // hold a `parking_lot` guard across `.await` (see
642 // `Conversation::snapshot_inputs`).
643 let snap = self
644 .conversations
645 .read()
646 .get(&conv_id)
647 .map(|c| c.snapshot_inputs())
648 .transpose()?;
649 if let Some(snap) = snap {
650 snap.flush().await?;
651 }
652 Ok(envelope)
653 }
654
655 /// Add members. The Commit goes on the wire; the Welcome should be delivered to the new
656 /// devices' inboxes (the host transport implements that — typically as a separate addressed
657 /// envelope).
658 ///
659 /// [CR-2] Each entry is `(DeviceId, KeyPackage_bytes)`. The host typically gets the
660 /// device_id from the directory at the same time it gets the KeyPackage; we use it to
661 /// record a per-conversation `device_id → leaf_index` map so [`Self::revoke_device`]
662 /// can later locate the leaf without a fresh directory lookup. The SDK does not
663 /// cryptographically verify the host's device-id claim — that's a directory policy
664 /// concern.
665 //
666 // The `conversations` lock is taken only for the SYNCHRONOUS MLS work
667 // (the add commit) and the synchronous snapshot capture, then dropped
668 // BEFORE every `.await`. We must never hold a `parking_lot` guard
669 // across an await — see `Conversation::snapshot_inputs` for why (the
670 // single-threaded wasm worker would panic in `parking_lot`'s parker
671 // stub). `parking_lot/send_guard` is still set so any guard that DOES
672 // briefly cross a yield-free boundary stays `Send`.
673 pub async fn add_members(
674 &self,
675 conv_id: ConversationId,
676 entries: Vec<(DeviceId, Vec<u8>)>,
677 now_ms: u64,
678 ) -> Result<()> {
679 // Phase 1 — stage the Commit WITHOUT merging (local epoch unchanged).
680 let staged = {
681 let mut guard = self.conversations.write();
682 let convo = guard
683 .get_mut(&conv_id)
684 .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
685 convo.stage_add_members(entries, now_ms)?
686 };
687
688 // Phase 2 — send the Commit FIRST, then merge only if the server accepts
689 // it (send-then-merge). A Commit the server REJECTS is rolled back, so the
690 // local epoch can never run ahead of the server — the desync that
691 // permanently bricks a group (every later Commit 409s; peers can't decrypt
692 // our epoch). A network failure with NO response is ambiguous (the server
693 // may have applied it), so there we merge to match a possible masked
694 // success rather than strand ourselves a step behind.
695 if let Err(send_err) = self.transport.send(staged.commit.clone()).await {
696 let merged = {
697 let mut guard = self.conversations.write();
698 match guard.get_mut(&conv_id) {
699 Some(convo) if is_definite_rejection(&send_err) => {
700 let _ = convo.abort_staged();
701 false
702 }
703 Some(convo) => {
704 convo.confirm_staged(&staged, now_ms)?;
705 true
706 }
707 None => false,
708 }
709 };
710 if merged {
711 self.flush_conversation(&conv_id).await?;
712 }
713 return Err(send_err);
714 }
715
716 // Phase 3 — Commit accepted: merge locally + persist (so the advanced
717 // epoch survives a crash even if the Welcome below fails).
718 {
719 let mut guard = self.conversations.write();
720 let convo = guard
721 .get_mut(&conv_id)
722 .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
723 convo.confirm_staged(&staged, now_ms)?;
724 }
725 self.flush_conversation(&conv_id).await?;
726
727 // Phase 4 — deliver the Welcome to the new members. Best-effort: they are
728 // in the group server-side now; a failed Welcome is recoverable
729 // (re-invite) and must NOT roll back the merged Commit.
730 if let Some(welcome) = staged.welcome {
731 self.transport.send(welcome).await?;
732 }
733 Ok(())
734 }
735
736 /// Change a conversation's `name` (carried in the GroupContext) and broadcast
737 /// the change to every member as an MLS GroupContextExtensions Commit. Unlike
738 /// a hydration broadcast, the new name rides MLS group STATE, so every member
739 /// — and every future joiner via the GroupInfo — converges on it. Hosts use
740 /// this to make a rename or an embedded avatar-media-id change bulletproof
741 /// (the `name` carries the `ping:meta:v1:` blob).
742 ///
743 /// No Welcome (membership is unchanged). Uses the same send-then-merge
744 /// rollback discipline as [`Self::add_members`] so a server-rejected Commit
745 /// never desyncs the local epoch. All members must have re-linked since the
746 /// group-name capability shipped (see `conversation::ping_leaf_capabilities`),
747 /// else openmls rejects the Commit.
748 pub async fn set_conversation_name(
749 &self,
750 conv_id: ConversationId,
751 name: Option<String>,
752 now_ms: u64,
753 ) -> Result<()> {
754 // Phase 1 — stage the Commit WITHOUT merging (local epoch unchanged).
755 let staged = {
756 let mut guard = self.conversations.write();
757 let convo = guard
758 .get_mut(&conv_id)
759 .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
760 convo.stage_set_name(name, now_ms)?
761 };
762
763 // Phase 2 — send-then-merge (see `add_members` for the rollback rationale).
764 if let Err(send_err) = self.transport.send(staged.commit.clone()).await {
765 let merged = {
766 let mut guard = self.conversations.write();
767 match guard.get_mut(&conv_id) {
768 Some(convo) if is_definite_rejection(&send_err) => {
769 let _ = convo.abort_staged();
770 false
771 }
772 Some(convo) => {
773 convo.confirm_staged(&staged, now_ms)?;
774 true
775 }
776 None => false,
777 }
778 };
779 if merged {
780 self.flush_conversation(&conv_id).await?;
781 }
782 return Err(send_err);
783 }
784
785 // Phase 3 — Commit accepted: merge locally + persist.
786 {
787 let mut guard = self.conversations.write();
788 let convo = guard
789 .get_mut(&conv_id)
790 .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
791 convo.confirm_staged(&staged, now_ms)?;
792 }
793 self.flush_conversation(&conv_id).await?;
794 Ok(())
795 }
796
797 /// Snapshot + flush a conversation's persistable state. Captures the snapshot
798 /// synchronously under the read guard, drops the guard, then awaits the flush
799 /// (never hold a `parking_lot` guard across an await — wasm parker panics).
800 async fn flush_conversation(&self, conv_id: &ConversationId) -> Result<()> {
801 let snap = self
802 .conversations
803 .read()
804 .get(conv_id)
805 .map(|c| c.snapshot_inputs())
806 .transpose()?;
807 if let Some(snap) = snap {
808 snap.flush().await?;
809 }
810 Ok(())
811 }
812
813 /// Admits `new_device_id` to every conversation in `kps_per_chat` via
814 /// the standard MLS `add_members` flow — one Commit + one Welcome per
815 /// chat. This is the SDK-side replacement for the host's previous
816 /// per-chat reconciler loop after device linking; centralising it
817 /// here means iOS/Android/web hosts all share the orchestration and
818 /// the transport's Welcome-recipient priming is automatic.
819 ///
820 /// Inputs:
821 /// - `new_device_id`: the device being admitted (matches the
822 /// `device_binding_sig` recipient in the linking ticket).
823 /// - `kps_per_chat`: one freshly-claimed KeyPackage per chat. The
824 /// host claims these via the auth-layer's per-account KP pool
825 /// (`GET /v1/devices/{accountId}`) AFTER the new device's
826 /// bootstrap has uploaded its KP batch.
827 /// - `now_ms`: wall-clock used to stamp HLCs on the emitted
828 /// envelopes.
829 ///
830 /// Per-chat failures (unknown conversation, MLS error, transport
831 /// error, etc.) are CAPTURED in the returned vec rather than
832 /// short-circuiting the whole call — losing one chat shouldn't
833 /// strand the new device on every other chat. The caller decides
834 /// whether to retry the failed entries (e.g. with a fresh KP).
835 pub async fn admit_device_to_chats(
836 &self,
837 new_device_id: DeviceId,
838 kps_per_chat: Vec<(ConversationId, Vec<u8>)>,
839 now_ms: u64,
840 ) -> Result<Vec<AdmitChatOutcome>> {
841 let mut outcomes = Vec::with_capacity(kps_per_chat.len());
842 for (conv_id, kp_bytes) in kps_per_chat {
843 // Belt-and-braces: skip the DeviceGroup. The DG was already
844 // welcomed via the linking ticket — re-adding the new
845 // device there would produce a duplicate-add Commit that
846 // BE de-dups, but the noise is avoidable.
847 let is_dg = self
848 .conversations
849 .read()
850 .get(&conv_id)
851 .map(|c| c.meta().is_device_group)
852 .unwrap_or(false);
853 if is_dg {
854 outcomes.push(AdmitChatOutcome {
855 conversation_id: conv_id,
856 status: AdmitChatStatus::Skipped {
857 reason: "device_group".to_string(),
858 },
859 });
860 continue;
861 }
862
863 // Prime the host transport with the welcome recipient BEFORE
864 // we mutate MLS state. If priming fails (non-web hosts use
865 // the default no-op), continue — the host's transport will
866 // either route some other way or surface a 4xx on the
867 // welcome send and we'll catch it below.
868 let _ = self
869 .transport
870 .set_next_welcome_recipients(conv_id, vec![new_device_id.clone()])
871 .await;
872
873 let entry = (new_device_id.clone(), kp_bytes);
874 let outcome_result = {
875 let mut guard = self.conversations.write();
876 match guard.get_mut(&conv_id) {
877 Some(convo) => convo.add_members(vec![entry], now_ms),
878 None => Err(Error::UnknownConversation(conv_id.as_hex())),
879 }
880 };
881
882 let outcome = match outcome_result {
883 Ok(o) => o,
884 Err(e) => {
885 outcomes.push(AdmitChatOutcome {
886 conversation_id: conv_id,
887 status: AdmitChatStatus::Failed {
888 error: e.to_string(),
889 },
890 });
891 continue;
892 }
893 };
894
895 if let Err(e) = self.transport.send(outcome.commit).await {
896 outcomes.push(AdmitChatOutcome {
897 conversation_id: conv_id,
898 status: AdmitChatStatus::Failed {
899 error: format!("commit send: {e}"),
900 },
901 });
902 continue;
903 }
904 if let Err(e) = self.transport.send(outcome.welcome).await {
905 outcomes.push(AdmitChatOutcome {
906 conversation_id: conv_id,
907 status: AdmitChatStatus::Failed {
908 error: format!("welcome send: {e}"),
909 },
910 });
911 continue;
912 }
913
914 // Capture the snapshot under the read guard, drop it, then
915 // flush async (never hold the lock across `.await`).
916 let snap_result = self
917 .conversations
918 .read()
919 .get(&conv_id)
920 .map(|c| c.snapshot_inputs())
921 .transpose();
922 let flush_result = match snap_result {
923 Ok(Some(snap)) => snap.flush().await,
924 Ok(None) => Ok(()),
925 Err(e) => Err(e),
926 };
927 if let Err(e) = flush_result {
928 // Snapshot failure is non-fatal for the join — the MLS adds
929 // already shipped — but record it so the host can decide
930 // whether to retry. The next successful send/process will
931 // re-snapshot anyway.
932 outcomes.push(AdmitChatOutcome {
933 conversation_id: conv_id,
934 status: AdmitChatStatus::Failed {
935 error: format!("snapshot: {e}"),
936 },
937 });
938 continue;
939 }
940
941 outcomes.push(AdmitChatOutcome {
942 conversation_id: conv_id,
943 status: AdmitChatStatus::Admitted,
944 });
945 }
946 Ok(outcomes)
947 }
948
949 pub async fn remove_members(
950 &self,
951 conv_id: ConversationId,
952 leaf_indexes: Vec<u32>,
953 now_ms: u64,
954 ) -> Result<()> {
955 // Send-then-merge — see `add_members` for the full rationale.
956 let staged = {
957 let mut guard = self.conversations.write();
958 let convo = guard
959 .get_mut(&conv_id)
960 .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
961 convo.stage_remove_members(leaf_indexes, now_ms)?
962 };
963
964 if let Err(send_err) = self.transport.send(staged.commit.clone()).await {
965 let merged = {
966 let mut guard = self.conversations.write();
967 match guard.get_mut(&conv_id) {
968 Some(convo) if is_definite_rejection(&send_err) => {
969 let _ = convo.abort_staged();
970 false
971 }
972 Some(convo) => {
973 convo.confirm_staged(&staged, now_ms)?;
974 true
975 }
976 None => false,
977 }
978 };
979 if merged {
980 self.flush_conversation(&conv_id).await?;
981 }
982 return Err(send_err);
983 }
984
985 {
986 let mut guard = self.conversations.write();
987 let convo = guard
988 .get_mut(&conv_id)
989 .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
990 convo.confirm_staged(&staged, now_ms)?;
991 }
992 self.flush_conversation(&conv_id).await?;
993 Ok(())
994 }
995
996 /// Re-admit a device, first evicting any existing leaf that duplicates the
997 /// new KeyPackage's signature key (the phrase-restore case — see
998 /// [`Conversation::duplicate_signature_key_leaves`] and
999 /// `docs/specs/re-admit-device.md`). Equivalent to [`Self::add_members`] when
1000 /// there is no duplicate, so it is a strict superset — safe to prefer on the
1001 /// recovery re-admission path.
1002 ///
1003 /// It composes the two conformance-tested membership primitives —
1004 /// `remove_members` (evict the dead duplicate leaf, freeing its signing key)
1005 /// then `add_members` (admit the fresh device + ship its Welcome) — each with
1006 /// its own send-then-merge rollback, so a server-rejected Commit never leaves
1007 /// the local epoch ahead of the server. Two Commits on the rare recovery path;
1008 /// folding them into a single combined Remove+Add commit is a possible future
1009 /// optimization (kept out of scope to reuse already-vetted primitives). If the
1010 /// remove succeeds but the add fails, the device is simply un-admitted (no
1011 /// worse than before) and the caller retries.
1012 pub async fn re_admit_device(
1013 &self,
1014 conv_id: ConversationId,
1015 entry: (DeviceId, Vec<u8>),
1016 now_ms: u64,
1017 ) -> Result<()> {
1018 let dup_leaves = {
1019 let guard = self.conversations.read();
1020 let convo = guard
1021 .get(&conv_id)
1022 .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
1023 convo.duplicate_signature_key_leaves(&entry.1)?
1024 };
1025 if !dup_leaves.is_empty() {
1026 self.remove_members(conv_id, dup_leaves, now_ms).await?;
1027 }
1028 self.add_members(conv_id, vec![entry], now_ms).await?;
1029 Ok(())
1030 }
1031
1032 /// Leave a conversation. Broadcasts a self-Remove PROPOSAL (MLS doesn't
1033 /// allow committing your own removal — a remaining member commits it via
1034 /// [`Self::commit_pending_proposals`]). After this returns, the host should
1035 /// delete the conversation locally; the leaver remains a cryptographic
1036 /// member only until a peer commits the proposal, at which point the server
1037 /// stops delivering to this device.
1038 pub async fn leave_conversation(&self, conv_id: ConversationId, now_ms: u64) -> Result<()> {
1039 let proposal = {
1040 let mut guard = self.conversations.write();
1041 let convo = guard
1042 .get_mut(&conv_id)
1043 .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
1044 convo.leave_group(now_ms)?
1045 };
1046 // A proposal doesn't change the epoch, so there's nothing to roll back
1047 // on a send failure — surface the error and let the host retry.
1048 self.transport.send(proposal).await?;
1049 self.flush_conversation(&conv_id).await?;
1050 Ok(())
1051 }
1052
1053 /// Drop a conversation's ENTIRE local state with **no** network side effect.
1054 ///
1055 /// Unlike [`Self::leave_conversation`] (which broadcasts a self-Remove
1056 /// proposal so a remaining member evicts you), this is a purely LOCAL
1057 /// teardown for a group the server does not back — e.g. an invite that minted
1058 /// the MLS group locally but never completed server-side, so the backend
1059 /// 404s `fetchSince` / 403s the member roster for it. The host detects that
1060 /// authoritative "not a member" verdict and calls this so the dead group
1061 /// stops rehydrating on every restart (and re-materialising as a ghost
1062 /// conversation). No envelope is sent — there is no live group to send to.
1063 ///
1064 /// Deletes the OpenMLS group state AND the host-side snapshot rows
1065 /// (`groups/{id}/…`, `cursors/{id}`, `device_leaves/{id}`) that
1066 /// [`Self::rehydrate_conversations`] would otherwise reload, then drops the
1067 /// in-memory handle + `stranded` marker. Idempotent: an unknown id still
1068 /// best-effort purges any orphan storage rows, and calling it twice is safe.
1069 pub async fn drop_conversation_local(&self, conv_id: ConversationId) -> Result<()> {
1070 // Take the handle OUT of the map first, then delete its OpenMLS state via
1071 // the owned value — mirrors the self-removal teardown in
1072 // `process_envelope` and keeps no lock across the awaits below.
1073 let existing = self.conversations.write().remove(&conv_id);
1074 if let Some(mut convo) = existing {
1075 if let Err(e) = convo.delete_group_state() {
1076 // Best-effort: dropping the in-memory handle + purging the
1077 // snapshot rows below already makes the group non-rehydratable.
1078 tracing::warn!(error = %e, "drop_conversation_local: delete group state failed");
1079 }
1080 }
1081 self.stranded.write().remove(&conv_id);
1082
1083 // Purge the host-side snapshot rows so a restart's
1084 // `rehydrate_conversations` (which walks `groups/{id}/meta`) can't bring
1085 // the group back. Best-effort — deleting an absent key is a no-op on
1086 // every backend. Sweep the whole `groups/{id}/` prefix so any future
1087 // sub-key is covered, then the known `cursors` + `device_leaves` rows.
1088 let hex = conv_id.as_hex();
1089 if let Ok(keys) = self.storage.list_keys("groups", &format!("{hex}/")).await {
1090 for k in keys {
1091 let _ = self.storage.delete("groups", &k).await;
1092 }
1093 }
1094 let _ = self.storage.delete("cursors", &hex).await;
1095 let _ = self.storage.delete("device_leaves", &hex).await;
1096 Ok(())
1097 }
1098
1099 /// Conversations with buffered pending proposals (e.g. a peer's leave
1100 /// proposal awaiting a Commit). The host polls this after a sync and, if it
1101 /// is the designated committer, calls [`Self::commit_pending_proposals`] to
1102 /// evict the leaver. Sorted for determinism.
1103 pub fn conversations_with_pending_proposals(&self) -> Vec<ConversationId> {
1104 let guard = self.conversations.read();
1105 let mut ids: Vec<ConversationId> = guard
1106 .iter()
1107 .filter(|(_, c)| c.has_pending_proposals())
1108 .map(|(id, _)| *id)
1109 .collect();
1110 ids.sort_by_key(|a| a.0);
1111 ids
1112 }
1113
1114 /// Commit all buffered pending proposals for a conversation (evicts a peer
1115 /// who left). No-op (Ok) when nothing is pending. Send-then-merge with
1116 /// rollback like add/remove so a server-rejected Commit doesn't desync the
1117 /// epoch — on an `epoch_advanced` rejection the host should re-sync (another
1118 /// member already committed) and the pending proposal will have cleared.
1119 pub async fn commit_pending_proposals(
1120 &self,
1121 conv_id: ConversationId,
1122 now_ms: u64,
1123 ) -> Result<()> {
1124 let staged = {
1125 let mut guard = self.conversations.write();
1126 let convo = guard
1127 .get_mut(&conv_id)
1128 .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
1129 match convo.stage_commit_pending_proposals(now_ms)? {
1130 Some(s) => s,
1131 None => return Ok(()),
1132 }
1133 };
1134
1135 if let Err(send_err) = self.transport.send(staged.commit.clone()).await {
1136 let merged = {
1137 let mut guard = self.conversations.write();
1138 match guard.get_mut(&conv_id) {
1139 Some(convo) if is_definite_rejection(&send_err) => {
1140 let _ = convo.abort_staged();
1141 false
1142 }
1143 Some(convo) => {
1144 convo.confirm_staged(&staged, now_ms)?;
1145 true
1146 }
1147 None => false,
1148 }
1149 };
1150 if merged {
1151 self.flush_conversation(&conv_id).await?;
1152 }
1153 return Err(send_err);
1154 }
1155
1156 {
1157 let mut guard = self.conversations.write();
1158 let convo = guard
1159 .get_mut(&conv_id)
1160 .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
1161 convo.confirm_staged(&staged, now_ms)?;
1162 }
1163 self.flush_conversation(&conv_id).await?;
1164 Ok(())
1165 }
1166
1167 /// Process an inbound envelope coming from the transport's subscribe callback or a sync pull.
1168 /// Returns `Some` for application traffic, `None` for handshake messages (already merged).
1169 ///
1170 /// LIVE path: applies the envelope to the in-memory MLS working set and then
1171 /// durably flushes THIS envelope before returning (per-envelope crash-safety
1172 /// for streaming). The catch-up drain (`sync_conversations`) instead applies a
1173 /// whole page in-memory and flushes ONCE per page — see `apply_envelope_in_memory`.
1174 pub async fn process_envelope(
1175 &self,
1176 env: &MessageEnvelope,
1177 now_ms: u64,
1178 ) -> Result<Option<IncomingMessage>> {
1179 let (out, removed) = self.apply_envelope_in_memory(env, now_ms)?;
1180 // A self-removal already deleted the group's storage; nothing to flush.
1181 if !removed {
1182 self.flush_conversation(&env.conversation_id).await?;
1183 }
1184 Ok(out)
1185 }
1186
1187 /// Apply an inbound envelope to the IN-MEMORY MLS working set and advance the
1188 /// in-memory cursor, WITHOUT the durable checkpoint flush. Returns the
1189 /// decrypted application message (if any) and whether the conversation was
1190 /// REMOVED (a Commit that removed our own leaf tears the group down and
1191 /// deletes its storage synchronously — a removed conversation needs no flush).
1192 ///
1193 /// The write guard is taken and dropped entirely within this synchronous
1194 /// helper — NO `.await` happens while it is held. That is the wasm
1195 /// parking-panic constraint: previously the write guard was held across
1196 /// `snapshot_to_storage().await`, and on the single-threaded wasm worker a
1197 /// concurrent reader that landed while a writer was waiting made `parking_lot`
1198 /// park → panic "Parking not supported". Callers `.await` the flush AFTER this
1199 /// returns (`process_envelope` per call; `sync_conversations` once per page).
1200 ///
1201 /// Batching the flush is the drain-wedge fix: `flush` writes a FULL-state
1202 /// checkpoint (the whole MLS store as one blob), so flushing per envelope made
1203 /// a backlog drain O(N × total_state) of synchronous serialization on the
1204 /// host's single serial queue — the stall the client saw as "messages
1205 /// delivered late". Crash-safety is unchanged: `flush` persists MLS state
1206 /// before the cursor (see `ConversationSnapshot::flush`), so a crash mid-page
1207 /// re-fetches the page and `SyncCursor::is_new` dedups the already-applied
1208 /// events.
1209 fn apply_envelope_in_memory(
1210 &self,
1211 env: &MessageEnvelope,
1212 now_ms: u64,
1213 ) -> Result<(Option<IncomingMessage>, bool)> {
1214 // Welcome envelopes for unknown conversations are routed to
1215 // `join_conversation` by the caller. Here we only handle traffic for
1216 // already-open groups.
1217 let mut guard = self.conversations.write();
1218 let convo = match guard.get_mut(&env.conversation_id) {
1219 Some(c) => c,
1220 None => return Err(Error::UnknownConversation(env.conversation_id.as_hex())),
1221 };
1222 let out = convo.process(env, now_ms)?;
1223 // SELF-REMOVAL: if this Commit removed our own leaf the group is now
1224 // Inactive. Tear it down — delete the OpenMLS state + drop the handle
1225 // — so the conversation becomes UNKNOWN to this client. A later
1226 // re-invite Welcome for the same group_id is then routed to
1227 // `join_conversation` (the host only joins UNKNOWN conversations) and
1228 // re-joins from clean storage, instead of being suppressed as an
1229 // "already-joined duplicate" (the cause of the stuck-un-joined re-invite).
1230 if !convo.is_active() {
1231 let conv_id = env.conversation_id;
1232 // Best-effort storage cleanup; even if it fails, dropping the
1233 // in-memory handle already makes the conversation re-joinable.
1234 if let Err(e) = convo.delete_group_state() {
1235 tracing::warn!(error = %e, "process_envelope: delete removed group state failed");
1236 }
1237 guard.remove(&conv_id);
1238 self.stranded.write().remove(&conv_id);
1239 return Ok((out, true));
1240 }
1241 Ok((out, false))
1242 }
1243
1244 /// Catch-up sync: pull missing events for every open conversation since its cursor.
1245 /// Returns the list of newly-decrypted application messages, in apply order.
1246 pub async fn sync_conversations(&self, now_ms: u64) -> Result<Vec<IncomingMessage>> {
1247 // Snapshot the conversation IDs ONLY — not their cursors. The cursor is
1248 // re-read fresh per fetch below. Two bugs this avoids:
1249 // 1. Stale-cursor pagination: `process_envelope` advances the
1250 // conversation's cursor as it applies each page, but the OLD code
1251 // kept fetching from the cursor captured up-front — so a chat with
1252 // more than one page (256+) of backlog re-fetched the SAME first
1253 // page forever and never paged past it (catch-up silently truncated
1254 // a freshly-linked device's history, incl. group-avatar/name
1255 // hydration that lands after the first page).
1256 // 2. Join-during-sync: reading IDs fresh here (and re-reading the map
1257 // each iteration) means a conversation the host joins via a live
1258 // Welcome right before/around this call is still covered.
1259 // Iterate a sorted, de-duplicated ID set for deterministic order.
1260 let conversation_ids: Vec<ConversationId> = {
1261 let guard = self.conversations.read();
1262 let mut ids: Vec<ConversationId> = guard.keys().copied().collect();
1263 ids.sort_by_key(|a| a.0);
1264 ids
1265 };
1266
1267 let mut delivered = Vec::new();
1268 for conv_id in conversation_ids {
1269 loop {
1270 // Re-read the LIVE cursor each iteration so pagination advances
1271 // as `process_envelope` consumes pages. If the conversation was
1272 // removed mid-sync (e.g. a wipe), stop cleanly.
1273 let cursor = match self.conversations.read().get(&conv_id) {
1274 Some(c) => c.cursor.clone(),
1275 None => break,
1276 };
1277 // PER-CONVERSATION ISOLATION: a transport error on ONE
1278 // conversation (a transient 5xx, a non-404 fetch failure) must
1279 // not abort catch-up for ALL the others. Previously a single
1280 // erroring conversation propagated `?` and failed the entire
1281 // `sync_conversations` — so on a freshly-linked device, one bad
1282 // conversation (e.g. the device group, or a group mid-churn)
1283 // blocked every chat from syncing, and group name/avatar
1284 // hydration broadcasts never arrived. Log + skip this
1285 // conversation instead.
1286 let batch = match self.transport.fetch_since(conv_id, cursor, 256).await {
1287 Ok(b) => b,
1288 Err(e) => {
1289 tracing::warn!(error = %e, "sync_conversations: fetch_since failed; skipping conversation");
1290 break;
1291 }
1292 };
1293 if batch.is_empty() {
1294 break;
1295 }
1296 let mut advanced = false;
1297 let mut removed = false;
1298 for env in &batch {
1299 // PER-ENVELOPE ISOLATION: a single undecryptable / malformed
1300 // / wrong-epoch envelope must not drop the rest of the page
1301 // (or fail the sync). Skip it; the live stream / a later
1302 // epoch advance can still deliver retriable ones.
1303 //
1304 // BATCHED CHECKPOINT (drain-wedge fix): apply each envelope to
1305 // the in-memory working set WITHOUT flushing, then persist the
1306 // whole page with ONE full-state checkpoint below. This turns a
1307 // backlog drain from O(N × total_state) synchronous
1308 // serializations into O(1) per page (~100-1000× less work on
1309 // the host's single serial queue) — the stall the client saw
1310 // as "messages delivered late".
1311 match self.apply_envelope_in_memory(env, now_ms) {
1312 Ok((msg, rm)) => {
1313 // Commits / handshakes advance the in-memory cursor too
1314 // (no app message surfaced) — treat them as progress so
1315 // we keep paging instead of re-fetching the same page.
1316 advanced = true;
1317 if let Some(msg) = msg {
1318 delivered.push(msg);
1319 }
1320 if rm {
1321 // A self-removal Commit deleted the conversation
1322 // mid-page; stop applying further envelopes to it.
1323 removed = true;
1324 break;
1325 }
1326 }
1327 Err(e) => {
1328 tracing::warn!(error = %e, "sync_conversations: process_envelope failed; skipping envelope");
1329 }
1330 }
1331 }
1332 if advanced {
1333 // Progress was made — this conversation is not stranded
1334 // (clear any prior stranded mark).
1335 self.stranded.write().remove(&conv_id);
1336 }
1337 // ONE durable checkpoint for the whole page. Skipped when the
1338 // conversation was self-removed (its storage was already deleted).
1339 // Crash-safety is preserved by `flush`'s state-before-cursor
1340 // ordering: a crash before/inside this flush re-fetches the page on
1341 // restart and `SyncCursor::is_new` dedups the already-applied events.
1342 if advanced && !removed {
1343 if let Err(e) = self.flush_conversation(&conv_id).await {
1344 tracing::warn!(error = %e, "sync_conversations: batch checkpoint failed; will re-fetch on next sync");
1345 break;
1346 }
1347 }
1348 if removed {
1349 break; // conversation gone; nothing more to page
1350 }
1351 if batch.len() < 256 {
1352 break; // partial page → caught up
1353 }
1354 if !advanced {
1355 // Full page but nothing advanced the cursor (every envelope
1356 // errored / was wrong-epoch). The group can no longer advance
1357 // from local state — a Commit was missed. Mark it STRANDED so
1358 // the host can recover it (re-Welcome / snapshot import) via
1359 // `stranded_conversations()`, and bail to avoid an infinite
1360 // re-fetch loop.
1361 tracing::warn!(
1362 "sync_conversations: full page made no progress; marking conversation stranded"
1363 );
1364 self.stranded.write().insert(conv_id);
1365 break;
1366 }
1367 }
1368 }
1369 Ok(delivered)
1370 }
1371
1372 /// Rehydrate conversations from storage on startup ([CR-4]).
1373 ///
1374 /// Walks the host-side `groups` namespace for meta records, pairs each with its
1375 /// cursor + device→leaf map, and asks `Conversation::load` to re-attach to the
1376 /// underlying OpenMLS group state. The MLS state itself was persisted by the
1377 /// SQLite-backed `PersistentMlsProvider` on the previous run; this method
1378 /// reconciles the SDK-side caches with what's on disk.
1379 async fn rehydrate_conversations(self: &Arc<Self>, now_ms: u64) -> Result<()> {
1380 let metas = self.storage.list_keys("groups", "").await?;
1381 for path in metas {
1382 // path looks like "{convId}/meta"
1383 let Some((id_hex, suffix)) = path.split_once('/') else {
1384 continue;
1385 };
1386 if suffix != "meta" {
1387 continue;
1388 }
1389 let Some(meta_bytes) = self.storage.get("groups", &path).await? else {
1390 continue;
1391 };
1392 let meta: ConversationMeta = match codec::decode(&meta_bytes) {
1393 Ok(m) => m,
1394 Err(_) => continue,
1395 };
1396 let cursor_bytes = self
1397 .storage
1398 .get("cursors", id_hex)
1399 .await?
1400 .unwrap_or_default();
1401 let cursor = if cursor_bytes.is_empty() {
1402 SyncCursor::default()
1403 } else {
1404 SyncCursor::decode(&cursor_bytes).unwrap_or_default()
1405 };
1406
1407 // [CR-2] device→leaf map was persisted alongside meta + cursor.
1408 let device_leaves_bytes = self
1409 .storage
1410 .get("device_leaves", id_hex)
1411 .await?
1412 .unwrap_or_default();
1413 let device_leaves: std::collections::BTreeMap<DeviceId, u32> =
1414 if device_leaves_bytes.is_empty() {
1415 std::collections::BTreeMap::new()
1416 } else {
1417 let pairs: Vec<(DeviceId, u32)> =
1418 codec::decode(&device_leaves_bytes).unwrap_or_default();
1419 pairs.into_iter().collect()
1420 };
1421
1422 match Conversation::load(
1423 meta.id,
1424 meta.clone(),
1425 cursor,
1426 device_leaves,
1427 self.local_device.device_id.clone(),
1428 self.crypto.clone(),
1429 self.signing.clone(),
1430 self.storage.clone(),
1431 now_ms,
1432 ) {
1433 Ok(Some(convo)) => {
1434 tracing::debug!(
1435 target: "ping_core::client",
1436 convo = %id_hex,
1437 epoch = meta.epoch,
1438 "rehydrated conversation from disk"
1439 );
1440 self.conversations.write().insert(meta.id, convo);
1441 }
1442 Ok(None) => {
1443 tracing::warn!(
1444 target: "ping_core::client",
1445 convo = %id_hex,
1446 "host-side meta present but OpenMLS state missing — skipping"
1447 );
1448 }
1449 Err(e) => {
1450 tracing::warn!(
1451 target: "ping_core::client",
1452 convo = %id_hex,
1453 error = %e,
1454 "Conversation::load failed — skipping"
1455 );
1456 }
1457 }
1458 }
1459 Ok(())
1460 }
1461
1462 // ------------------- Multi-device API -------------------
1463
1464 /// Build a [`LinkingTicket`] for a new device. The caller obtains `new_device_kp` from the
1465 /// new device (e.g., via QR-encoded handshake) and is responsible for sealing the returned
1466 /// ticket against the new device's ephemeral X25519 pubkey before transmission via
1467 /// [`ping_link::seal_ticket`].
1468 ///
1469 /// [CR-13] `last_app_events` is a host-supplied list of `(conversation_id, app_event_bytes)`
1470 /// for the new device's "what you missed" UI. The SDK adds its own metas + (currently-
1471 /// empty) per-conversation MLS state and bundles everything into
1472 /// [`device::CatchupSnapshot`], CBOR-encoded into the ticket's `catchup_snapshot` field.
1473 /// Pass an empty `Vec` to suppress catchup data (the new device sees an empty
1474 /// conversation list until normal sync runs).
1475 pub async fn build_linking_ticket(
1476 self: &Arc<Self>,
1477 new_device_id: DeviceId,
1478 new_device_kp: Vec<u8>,
1479 last_app_events: Vec<(ConversationId, Vec<u8>)>,
1480 now_ms: u64,
1481 ) -> Result<LinkingTicket> {
1482 let device_binding_sig = self.identity.sign_device_binding(&new_device_id.0);
1483 let dg_id = device_group_id_for(self.identity.user_id());
1484
1485 // [CR-10] DG is eagerly created at init now, but call ensure here too so
1486 // hosts that bypass `MessagingClient::init` (mocked tests, legacy upgrade
1487 // paths) keep working.
1488 self.ensure_device_group(now_ms).await?;
1489
1490 // Admit the new device to the DeviceGroup.
1491 let outcome = {
1492 let mut conversations = self.conversations.write();
1493 // `ensure_device_group` above creates it, but return a typed error
1494 // instead of panicking (which would unwind across FFI/wasm) on the
1495 // pathological case where it's still missing.
1496 let dg = conversations.get_mut(&dg_id).ok_or_else(|| {
1497 Error::Invalid("device group missing after ensure_device_group".into())
1498 })?;
1499 // [CR-2] Record the new device's leaf in the DG so future `revoke_device`
1500 // can find it. The new_device_id we got as a parameter is the inviter's
1501 // own assertion — same trust model as the rest of `add_members`.
1502 dg.add_members(vec![(new_device_id.clone(), new_device_kp)], now_ms)?
1503 };
1504
1505 // [CR-13] Assemble the catchup snapshot: SDK-known conversation metadata + host-
1506 // supplied last-known plaintext per conversation. [CR-7] now populates
1507 // `group_state_bytes` with each group's MLS state so the new device can decrypt
1508 // historical traffic without re-Welcoming. An empty `group_state_bytes` would
1509 // mean either a group with no exportable state (shouldn't happen) or an
1510 // encoder failure (we let those propagate as errors below).
1511 let catchup_snapshot = if last_app_events.is_empty() && self.conversations.read().is_empty()
1512 {
1513 // Cheap path: nothing to snapshot, skip the encode round-trip.
1514 Vec::new()
1515 } else {
1516 // CR-7 per-group state export is O(conversations) expensive MLS
1517 // serialization AND unconsumed by current hosts (both re-admit the new
1518 // device via the post-link re-Welcome reconciler rather than importing
1519 // this state). Exporting every group's state for an account with hundreds
1520 // or thousands of conversations is wasted CPU that also blows the 256 KB
1521 // ticket cap. So budget the EXPORT itself: fill `group_state_bytes` only
1522 // while under a small byte budget, in iteration order; once exhausted, ship
1523 // the (cheap) meta with empty `group_state_bytes` and skip the expensive
1524 // export entirely — the receiver falls back to the normal re-Welcome path
1525 // for those. `encode_within_cap` below is the final guarantee the whole
1526 // snapshot fits regardless. This keeps the per-link cost bounded to a
1527 // CONSTANT (~budget) instead of growing with conversation count.
1528 const GROUP_STATE_EXPORT_BUDGET: usize = CATCHUP_SNAPSHOT_SOFT_CAP; // 64 KiB
1529 let mut group_state_budget = GROUP_STATE_EXPORT_BUDGET;
1530 let conversation_metas: Vec<CatchupConversationEntry> = {
1531 let guard = self.conversations.read();
1532 let mut metas = Vec::with_capacity(guard.len());
1533 for c in guard.values() {
1534 let group_state_bytes = if group_state_budget > 0 {
1535 let bytes = c.export_state_snapshot(now_ms)?.to_vec();
1536 if bytes.len() <= group_state_budget {
1537 group_state_budget -= bytes.len();
1538 bytes
1539 } else {
1540 // This one would overflow the budget — drop it and stop
1541 // exporting further (later conversations skip the export).
1542 group_state_budget = 0;
1543 Vec::new()
1544 }
1545 } else {
1546 Vec::new()
1547 };
1548 metas.push(CatchupConversationEntry {
1549 conversation_id: c.id(),
1550 meta: c.meta().clone(),
1551 group_state_bytes,
1552 });
1553 }
1554 metas
1555 };
1556 let last_app_events_per_conv: Vec<CatchupAppEventEntry> = last_app_events
1557 .into_iter()
1558 .map(|(conversation_id, app_event_bytes)| CatchupAppEventEntry {
1559 conversation_id,
1560 app_event_bytes,
1561 })
1562 .collect();
1563 // `encode_within_cap` (not `encode`) so a user with many/large groups can
1564 // still link: the snapshot snapshots EVERY conversation, so a big account
1565 // would otherwise blow the 256 KB ticket cap and hard-fail linking outright.
1566 // It sheds per-group MLS state (largest first) to fit — those conversations
1567 // catch up via the post-link re-Welcome reconciler the hosts already run.
1568 CatchupSnapshot {
1569 v: CATCHUP_SNAPSHOT_VERSION,
1570 conversation_metas,
1571 last_app_events_per_conv,
1572 }
1573 .encode_within_cap()?
1574 };
1575
1576 Ok(LinkingTicket {
1577 v: 1,
1578 user_id: self.identity.user_id().clone(),
1579 user_pubkey: self.identity.public_key().to_bytes().to_vec(),
1580 new_device_id,
1581 device_binding_sig,
1582 device_group_welcome: outcome.welcome.payload,
1583 catchup_snapshot,
1584 })
1585 }
1586
1587 /// Apply a received linking ticket. Joins the user's DeviceGroup; the catch-up snapshot
1588 /// (if any) is decrypted by the host using the standard per-conversation channel afterwards.
1589 pub async fn consume_linking_ticket(
1590 self: &Arc<Self>,
1591 ticket: &LinkingTicket,
1592 now_ms: u64,
1593 ) -> Result<()> {
1594 // Verify the binding the existing device made for us. (Ed25519 public keys are 32 bytes.)
1595 let pk_bytes: [u8; 32] = ticket
1596 .user_pubkey
1597 .as_slice()
1598 .try_into()
1599 .map_err(|_| Error::Identity("user_pubkey must be 32 bytes".into()))?;
1600 let user_pk = ed25519_dalek::VerifyingKey::from_bytes(&pk_bytes)
1601 .map_err(|e| Error::Identity(format!("bad user pubkey: {e}")))?;
1602 Identity::verify_device_binding(
1603 &user_pk,
1604 &ticket.user_id,
1605 &ticket.new_device_id.0,
1606 &ticket.device_binding_sig,
1607 )?;
1608 if ticket.new_device_id != self.local_device.device_id {
1609 return Err(Error::Invalid(
1610 "ticket addressed to a different device".into(),
1611 ));
1612 }
1613
1614 let dummy_env = MessageEnvelope::new(
1615 ConversationId(device_group_id_for(&ticket.user_id).0),
1616 0,
1617 MessageKind::Welcome,
1618 self.local_device.device_id.clone(),
1619 0,
1620 crate::clock::Hlc::ZERO,
1621 ticket.device_group_welcome.clone(),
1622 );
1623 self.join_conversation(&dummy_env, now_ms).await?;
1624 Ok(())
1625 }
1626
1627 /// [CR-7] Export the MLS state snapshot for one open conversation.
1628 ///
1629 /// Thin pass-through to [`Conversation::export_state_snapshot`]. Returned bytes
1630 /// are wrapped in `Zeroizing` because they contain past epoch secrets.
1631 pub fn export_conversation_state_snapshot(
1632 &self,
1633 conv_id: ConversationId,
1634 now_ms: u64,
1635 ) -> Result<zeroize::Zeroizing<Vec<u8>>> {
1636 let guard = self.conversations.read();
1637 let convo = guard
1638 .get(&conv_id)
1639 .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
1640 convo.export_state_snapshot(now_ms)
1641 }
1642
1643 /// [CR-7] Import a `GroupStateSnapshot` produced by another device's
1644 /// [`Conversation::export_state_snapshot`].
1645 ///
1646 /// Replays the snapshot's entries into this client's OpenMLS provider, then
1647 /// reconstructs the `Conversation` handle via `MlsGroup::load`. After return,
1648 /// the conversation is in `list_conversations()` and `send`/`process_envelope`
1649 /// work against it normally.
1650 ///
1651 /// **Scope.** This is for the *same-user* hand-off (linking, recovery). The
1652 /// snapshot exposes the exporter's view of past epoch secrets for the target
1653 /// group; only call this when the receiving device has been authenticated to
1654 /// the same user identity (mnemonic, QR-handshake). Cross-user history transfer
1655 /// uses HPKE-sealed AppEvent re-shares (umbrella §15.6), not this method.
1656 ///
1657 /// **Sanity.** Refuses snapshots whose `group_id` doesn't match the bytes the
1658 /// receiver intends to claim — guards against host bugs that shuffle snapshots
1659 /// between groups. Refuses mismatched OpenMLS storage versions outright; no
1660 /// silent forward/back compatibility.
1661 pub async fn import_state_snapshot(
1662 self: &Arc<Self>,
1663 snapshot_bytes: &[u8],
1664 now_ms: u64,
1665 ) -> Result<ConversationId> {
1666 use crate::device::GroupStateSnapshot;
1667 let snap = GroupStateSnapshot::decode(snapshot_bytes)
1668 .map_err(|e| Error::Invalid(format!("snapshot decode: {e}")))?;
1669
1670 if snap.openmls_storage_version != openmls_traits::storage::CURRENT_VERSION {
1671 return Err(Error::Invalid(format!(
1672 "snapshot openmls_storage_version={} not supported (this SDK supports v={})",
1673 snap.openmls_storage_version,
1674 openmls_traits::storage::CURRENT_VERSION
1675 )));
1676 }
1677
1678 let conv_id = snap.group_id;
1679
1680 // Refuse if we already have an active handle for this conv — the host should
1681 // close it first, otherwise import silently overwrites in-memory state and
1682 // the existing handle becomes stale.
1683 if self.conversations.read().contains_key(&conv_id) {
1684 return Err(Error::Invalid(format!(
1685 "conversation {} already open; close before importing snapshot",
1686 conv_id.as_hex()
1687 )));
1688 }
1689
1690 // Replay raw KV pairs into the provider's working set.
1691 let entries: Vec<(Vec<u8>, Vec<u8>)> =
1692 snap.entries.into_iter().map(|e| (e.key, e.value)).collect();
1693 self.crypto
1694 .import_entries(entries)
1695 .map_err(|e| Error::Storage(format!("import entries: {e}")))?;
1696
1697 // Reconstruct the Conversation handle. `Conversation::load` will return
1698 // `Ok(None)` if OpenMLS still can't find the group — i.e. our snapshot was
1699 // incomplete or for a different storage version.
1700 let meta = ConversationMeta {
1701 id: conv_id,
1702 name: None,
1703 epoch: 0, // will be overwritten from the loaded group state in process()
1704 member_count: 0,
1705 is_device_group: false, // host can flip this via meta update if needed
1706 created_at_ms: now_ms,
1707 };
1708 let convo = Conversation::load(
1709 conv_id,
1710 meta,
1711 SyncCursor::default(),
1712 std::collections::BTreeMap::new(),
1713 self.local_device.device_id.clone(),
1714 self.crypto.clone(),
1715 self.signing.clone(),
1716 self.storage.clone(),
1717 now_ms,
1718 )?
1719 .ok_or_else(|| {
1720 Error::Invalid(
1721 "snapshot imported but OpenMLS could not load the group — snapshot may be incomplete or storage version mismatched"
1722 .into(),
1723 )
1724 })?;
1725
1726 // Pull the live epoch + member count from the loaded group so the meta we
1727 // just stubbed is consistent with what we'll observe on subsequent process_envelope.
1728 let live_epoch = convo.epoch();
1729 let live_members = convo.group.members().count() as u32;
1730 let live_name = convo.name_from_group_state();
1731 let mut convo = convo;
1732 convo.meta.epoch = live_epoch;
1733 convo.meta.member_count = live_members;
1734 // Recover the name from the loaded GroupContext state (a snapshot import
1735 // is join-equivalent; the stubbed `name: None` would otherwise stick).
1736 convo.meta.name = live_name;
1737 convo.snapshot_to_storage().await?;
1738
1739 self.conversations.write().insert(conv_id, convo);
1740 Ok(conv_id)
1741 }
1742
1743 /// Export a derived secret from one conversation's MLS exporter ([CR-8]).
1744 ///
1745 /// Thin pass-through to [`Conversation::export_secret`]. See that method's doc comment
1746 /// for the contract on `label`, `context`, length validation, and zeroization. The
1747 /// returned `Zeroizing<Vec<u8>>` is automatically wiped when dropped.
1748 pub fn export_conversation_secret(
1749 &self,
1750 conv_id: ConversationId,
1751 label: &str,
1752 context: &[u8],
1753 length: usize,
1754 ) -> Result<Zeroizing<Vec<u8>>> {
1755 let guard = self.conversations.read();
1756 let convo = guard
1757 .get(&conv_id)
1758 .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
1759 convo.export_secret(label, context, length)
1760 }
1761
1762 /// Revoke a device by removing its leaf from every conversation where we know its
1763 /// position ([CR-2]).
1764 ///
1765 /// Returns one Commit envelope per conversation the device was a leaf in. The host
1766 /// broadcasts each envelope to the affected conversation; the SDK has also already
1767 /// handed them to the transport via `transport.send` (idempotent broadcast is the
1768 /// host's call).
1769 ///
1770 /// **Scope.** The SDK can only resolve leaves it recorded itself — either when it
1771 /// admitted the device via [`Self::add_members`] or when this device joined as the
1772 /// target via Welcome. For peer-admitted devices the leaf index isn't locally known;
1773 /// those conversations are silently skipped. The host can fall back to
1774 /// `remove_members(leaf_index)` directly using a transport-side directory lookup if
1775 /// it needs to revoke from those conversations too. See
1776 /// `docs/architecture/multi-device.md §Device removal` for the broader flow.
1777 ///
1778 /// Conversations with no entry for `device_id` produce no envelope; an empty `Vec`
1779 /// return is a valid outcome (e.g. the device was already revoked, or was never
1780 /// added by this client).
1781 #[allow(clippy::await_holding_lock)] // see add_members for rationale
1782 pub async fn revoke_device(
1783 &self,
1784 device_id: DeviceId,
1785 now_ms: u64,
1786 ) -> Result<Vec<MessageEnvelope>> {
1787 // 1. Walk every open conversation and gather (conv_id, leaf_index) pairs where
1788 // we know `device_id` controls a leaf. Done under a read lock so we don't hold
1789 // the write lock across the per-conversation remove path.
1790 let targets: Vec<(ConversationId, u32)> = self
1791 .conversations
1792 .read()
1793 .iter()
1794 .filter_map(|(id, c)| c.leaf_index_of(&device_id).map(|leaf| (*id, leaf)))
1795 .collect();
1796
1797 // 2. For each target, emit a remove_members commit. We do this sequentially: each
1798 // one is a separate MLS epoch advance on its own group, and they don't share
1799 // state, so parallel issuance is safe but adds complexity we don't need for v1.
1800 let mut envelopes = Vec::with_capacity(targets.len());
1801 for (conv_id, leaf_index) in targets {
1802 let envelope = {
1803 let mut guard = self.conversations.write();
1804 let convo = guard
1805 .get_mut(&conv_id)
1806 .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
1807 convo.remove_members(vec![leaf_index], now_ms)?
1808 };
1809 self.transport.send(envelope.clone()).await?;
1810 if let Some(c) = self.conversations.read().get(&conv_id) {
1811 c.snapshot_to_storage().await?;
1812 }
1813 envelopes.push(envelope);
1814 }
1815
1816 // 3. Notify the auth-layer server so it can invalidate the
1817 // revoked device's KeyPackage pool, mark `auth.devices.revoked_at`,
1818 // and refuse any future envelope signed by the revoked device's
1819 // JWT. Done AFTER the MLS Commits so peers learn via MLS first
1820 // (the canonical path) and the auth layer is the eventual-
1821 // consistency cleanup. Transport failures bubble up so callers
1822 // can retry — but the MLS-side work has already shipped, so
1823 // the device is functionally revoked in every group; only the
1824 // auth-layer KeyPackage purge is pending.
1825 self.transport.revoke_device_remote(device_id).await?;
1826 Ok(envelopes)
1827 }
1828}
1829
1830fn device_group_id_for(user_id: &UserId) -> ConversationId {
1831 // Deterministic 16-byte ID derived from the user's id, prefixed so it cannot collide with
1832 // a randomly-generated ULID in normal use (ULIDs start with a millisecond timestamp).
1833 let mut bytes = [0u8; 16];
1834 bytes[0] = 0xFF;
1835 bytes[1] = 0xDC; // "DeviCe" group sentinel
1836 let h = codec::sha256(&user_id.0);
1837 bytes[2..].copy_from_slice(&h[..14]);
1838 ConversationId(bytes)
1839}
1840
1841fn encode_local_device(d: &LocalDevice) -> Result<Vec<u8>> {
1842 use serde::Serialize;
1843 #[derive(Serialize)]
1844 struct Persisted<'a> {
1845 device_id: &'a DeviceId,
1846 label: &'a str,
1847 created_at_ms: u64,
1848 #[serde(with = "serde_bytes")]
1849 signing_seed: &'a [u8],
1850 }
1851 codec::encode(&Persisted {
1852 device_id: &d.device_id,
1853 label: &d.label,
1854 created_at_ms: d.created_at_ms,
1855 signing_seed: d.signing.as_bytes(),
1856 })
1857}
1858
1859fn decode_local_device(bytes: &[u8], user_id: UserId) -> Result<LocalDevice> {
1860 use serde::Deserialize;
1861 #[derive(Deserialize)]
1862 struct Persisted {
1863 device_id: DeviceId,
1864 label: String,
1865 created_at_ms: u64,
1866 #[serde(with = "serde_bytes")]
1867 signing_seed: Vec<u8>,
1868 }
1869 let p: Persisted = codec::decode(bytes)?;
1870 let seed: [u8; 32] = p
1871 .signing_seed
1872 .as_slice()
1873 .try_into()
1874 .map_err(|_| Error::Invalid("device signing seed must be 32 bytes".into()))?;
1875 let signing = ed25519_dalek::SigningKey::from_bytes(&seed);
1876 Ok(LocalDevice {
1877 device_id: p.device_id,
1878 user_id,
1879 label: p.label,
1880 signing,
1881 created_at_ms: p.created_at_ms,
1882 })
1883}