gbp_mls/lib.rs
1//! MLS (RFC 9420) integration for the Group Protocol Stack.
2//!
3//! This crate provides:
4//!
5//! * [`MlsContext`] — a member-side wrapper around an `openmls 0.8` group
6//! (signing key, credential, provider, current group).
7//! * [`StreamLabel`] — labelled exporter constants used to derive AEAD keys
8//! from the MLS exporter (`gbp/control`, `gbp/audio`, `gbp/text`,
9//! `gbp/signal`).
10//! * `seal` / `open` — ChaCha20-Poly1305 AEAD with the labelled-exporter key.
11//!
12//! On every epoch change the old key material is invalidated automatically:
13//! the AEAD key is derived on the fly from `MlsGroup::export_secret`, never
14//! cached, and the previous epoch's secret becomes unreachable as soon as the
15//! group ratchets forward.
16
17#![deny(missing_docs)]
18
19use chacha20poly1305::{
20 ChaCha20Poly1305, Key, Nonce,
21 aead::{Aead, KeyInit},
22};
23use gbp_core::StreamType;
24use openmls::prelude::tls_codec::Serialize as _;
25use openmls::prelude::*;
26use openmls_basic_credential::SignatureKeyPair;
27use openmls_rust_crypto::OpenMlsRustCrypto;
28
29/// MLS ciphersuite used by the stack: X25519-AES128GCM-SHA256-Ed25519.
30pub const CIPHERSUITE: Ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
31
32/// Exporter label that binds the AEAD key to a stream class.
33#[derive(Copy, Clone, Debug, PartialEq, Eq)]
34pub enum StreamLabel {
35 /// `gbp/control` — control plane key.
36 Control,
37 /// `gbp/audio` — GAP key.
38 Audio,
39 /// `gbp/text` — GTP key.
40 Text,
41 /// `gbp/signal` — GSP key.
42 Signal,
43}
44
45impl StreamLabel {
46 /// Returns the stable string used as the `MlsGroup::export_secret` label.
47 pub fn as_str(self) -> &'static str {
48 match self {
49 Self::Control => "gbp/control",
50 Self::Audio => "gbp/audio",
51 Self::Text => "gbp/text",
52 Self::Signal => "gbp/signal",
53 }
54 }
55}
56
57/// Maps a [`StreamType`] to the corresponding [`StreamLabel`].
58pub fn label_for(st: StreamType) -> StreamLabel {
59 match st {
60 StreamType::Control => StreamLabel::Control,
61 StreamType::Audio => StreamLabel::Audio,
62 StreamType::Text => StreamLabel::Text,
63 StreamType::Signal => StreamLabel::Signal,
64 }
65}
66
67/// Categorises an MLS message processed via
68/// [`MlsContext::process_message`].
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum ProcessedKind {
71 /// A Commit message was applied to the group; epoch advanced.
72 Commit,
73 /// An Application message was decrypted (not used by this stack — GBP
74 /// carries application data outside MLS application messages).
75 Application,
76 /// A Proposal-only message was staged.
77 Proposal,
78 /// An external message that did not advance the group.
79 External,
80}
81
82/// Errors raised by the MLS / AEAD layer.
83#[derive(Debug, thiserror::Error)]
84pub enum MlsError {
85 /// Any error returned by `openmls`, serialised as a string.
86 #[error("openmls: {0}")]
87 OpenMls(String),
88 /// AEAD seal or open failure.
89 #[error("aead: {0}")]
90 Aead(String),
91 /// A pending staged commit already exists — the previous transition must
92 /// be finalised or cleared before processing another commit.
93 #[error("transition in progress: pending staged commit exists")]
94 TransitionInProgress,
95}
96
97/// MLS context for a single group member.
98///
99/// Owns the OpenMLS provider, the signing key, the credential and the
100/// current `MlsGroup`. Ratcheting forward is performed by [`MlsContext::invite`]
101/// and [`MlsContext::accept_welcome`].
102pub struct MlsContext {
103 /// OpenMLS crypto provider.
104 pub provider: OpenMlsRustCrypto,
105 /// Signing key pair for this member.
106 pub signer: SignatureKeyPair,
107 /// Current MLS group.
108 pub group: MlsGroup,
109 /// Credential with the public signing key.
110 pub credential: CredentialWithKey,
111 /// Member identity (opaque application-defined bytes).
112 pub identity: Vec<u8>,
113 /// Staged commit produced by [`MlsContext::process_message`] but not
114 /// yet merged. Held until [`MlsContext::finalize_pending_commit`] (on
115 /// EXECUTE_TRANSITION) so that the local epoch only advances together
116 /// with the rest of the group, never earlier — otherwise this side's
117 /// READY frame would be sealed under an epoch the coordinator can't
118 /// open.
119 pub pending_staged: Option<StagedCommit>,
120}
121
122impl MlsContext {
123 /// Creates a new context with a single-member group, returning the
124 /// context together with a [`KeyPackageBundle`] that other members can
125 /// use to invite this one.
126 pub fn new_member(identity: &[u8]) -> Result<(Self, KeyPackageBundle), MlsError> {
127 let provider = OpenMlsRustCrypto::default();
128 let signer = SignatureKeyPair::new(CIPHERSUITE.signature_algorithm())
129 .map_err(|e| MlsError::OpenMls(format!("signer: {e:?}")))?;
130 signer
131 .store(provider.storage())
132 .map_err(|e| MlsError::OpenMls(format!("store signer: {e:?}")))?;
133
134 let credential = BasicCredential::new(identity.to_vec());
135 let credential_with_key = CredentialWithKey {
136 credential: credential.into(),
137 signature_key: signer.public().into(),
138 };
139
140 let kp_bundle = KeyPackage::builder()
141 .build(CIPHERSUITE, &provider, &signer, credential_with_key.clone())
142 .map_err(|e| MlsError::OpenMls(format!("kp: {e:?}")))?;
143
144 let cfg = MlsGroupCreateConfig::builder()
145 .ciphersuite(CIPHERSUITE)
146 .use_ratchet_tree_extension(true)
147 .build();
148 let group = MlsGroup::new(&provider, &signer, &cfg, credential_with_key.clone())
149 .map_err(|e| MlsError::OpenMls(format!("group: {e:?}")))?;
150
151 Ok((
152 Self {
153 provider,
154 signer,
155 group,
156 credential: credential_with_key,
157 identity: identity.to_vec(),
158 pending_staged: None,
159 },
160 kp_bundle,
161 ))
162 }
163
164 /// Result of [`MlsContext::invite_full`]: the Commit message that
165 /// existing members must apply via [`MlsContext::process_message`],
166 /// plus the Welcome that the new joiner must apply via
167 /// [`MlsContext::accept_welcome`].
168 ///
169 /// RFC 9420 §11/§12.4 — Welcome is for the joiner only; existing members
170 /// MUST receive the Commit to advance their epoch.
171 ///
172 /// IMPORTANT: this call **does not** merge the pending commit. The
173 /// caller MUST call [`MlsContext::finalize_pending_commit`] only after
174 /// they are confident the Commit/Welcome have been distributed (e.g.
175 /// the GBP coordinator has observed READY quorum). If the distribution
176 /// fails, call [`MlsContext::clear_pending_commit`] to roll back.
177 pub fn invite_full(
178 &mut self,
179 key_packages: &[KeyPackage],
180 ) -> Result<(Vec<u8>, Vec<u8>), MlsError> {
181 let (commit, welcome, _gi) = self
182 .group
183 .add_members(&self.provider, &self.signer, key_packages)
184 .map_err(|e| MlsError::OpenMls(format!("add_members: {e:?}")))?;
185 let commit_bytes = commit
186 .tls_serialize_detached()
187 .map_err(|e| MlsError::OpenMls(format!("commit serialize: {e:?}")))?;
188 let welcome_bytes = welcome
189 .tls_serialize_detached()
190 .map_err(|e| MlsError::OpenMls(format!("welcome serialize: {e:?}")))?;
191 Ok((commit_bytes, welcome_bytes))
192 }
193
194 /// Backwards-compatible wrapper. Builds the Commit, eagerly merges, and
195 /// returns only the Welcome bytes. Kept for callers that distribute the
196 /// Commit out-of-band and don't need atomic abort semantics.
197 pub fn invite(&mut self, key_packages: &[KeyPackage]) -> Result<Vec<u8>, MlsError> {
198 let (_commit, welcome) = self.invite_full(key_packages)?;
199 self.finalize_pending_commit()?;
200 Ok(welcome)
201 }
202
203 /// Removes members identified by their MLS LeafIndex via a Remove commit
204 /// and returns the TLS-serialised Commit message that remaining members
205 /// must apply via [`MlsContext::process_message`].
206 ///
207 /// Like [`MlsContext::invite_full`], the caller is responsible for
208 /// calling [`MlsContext::finalize_pending_commit`] after successful
209 /// distribution, or [`MlsContext::clear_pending_commit`] on failure.
210 /// RFC 9420 §12.3.
211 pub fn remove_members(&mut self, leaf_indices: &[u32]) -> Result<Vec<u8>, MlsError> {
212 // Validate indices against the current group size up front so the
213 // caller gets a clear error rather than an opaque openmls failure.
214 let group_size = self.group.members().count() as u32;
215 for &idx in leaf_indices {
216 if idx >= group_size {
217 return Err(MlsError::OpenMls(format!(
218 "leaf_index {idx} out of range (group size {group_size})"
219 )));
220 }
221 }
222 let leaves: Vec<LeafNodeIndex> =
223 leaf_indices.iter().copied().map(LeafNodeIndex::new).collect();
224 let (commit, _welcome_opt, _gi) = self
225 .group
226 .remove_members(&self.provider, &self.signer, &leaves)
227 .map_err(|e| MlsError::OpenMls(format!("remove_members: {e:?}")))?;
228 commit
229 .tls_serialize_detached()
230 .map_err(|e| MlsError::OpenMls(format!("commit serialize: {e:?}")))
231 }
232
233 /// Merges any pending commit. Handles both:
234 /// * a self-issued commit produced by [`MlsContext::invite_full`] /
235 /// [`MlsContext::remove_members`] (merged via `merge_pending_commit`);
236 /// * a staged commit deposited by [`MlsContext::process_message`]
237 /// (merged via `merge_staged_commit`, consumed from
238 /// [`MlsContext::pending_staged`]).
239 ///
240 /// Idempotent: if there is nothing to merge, returns Ok. Called from
241 /// the GBP control plane in response to `EXECUTE_TRANSITION`.
242 pub fn finalize_pending_commit(&mut self) -> Result<(), MlsError> {
243 if let Some(staged) = self.pending_staged.take() {
244 self.group
245 .merge_staged_commit(&self.provider, staged)
246 .map_err(|e| MlsError::OpenMls(format!("merge_staged: {e:?}")))?;
247 }
248 // merge_pending_commit errors if there's nothing to merge — for
249 // members that only received a commit (no self-issued one) that's
250 // expected, so swallow the error. Self-issued commits are merged
251 // via this path on the coordinator side.
252 let _ = self.group.merge_pending_commit(&self.provider);
253 Ok(())
254 }
255
256 /// Discards any pending commit (self-issued and/or staged) without
257 /// applying it. Used on `ABORT_TRANSITION`.
258 pub fn clear_pending_commit(&mut self) -> Result<(), MlsError> {
259 self.pending_staged = None;
260 self.group
261 .clear_pending_commit(self.provider.storage())
262 .map_err(|e| MlsError::OpenMls(format!("clear: {e:?}")))?;
263 Ok(())
264 }
265
266 /// Applies a Commit (or staged Proposal) message to the group. Existing
267 /// members invoke this after receiving the Commit broadcast embedded in
268 /// `PREPARE_TRANSITION` args.
269 ///
270 /// IMPORTANT: a Commit is staged but **not** merged here. It must be
271 /// merged via [`MlsContext::finalize_pending_commit`] in response to the
272 /// matching `EXECUTE_TRANSITION`, so that this side's MLS epoch
273 /// advances together with the rest of the group — never earlier.
274 /// Calling this twice without an intervening finalize/clear discards
275 /// the previously staged commit (the second call wins).
276 pub fn process_message(&mut self, msg_bytes: &[u8]) -> Result<ProcessedKind, MlsError> {
277 let msg_in = MlsMessageIn::tls_deserialize_exact_bytes(msg_bytes)
278 .map_err(|e| MlsError::OpenMls(format!("msg parse: {e:?}")))?;
279 let protocol_msg = match msg_in.extract() {
280 MlsMessageBodyIn::PublicMessage(m) => ProtocolMessage::from(m),
281 MlsMessageBodyIn::PrivateMessage(m) => ProtocolMessage::from(m),
282 other => {
283 return Err(MlsError::OpenMls(format!(
284 "expected protocol message, got {other:?}"
285 )));
286 }
287 };
288 let processed = self
289 .group
290 .process_message(&self.provider, protocol_msg)
291 .map_err(|e| MlsError::OpenMls(format!("process: {e:?}")))?;
292 match processed.into_content() {
293 ProcessedMessageContent::StagedCommitMessage(staged) => {
294 if self.pending_staged.is_some() {
295 return Err(MlsError::TransitionInProgress);
296 }
297 self.pending_staged = Some(*staged);
298 Ok(ProcessedKind::Commit)
299 }
300 ProcessedMessageContent::ApplicationMessage(_) => Ok(ProcessedKind::Application),
301 ProcessedMessageContent::ProposalMessage(_) => Ok(ProcessedKind::Proposal),
302 ProcessedMessageContent::ExternalJoinProposalMessage(_) => Ok(ProcessedKind::External),
303 }
304 }
305
306 /// Replaces the local group with the one described by the given
307 /// `Welcome` message.
308 pub fn accept_welcome(&mut self, welcome_bytes: &[u8]) -> Result<(), MlsError> {
309 let msg_in = MlsMessageIn::tls_deserialize_exact_bytes(welcome_bytes)
310 .map_err(|e| MlsError::OpenMls(format!("welcome parse: {e:?}")))?;
311 let welcome = match msg_in.extract() {
312 MlsMessageBodyIn::Welcome(w) => w,
313 other => {
314 return Err(MlsError::OpenMls(format!(
315 "expected welcome, got {other:?}"
316 )));
317 }
318 };
319 let join_cfg = MlsGroupJoinConfig::builder()
320 .use_ratchet_tree_extension(true)
321 .build();
322 let staged = StagedWelcome::new_from_welcome(&self.provider, &join_cfg, welcome, None)
323 .map_err(|e| MlsError::OpenMls(format!("staged: {e:?}")))?;
324 self.group = staged
325 .into_group(&self.provider)
326 .map_err(|e| MlsError::OpenMls(format!("into_group: {e:?}")))?;
327 Ok(())
328 }
329
330 /// Returns the current group epoch.
331 pub fn epoch(&self) -> u64 {
332 self.group.epoch().as_u64()
333 }
334
335 /// Returns the 16-byte group identifier (truncated or zero-padded if the
336 /// underlying MLS group_id has a different length).
337 pub fn group_id_16(&self) -> [u8; 16] {
338 let raw = self.group.group_id().as_slice();
339 let mut out = [0u8; 16];
340 let n = raw.len().min(16);
341 out[..n].copy_from_slice(&raw[..n]);
342 out
343 }
344
345 /// Exports a 32-byte secret under the given stream label.
346 pub fn export_stream_key(&self, label: StreamLabel) -> Result<[u8; 32], MlsError> {
347 let secret = self
348 .group
349 .export_secret(self.provider.crypto(), label.as_str(), &[], 32)
350 .map_err(|e| MlsError::OpenMls(format!("export: {e:?}")))?;
351 let mut out = [0u8; 32];
352 out.copy_from_slice(&secret);
353 Ok(out)
354 }
355
356 /// Encrypts `plaintext` with ChaCha20-Poly1305 using the stream-labelled
357 /// AEAD key and a nonce derived from the per-stream `seq`.
358 pub fn seal(
359 &self,
360 label: StreamLabel,
361 seq: u32,
362 plaintext: &[u8],
363 ) -> Result<Vec<u8>, MlsError> {
364 let key = self.export_stream_key(label)?;
365 let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
366 let mut nonce = [0u8; 12];
367 nonce[..4].copy_from_slice(&seq.to_be_bytes());
368 cipher
369 .encrypt(Nonce::from_slice(&nonce), plaintext)
370 .map_err(|e| MlsError::Aead(e.to_string()))
371 }
372
373 /// Decrypts `ciphertext` with the same parameters as [`MlsContext::seal`].
374 pub fn open(
375 &self,
376 label: StreamLabel,
377 seq: u32,
378 ciphertext: &[u8],
379 ) -> Result<Vec<u8>, MlsError> {
380 let key = self.export_stream_key(label)?;
381 let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
382 let mut nonce = [0u8; 12];
383 nonce[..4].copy_from_slice(&seq.to_be_bytes());
384 cipher
385 .decrypt(Nonce::from_slice(&nonce), ciphertext)
386 .map_err(|e| MlsError::Aead(e.to_string()))
387 }
388}