pub struct MessagingClient { /* private fields */ }Implementations§
Source§impl MessagingClient
impl MessagingClient
Sourcepub async fn init(cfg: ClientConfig) -> Result<Arc<Self>>
pub async fn init(cfg: ClientConfig) -> Result<Arc<Self>>
Initialise. Creates a new local device if none is recorded in storage; otherwise rehydrates.
Sourcepub async fn open_read_only(cfg: ClientConfig) -> Result<Arc<Self>>
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
LocalDeviceis 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.
Sourcepub fn preview_envelope(
&self,
env: &MessageEnvelope,
now_ms: u64,
) -> Result<Option<IncomingMessage>>
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”.
pub fn user_id(&self) -> UserId
Sourcepub fn export_identity(&self) -> Zeroizing<Vec<u8>>
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.
pub fn device_id(&self) -> DeviceId
pub fn device_info(&self, now_ms: u64) -> DeviceInfo
Sourcepub async fn fresh_key_package(&self) -> Result<Vec<u8>>
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.
Sourcepub async fn fresh_last_resort_key_package(&self) -> Result<Vec<u8>>
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.
Sourcepub async fn create_conversation(
self: &Arc<Self>,
name: Option<String>,
now_ms: u64,
) -> Result<ConversationId>
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).
Sourcepub async fn create_call_conversation(
self: &Arc<Self>,
name: Option<String>,
now_ms: u64,
) -> Result<ConversationId>
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.
Sourcepub async fn join_conversation(
self: &Arc<Self>,
welcome_envelope: &MessageEnvelope,
now_ms: u64,
) -> Result<ConversationId>
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.
Sourcepub fn stranded_conversations(&self) -> Vec<ConversationId>
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.
pub fn list_conversations(&self) -> Vec<ConversationMeta>
Sourcepub fn members(&self, conv_id: ConversationId) -> Vec<MemberInfo>
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.
Sourcepub async fn send(
&self,
conv_id: ConversationId,
plaintext: Vec<u8>,
now_ms: u64,
) -> Result<MessageEnvelope>
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.
Sourcepub async fn add_members(
&self,
conv_id: ConversationId,
entries: Vec<(DeviceId, Vec<u8>)>,
now_ms: u64,
) -> Result<()>
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.
Sourcepub async fn set_conversation_name(
&self,
conv_id: ConversationId,
name: Option<String>,
now_ms: u64,
) -> Result<()>
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.
Sourcepub async fn admit_device_to_chats(
&self,
new_device_id: DeviceId,
kps_per_chat: Vec<(ConversationId, Vec<u8>)>,
now_ms: u64,
) -> Result<Vec<AdmitChatOutcome>>
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 thedevice_binding_sigrecipient 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).
pub async fn remove_members( &self, conv_id: ConversationId, leaf_indexes: Vec<u32>, now_ms: u64, ) -> Result<()>
Sourcepub async fn re_admit_device(
&self,
conv_id: ConversationId,
entry: (DeviceId, Vec<u8>),
now_ms: u64,
) -> Result<()>
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.
Sourcepub async fn leave_conversation(
&self,
conv_id: ConversationId,
now_ms: u64,
) -> Result<()>
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.
Sourcepub async fn drop_conversation_local(
&self,
conv_id: ConversationId,
) -> Result<()>
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.
Sourcepub fn conversations_with_pending_proposals(&self) -> Vec<ConversationId>
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.
Sourcepub async fn commit_pending_proposals(
&self,
conv_id: ConversationId,
now_ms: u64,
) -> Result<()>
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.
Sourcepub async fn process_envelope(
&self,
env: &MessageEnvelope,
now_ms: u64,
) -> Result<Option<IncomingMessage>>
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.
Sourcepub async fn sync_conversations(
&self,
now_ms: u64,
) -> Result<Vec<IncomingMessage>>
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.
Sourcepub 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>
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).
Sourcepub async fn consume_linking_ticket(
self: &Arc<Self>,
ticket: &LinkingTicket,
now_ms: u64,
) -> Result<()>
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.
Sourcepub fn export_conversation_state_snapshot(
&self,
conv_id: ConversationId,
now_ms: u64,
) -> Result<Zeroizing<Vec<u8>>>
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.
Sourcepub async fn import_state_snapshot(
self: &Arc<Self>,
snapshot_bytes: &[u8],
now_ms: u64,
) -> Result<ConversationId>
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.
Sourcepub fn export_conversation_secret(
&self,
conv_id: ConversationId,
label: &str,
context: &[u8],
length: usize,
) -> Result<Zeroizing<Vec<u8>>>
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.
Sourcepub async fn revoke_device(
&self,
device_id: DeviceId,
now_ms: u64,
) -> Result<Vec<MessageEnvelope>>
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§
Auto Trait Implementations§
impl !Freeze for MessagingClient
impl !RefUnwindSafe for MessagingClient
impl !UnwindSafe for MessagingClient
impl Send for MessagingClient
impl Sync for MessagingClient
impl Unpin for MessagingClient
impl UnsafeUnpin for MessagingClient
Blanket Implementations§
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
Source§impl<T> Declassify for T
impl<T> Declassify for T
type Declassified = T
fn declassify(self) -> T
Source§impl<T> Instrument for T
impl<T> Instrument for T
Source§fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
fn instrument(self, span: Span) -> Instrumented<Self> ⓘ
Source§fn in_current_span(self) -> Instrumented<Self> ⓘ
fn in_current_span(self) -> Instrumented<Self> ⓘ
Source§impl<T> IntoEither for T
impl<T> IntoEither for T
Source§fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
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 moreSource§fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
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