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