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}