Skip to main content

de_mls/mls_crypto/service/
api.rs

1//! Pluggable MLS backend trait, scoped per conversation.
2//!
3//! [`MlsService`] is the swap point for MLS implementations. The default
4//! impl is [`OpenMlsService`](super::OpenMlsService). One service instance
5//! corresponds to one MLS group; the user's MLS credentials and conversation id
6//! are set at construction and every method operates on that implicit
7//! conversation.
8//!
9//! Conversation construction is intentionally *not* on the trait — concrete impls
10//! expose their own constructors (e.g. `OpenMlsService::new_as_creator` /
11//! `new_from_welcome`), and key-package generation is also off the trait
12//! because a joiner needs to publish a key package before any conversation exists.
13//!
14//! The trait surface uses only opaque boundary types: no `openmls::*`
15//! types appear here, so swapping in a different MLS engine is purely a
16//! matter of writing a new impl. Identity is a separate User-level
17//! concept ([`crate::identity::Identity`]) — the MLS service consumes
18//! credentials built from it but does not own the identity itself.
19
20use openmls::prelude::Ciphersuite;
21
22use crate::{
23    ds::OutboundPacket,
24    mls_crypto::{
25        CommitCandidate, DecryptResult, MlsCommitInput, MlsError, MlsMessageKind,
26        StagedCandidateResult,
27    },
28    protos::de_mls::messages::v1::AppMessage,
29};
30
31/// MLS ciphersuite used by the default OpenMLS-backed impl.
32pub const CIPHERSUITE: Ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
33
34/// Default ceiling on MLS proposals per commit batch. Defends against
35/// runaway batch growth when freeze recovery preserves work across
36/// multiple failed cycles. Per-node config; not synced via `ConversationSync`.
37pub const DEFAULT_COMMIT_BATCH_MAX: usize = 50;
38
39/// Per-conversation MLS backend. Each instance corresponds to one MLS group.
40///
41/// Read-only methods take `&self`; methods that advance MLS state take
42/// `&mut self`. Callers serialize via the outer per-session lock.
43pub trait MlsService {
44    /// The conversation id this service is scoped to.
45    fn conversation_id(&self) -> &str;
46
47    /// Maximum number of MLS proposals the steward will pack into one
48    /// commit batch. Defaults to [`DEFAULT_COMMIT_BATCH_MAX`]; impls may
49    /// override per-instance.
50    fn commit_batch_max(&self) -> usize {
51        DEFAULT_COMMIT_BATCH_MAX
52    }
53
54    // ── Conversation lifecycle ──
55
56    /// Tear down all local MLS state for this conversation. Idempotent so
57    /// repeated leave / cleanup is safe.
58    fn delete(&mut self) -> Result<(), MlsError>;
59
60    // ── Membership / state queries ──
61
62    /// Current conversation members as serialized credential bytes (one entry
63    /// per leaf, in MLS leaf order).
64    fn members(&self) -> Result<Vec<Vec<u8>>, MlsError>;
65
66    /// Whether `identity` is currently a member.
67    fn is_member(&self, identity: &[u8]) -> bool;
68
69    /// Current MLS epoch. This is the single source of truth — never
70    /// maintain a parallel counter at the app layer.
71    fn current_epoch(&self) -> Result<u64, MlsError>;
72
73    // ── Steward-side commit pipeline (we are the committer) ──
74
75    /// Build a commit candidate from a list of membership changes and
76    /// stage it locally. Returns the wire bytes (proposals + commit + an
77    /// optional welcome) for the steward to broadcast.
78    ///
79    /// Side effect: leaves MLS holding our pending proposals and pending
80    /// commit. The caller MUST follow up with
81    /// [`merge_own_commit`](Self::merge_own_commit) once the candidate
82    /// wins selection, or [`discard_own_commit`](Self::discard_own_commit)
83    /// to roll back.
84    fn create_commit_candidate(
85        &mut self,
86        updates: &[MlsCommitInput],
87    ) -> Result<CommitCandidate, MlsError>;
88
89    /// Apply our pending commit, advancing the MLS epoch. Call after a
90    /// successful [`create_commit_candidate`](Self::create_commit_candidate)
91    /// when our candidate has won the freeze round.
92    fn merge_own_commit(&mut self) -> Result<(), MlsError>;
93
94    /// Roll back the local side effects of
95    /// [`create_commit_candidate`](Self::create_commit_candidate):
96    /// drop the pending commit and the pending proposals it contained.
97    fn discard_own_commit(&mut self) -> Result<(), MlsError>;
98
99    // ── Inbound commit pipeline (someone else committed) ──
100
101    /// Validate and stage a remote commit candidate atomically: each
102    /// proposal is processed and stored as MLS-pending, then the commit
103    /// is processed against that pending set, producing a staged commit
104    /// held internally.
105    ///
106    /// Does **not** merge. The caller validates the result (sender,
107    /// authorization, action set vs. voted-approved) and then calls
108    /// [`merge_staged_commit`](Self::merge_staged_commit) to advance the
109    /// epoch, or [`discard_staged_commit`](Self::discard_staged_commit)
110    /// to roll back proposals + staged commit together.
111    ///
112    /// Returns [`StagedCandidateResult::Aborted`] for benign rejections
113    /// (stale epoch, wrong conversation id, wire-shape mismatch). The caller
114    /// must still call `discard_staged_commit` to clean up any partial
115    /// state before trying the next candidate.
116    fn stage_remote_commit(
117        &mut self,
118        proposals: &[Vec<u8>],
119        commit_bytes: &[u8],
120    ) -> Result<StagedCandidateResult, MlsError>;
121
122    /// Apply the previously staged inbound commit, advancing the MLS
123    /// epoch. Errors if no commit is staged.
124    fn merge_staged_commit(&mut self) -> Result<(), MlsError>;
125
126    /// Roll back [`stage_remote_commit`](Self::stage_remote_commit):
127    /// drop the staged commit and clear the pending proposals it
128    /// staged on top of.
129    fn discard_staged_commit(&mut self) -> Result<(), MlsError>;
130
131    // ── Application messages ──
132
133    /// Encrypt an application message for the conversation, returning the raw
134    /// MLS wire bytes.
135    fn encrypt(&mut self, plaintext: &[u8]) -> Result<Vec<u8>, MlsError>;
136
137    /// Encode and encrypt `app_msg` and wrap the result as an
138    /// [`OutboundPacket`] on the application subtopic. The convenience
139    /// path most senders use.
140    fn build_message(
141        &mut self,
142        app_msg: &AppMessage,
143        app_id: &[u8],
144    ) -> Result<OutboundPacket, MlsError>;
145
146    /// Strict app-subtopic decrypt: accepts only `Application` messages,
147    /// silently ignoring anything else (including proposals and commits).
148    /// This guards the app subtopic against MLS-state pollution from
149    /// peers that misroute control messages.
150    fn decrypt_application_only(&mut self, ciphertext: &[u8]) -> Result<DecryptResult, MlsError>;
151
152    /// General decrypt: accepts `Application` messages and stores
153    /// incoming proposals as pending. Commits are out of scope here —
154    /// route them through
155    /// [`stage_remote_commit`](Self::stage_remote_commit) so they pass
156    /// the validation pipeline.
157    fn decrypt(&mut self, ciphertext: &[u8]) -> Result<DecryptResult, MlsError>;
158
159    /// Peek the untrusted outer kind of an MLS wire message without
160    /// processing or signature-checking it. Used for cheap pre-dispatch
161    /// lane checks (e.g. "is this a proposal or a commit").
162    fn inspect_message_kind(&self, message_bytes: &[u8]) -> Result<MlsMessageKind, MlsError>;
163}