Skip to main content

MessagingClient

Struct MessagingClient 

Source
pub struct MessagingClient { /* private fields */ }

Implementations§

Source§

impl MessagingClient

Source

pub async fn init(cfg: ClientConfig) -> Result<Arc<Self>>

Initialise. Creates a new local device if none is recorded in storage; otherwise rehydrates.

Source

pub async fn open_read_only(cfg: ClientConfig) -> Result<Arc<Self>>

Open a client that can DECRYPT but never persists — the notification preview path.

The motivating case is the iOS Notification Service Extension: a second process, woken per push, that has to turn one inbound envelope into a sender and a message preview for the lock screen, while the main app keeps running and keeps writing.

init cannot be used for that, and not merely as a matter of taste. Persistence here checkpoints the ENTIRE MLS working set as one blob (ping_mls_store), so two writers do not merge — whichever flushes second replaces everything the other did, discarding epochs and ratchet state wholesale. init also creates state (minting a LocalDevice, ensuring the DeviceGroup exists), which is exactly the behaviour a read-only opener must not have.

So this constructor is the same hydration with every writing path removed:

  • the storage backend MUST be read-only, checked here rather than trusted, so a caller cannot reach this code with a writable store;
  • a missing LocalDevice is an ERROR, never a fresh identity — this process joins an existing installation or it does nothing;
  • the DeviceGroup is not created. A reader that finds none simply has no DeviceGroup, which costs it nothing (personal-state sync is the app’s job, not a notification’s).

The MLS signing keypair is still stored, but only into the in-memory MemoryStorage the provider hands OpenMLS; with a read-only backend that never reaches disk.

Pair with preview_envelope, and give it a read-only host Storage too — this guards the MLS blob, not the host’s own key-value writes.

Source

pub fn preview_envelope( &self, env: &MessageEnvelope, now_ms: u64, ) -> Result<Option<IncomingMessage>>

Decrypt ONE application envelope for display, changing nothing durable.

The counterpart to open_read_only: applies the envelope to this process’s private copy of the MLS state and returns the plaintext, with no flush of either the MLS blob or the host’s key-value store. The ratchet advance is deliberately thrown away — the owning process still holds the un-advanced state and decrypts the same message again, independently, when it next runs. Two readers of one snapshot; neither can desynchronise the other.

Handshake traffic returns Ok(None) without being processed. A Commit moves the group to a new epoch and a Welcome joins one, and both are state changes whose whole point is to be durable — applying either in a process that discards its writes would burn the message for a preview nobody could act on, and a self-removing Commit would tear down group state the owner still needs. There is nothing to show for them anyway: they carry no user-visible text.

Ok(None) also covers “already applied” (the hydrated cursor rejects a replay) and “not a conversation this device knows”.

Source

pub fn user_id(&self) -> UserId

Source

pub fn export_identity(&self) -> Zeroizing<Vec<u8>>

Export this client’s account identity (the Ed25519 seed, CBOR-wrapped — same format Identity::import / MessagingClient::init(identity_export) accept). SECRET. Hosts use this to TRANSFER the account identity to a newly-linked device over the sealed linking channel, so every linked device shares ONE user_id and IncomingMessage.sender_user_id equals the local user_id() for any of the account’s own devices — the basis for cross-device self-attribution. Never log or persist in cleartext.

Source

pub fn device_id(&self) -> DeviceId

Source

pub fn device_info(&self, now_ms: u64) -> DeviceInfo

Source

pub async fn fresh_key_package(&self) -> Result<Vec<u8>>

Generate a fresh KeyPackage to publish to the directory. Hosts call this when registering a device or topping up the directory.

build() writes the private init + encryption keys into the storage provider’s working set, but ON ITS OWN that write is NOT durable: on the WASM/AsyncBlob backend the working set only reaches IndexedDB at the next checkpoint_async, so a page reload before the next state-changing op loses the private keys while the PUBLIC KeyPackage has already been published. Any Welcome later bound to that KeyPackage then fails with “No matching key package was found in the key store” (breaking calls and every invite to this device). So we checkpoint HERE, before returning the bytes the host will publish — the published KeyPackage is durable the instant it leaves this function. Hence async.

Source

pub async fn fresh_last_resort_key_package(&self) -> Result<Vec<u8>>

Generate a fresh LAST-RESORT KeyPackage.

A normal KeyPackage is single-use: once a Welcome consumes it, the private init key is deleted and the directory entry is burned. When a device’s published pool runs dry, every invite to that device hard-fails (“user unavailable”) until it comes online and tops up — the classic “I added them but they never got it” complaint.

A last-resort KeyPackage (RFC 9420 §10) carries the LastResort extension, signalling the server it may serve this KeyPackage MORE THAN ONCE when no single-use KeyPackages remain. The host publishes exactly one per device; the server keeps it as the always-available fallback so an invite never fails purely because the pool emptied. Forward secrecy for the joining epoch is slightly weaker (the init key is reused until replenishment), which is the accepted RFC trade-off for availability.

Source

pub async fn create_conversation( self: &Arc<Self>, name: Option<String>, now_ms: u64, ) -> Result<ConversationId>

Create a new conversation owned by this client (and seeded with a single member: this device).

Source

pub async fn create_call_conversation( self: &Arc<Self>, name: Option<String>, now_ms: u64, ) -> Result<ConversationId>

Create an ephemeral per-call MLS group. Identical to [create_conversation] except the id carries the 0xFF 0xCC call-group sentinel (see ConversationId::new_call_group), so every device — the callee joining via a name-stripped Welcome, a freshly-linked sibling — recognises it as a call group by its id alone and keeps it out of the chat list, with no dependence on the (creator-local) call: name or a per-device registry. Hosts MUST mint call groups via this method rather than create_conversation(name: "call:…") to get the structural guarantee.

Source

pub async fn join_conversation( self: &Arc<Self>, welcome_envelope: &MessageEnvelope, now_ms: u64, ) -> Result<ConversationId>

Join via a Welcome bundled in a MessageEnvelope of kind Welcome.

Source

pub fn stranded_conversations(&self) -> Vec<ConversationId>

Conversations detected as STRANDED during catch-up — a Commit was missed and the group can no longer advance from local state. The host should recover each (re-Welcome from a peer, or a same-user state-snapshot import) so messages start delivering again. The set self-clears as a conversation makes progress or is re-joined.

Source

pub fn list_conversations(&self) -> Vec<ConversationMeta>

Source

pub fn members(&self, conv_id: ConversationId) -> Vec<MemberInfo>

Member roster for a conversation, recovered locally from the MLS group’s leaf credentials. Empty if the conversation is unknown to this client. Lets any device (including one that just joined via a linking Welcome) resolve a 1:1 peer’s UserId without the out-of-band ping.profile re-send.

Source

pub async fn send( &self, conv_id: ConversationId, plaintext: Vec<u8>, now_ms: u64, ) -> Result<MessageEnvelope>

Send an application message. Returns once the envelope has been handed to the transport.

Source

pub async fn add_members( &self, conv_id: ConversationId, entries: Vec<(DeviceId, Vec<u8>)>, now_ms: u64, ) -> Result<()>

Add members. The Commit goes on the wire; the Welcome should be delivered to the new devices’ inboxes (the host transport implements that — typically as a separate addressed envelope).

[CR-2] Each entry is (DeviceId, KeyPackage_bytes). The host typically gets the device_id from the directory at the same time it gets the KeyPackage; we use it to record a per-conversation device_id → leaf_index map so Self::revoke_device can later locate the leaf without a fresh directory lookup. The SDK does not cryptographically verify the host’s device-id claim — that’s a directory policy concern.

Source

pub async fn set_conversation_name( &self, conv_id: ConversationId, name: Option<String>, now_ms: u64, ) -> Result<()>

Change a conversation’s name (carried in the GroupContext) and broadcast the change to every member as an MLS GroupContextExtensions Commit. Unlike a hydration broadcast, the new name rides MLS group STATE, so every member — and every future joiner via the GroupInfo — converges on it. Hosts use this to make a rename or an embedded avatar-media-id change bulletproof (the name carries the ping:meta:v1: blob).

No Welcome (membership is unchanged). Uses the same send-then-merge rollback discipline as Self::add_members so a server-rejected Commit never desyncs the local epoch. All members must have re-linked since the group-name capability shipped (see conversation::ping_leaf_capabilities), else openmls rejects the Commit.

Source

pub async fn admit_device_to_chats( &self, new_device_id: DeviceId, kps_per_chat: Vec<(ConversationId, Vec<u8>)>, now_ms: u64, ) -> Result<Vec<AdmitChatOutcome>>

Admits new_device_id to every conversation in kps_per_chat via the standard MLS add_members flow — one Commit + one Welcome per chat. This is the SDK-side replacement for the host’s previous per-chat reconciler loop after device linking; centralising it here means iOS/Android/web hosts all share the orchestration and the transport’s Welcome-recipient priming is automatic.

Inputs:

  • new_device_id: the device being admitted (matches the device_binding_sig recipient in the linking ticket).
  • kps_per_chat: one freshly-claimed KeyPackage per chat. The host claims these via the auth-layer’s per-account KP pool (GET /v1/devices/{accountId}) AFTER the new device’s bootstrap has uploaded its KP batch.
  • now_ms: wall-clock used to stamp HLCs on the emitted envelopes.

Per-chat failures (unknown conversation, MLS error, transport error, etc.) are CAPTURED in the returned vec rather than short-circuiting the whole call — losing one chat shouldn’t strand the new device on every other chat. The caller decides whether to retry the failed entries (e.g. with a fresh KP).

Source

pub async fn remove_members( &self, conv_id: ConversationId, leaf_indexes: Vec<u32>, now_ms: u64, ) -> Result<()>

Source

pub async fn re_admit_device( &self, conv_id: ConversationId, entry: (DeviceId, Vec<u8>), now_ms: u64, ) -> Result<()>

Re-admit a device, first evicting any existing leaf that duplicates the new KeyPackage’s signature key (the phrase-restore case — see Conversation::duplicate_signature_key_leaves and docs/specs/re-admit-device.md). Equivalent to Self::add_members when there is no duplicate, so it is a strict superset — safe to prefer on the recovery re-admission path.

It composes the two conformance-tested membership primitives — remove_members (evict the dead duplicate leaf, freeing its signing key) then add_members (admit the fresh device + ship its Welcome) — each with its own send-then-merge rollback, so a server-rejected Commit never leaves the local epoch ahead of the server. Two Commits on the rare recovery path; folding them into a single combined Remove+Add commit is a possible future optimization (kept out of scope to reuse already-vetted primitives). If the remove succeeds but the add fails, the device is simply un-admitted (no worse than before) and the caller retries.

Source

pub async fn leave_conversation( &self, conv_id: ConversationId, now_ms: u64, ) -> Result<()>

Leave a conversation. Broadcasts a self-Remove PROPOSAL (MLS doesn’t allow committing your own removal — a remaining member commits it via Self::commit_pending_proposals). After this returns, the host should delete the conversation locally; the leaver remains a cryptographic member only until a peer commits the proposal, at which point the server stops delivering to this device.

Source

pub async fn drop_conversation_local( &self, conv_id: ConversationId, ) -> Result<()>

Drop a conversation’s ENTIRE local state with no network side effect.

Unlike Self::leave_conversation (which broadcasts a self-Remove proposal so a remaining member evicts you), this is a purely LOCAL teardown for a group the server does not back — e.g. an invite that minted the MLS group locally but never completed server-side, so the backend 404s fetchSince / 403s the member roster for it. The host detects that authoritative “not a member” verdict and calls this so the dead group stops rehydrating on every restart (and re-materialising as a ghost conversation). No envelope is sent — there is no live group to send to.

Deletes the OpenMLS group state AND the host-side snapshot rows (groups/{id}/…, cursors/{id}, device_leaves/{id}) that Self::rehydrate_conversations would otherwise reload, then drops the in-memory handle + stranded marker. Idempotent: an unknown id still best-effort purges any orphan storage rows, and calling it twice is safe.

Source

pub fn conversations_with_pending_proposals(&self) -> Vec<ConversationId>

Conversations with buffered pending proposals (e.g. a peer’s leave proposal awaiting a Commit). The host polls this after a sync and, if it is the designated committer, calls Self::commit_pending_proposals to evict the leaver. Sorted for determinism.

Source

pub async fn commit_pending_proposals( &self, conv_id: ConversationId, now_ms: u64, ) -> Result<()>

Commit all buffered pending proposals for a conversation (evicts a peer who left). No-op (Ok) when nothing is pending. Send-then-merge with rollback like add/remove so a server-rejected Commit doesn’t desync the epoch — on an epoch_advanced rejection the host should re-sync (another member already committed) and the pending proposal will have cleared.

Source

pub async fn process_envelope( &self, env: &MessageEnvelope, now_ms: u64, ) -> Result<Option<IncomingMessage>>

Process an inbound envelope coming from the transport’s subscribe callback or a sync pull. Returns Some for application traffic, None for handshake messages (already merged).

LIVE path: applies the envelope to the in-memory MLS working set and then durably flushes THIS envelope before returning (per-envelope crash-safety for streaming). The catch-up drain (sync_conversations) instead applies a whole page in-memory and flushes ONCE per page — see apply_envelope_in_memory.

Source

pub async fn sync_conversations( &self, now_ms: u64, ) -> Result<Vec<IncomingMessage>>

Catch-up sync: pull missing events for every open conversation since its cursor. Returns the list of newly-decrypted application messages, in apply order.

Source

pub async fn build_linking_ticket( self: &Arc<Self>, new_device_id: DeviceId, new_device_kp: Vec<u8>, last_app_events: Vec<(ConversationId, Vec<u8>)>, now_ms: u64, ) -> Result<LinkingTicket>

Build a LinkingTicket for a new device. The caller obtains new_device_kp from the new device (e.g., via QR-encoded handshake) and is responsible for sealing the returned ticket against the new device’s ephemeral X25519 pubkey before transmission via [ping_link::seal_ticket].

[CR-13] last_app_events is a host-supplied list of (conversation_id, app_event_bytes) for the new device’s “what you missed” UI. The SDK adds its own metas + (currently- empty) per-conversation MLS state and bundles everything into [device::CatchupSnapshot], CBOR-encoded into the ticket’s catchup_snapshot field. Pass an empty Vec to suppress catchup data (the new device sees an empty conversation list until normal sync runs).

Source

pub async fn consume_linking_ticket( self: &Arc<Self>, ticket: &LinkingTicket, now_ms: u64, ) -> Result<()>

Apply a received linking ticket. Joins the user’s DeviceGroup; the catch-up snapshot (if any) is decrypted by the host using the standard per-conversation channel afterwards.

Source

pub fn export_conversation_state_snapshot( &self, conv_id: ConversationId, now_ms: u64, ) -> Result<Zeroizing<Vec<u8>>>

[CR-7] Export the MLS state snapshot for one open conversation.

Thin pass-through to Conversation::export_state_snapshot. Returned bytes are wrapped in Zeroizing because they contain past epoch secrets.

Source

pub async fn import_state_snapshot( self: &Arc<Self>, snapshot_bytes: &[u8], now_ms: u64, ) -> Result<ConversationId>

[CR-7] Import a GroupStateSnapshot produced by another device’s Conversation::export_state_snapshot.

Replays the snapshot’s entries into this client’s OpenMLS provider, then reconstructs the Conversation handle via MlsGroup::load. After return, the conversation is in list_conversations() and send/process_envelope work against it normally.

Scope. This is for the same-user hand-off (linking, recovery). The snapshot exposes the exporter’s view of past epoch secrets for the target group; only call this when the receiving device has been authenticated to the same user identity (mnemonic, QR-handshake). Cross-user history transfer uses HPKE-sealed AppEvent re-shares (umbrella §15.6), not this method.

Sanity. Refuses snapshots whose group_id doesn’t match the bytes the receiver intends to claim — guards against host bugs that shuffle snapshots between groups. Refuses mismatched OpenMLS storage versions outright; no silent forward/back compatibility.

Source

pub fn export_conversation_secret( &self, conv_id: ConversationId, label: &str, context: &[u8], length: usize, ) -> Result<Zeroizing<Vec<u8>>>

Export a derived secret from one conversation’s MLS exporter ([CR-8]).

Thin pass-through to Conversation::export_secret. See that method’s doc comment for the contract on label, context, length validation, and zeroization. The returned Zeroizing<Vec<u8>> is automatically wiped when dropped.

Source

pub async fn revoke_device( &self, device_id: DeviceId, now_ms: u64, ) -> Result<Vec<MessageEnvelope>>

Revoke a device by removing its leaf from every conversation where we know its position ([CR-2]).

Returns one Commit envelope per conversation the device was a leaf in. The host broadcasts each envelope to the affected conversation; the SDK has also already handed them to the transport via transport.send (idempotent broadcast is the host’s call).

Scope. The SDK can only resolve leaves it recorded itself — either when it admitted the device via Self::add_members or when this device joined as the target via Welcome. For peer-admitted devices the leaf index isn’t locally known; those conversations are silently skipped. The host can fall back to remove_members(leaf_index) directly using a transport-side directory lookup if it needs to revoke from those conversations too. See docs/architecture/multi-device.md §Device removal for the broader flow.

Conversations with no entry for device_id produce no envelope; an empty Vec return is a valid outcome (e.g. the device was already revoked, or was never added by this client).

Trait Implementations§

Source§

impl Debug for MessagingClient

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> Classify for T

Source§

type Classified = T

Source§

fn classify(self) -> T

Source§

impl<T> Declassify for T

Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T> Instrument for T

Source§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided Span, returning an Instrumented wrapper. Read more
Source§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> IntoEither for T

Source§

fn into_either(self, into_left: bool) -> Either<Self, Self>

Converts self into a Left variant of Either<Self, Self> if into_left is true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
Source§

fn into_either_with<F>(self, into_left: F) -> Either<Self, Self>
where F: FnOnce(&Self) -> bool,

Converts self into a Left variant of Either<Self, Self> if into_left(&self) returns true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
Source§

impl<T> Pointable for T

Source§

const ALIGN: usize

The alignment of pointer.
Source§

type Init = T

The type for initializers.
Source§

unsafe fn init(init: <T as Pointable>::Init) -> usize

Initializes a with the given initializer. Read more
Source§

unsafe fn deref<'a>(ptr: usize) -> &'a T

Dereferences the given pointer. Read more
Source§

unsafe fn deref_mut<'a>(ptr: usize) -> &'a mut T

Mutably dereferences the given pointer. Read more
Source§

unsafe fn drop(ptr: usize)

Drops the object pointed to by the given pointer. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
Source§

impl<S, T> Upcast<T> for S
where T: UpcastFrom<S> + ?Sized, S: ?Sized,

Source§

fn upcast(&self) -> &T
where Self: ErasableGeneric, T: Sized + ErasableGeneric<Repr = Self::Repr>,

Perform a zero-cost type-safe upcast to a wider ref type within the Wasm bindgen generics type system. Read more
Source§

fn upcast_into(self) -> T
where Self: Sized + ErasableGeneric, T: Sized + ErasableGeneric<Repr = Self::Repr>,

Perform a zero-cost type-safe upcast to a wider type within the Wasm bindgen generics type system. Read more
Source§

impl<V, T> VZip<V> for T
where V: MultiLane<T>,

Source§

fn vzip(self) -> V

Source§

impl<T> WithSubscriber for T

Source§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a WithDispatch wrapper. Read more
Source§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a WithDispatch wrapper. Read more