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