Skip to main content

openmls/group/mls_group/commit_builder/
external_commits.rs

1use openmls_traits::{crypto::OpenMlsCrypto, types::Ciphersuite};
2use thiserror::Error;
3use tls_codec::Serialize as _;
4
5#[cfg(doc)]
6use super::CommitMessageBundle;
7#[cfg(doc)]
8use crate::treesync::LeafNodeParameters;
9
10use crate::{
11    binary_tree::LeafNodeIndex,
12    credentials::CredentialWithKey,
13    error::LibraryError,
14    framing::{ContentType, DecryptedMessage, PublicMessageIn, Sender},
15    group::{
16        commit_builder::{CommitBuilder, ExternalCommitInfo, Initial},
17        past_secrets::MessageSecretsStore,
18        public_group::errors::CreationFromExternalError,
19        ExternalCommitBuilderFinalizeError, GroupContext, LeafNodeLifetimePolicy, MlsGroup,
20        MlsGroupJoinConfig, MlsGroupState, PendingCommitState, ProposalStore, PublicGroup,
21        QueuedProposal, ValidationError, PURE_PLAINTEXT_WIRE_FORMAT_POLICY,
22    },
23    messages::{
24        group_info::{GroupInfo, VerifiableGroupInfo},
25        proposals::{
26            ExternalInitProposal, PreSharedKeyProposal, Proposal, ProposalOrRefType, ProposalType,
27            RemoveProposal,
28        },
29    },
30    schedule::{psk::store::ResumptionPskStore, EpochSecrets, InitSecret},
31    storage::OpenMlsProvider,
32    treesync::RatchetTreeIn,
33    versions::ProtocolVersion,
34};
35
36/// Error type for the [`ExternalCommitBuilder`].
37#[derive(Debug, Error)]
38pub enum ExternalCommitBuilderError<StorageError> {
39    /// See [`LibraryError`] for more details.
40    #[error(transparent)]
41    LibraryError(#[from] LibraryError),
42    /// No ratchet tree available to build initial tree.
43    #[error("No ratchet tree available to build initial tree.")]
44    MissingRatchetTree,
45    /// No external_pub extension available to join group by external commit.
46    #[error("No external_pub extension available to join group by external commit.")]
47    MissingExternalPub,
48    /// We don't support the ciphersuite of the group we are trying to join.
49    #[error("Ciphersuite {0:?} of the group we are trying to join is not supported by the crypto provider.")]
50    UnsupportedCiphersuite(Ciphersuite),
51    /// This error indicates the public tree is invalid. See
52    /// [`CreationFromExternalError`] for more details.
53    #[error(transparent)]
54    PublicGroupError(#[from] CreationFromExternalError<StorageError>),
55    /// An error occurred when writing group to storage
56    #[error("An error occurred when writing group to storage.")]
57    StorageError(StorageError),
58    /// Error validating proposals.
59    #[error("Error validating proposals: {0}")]
60    InvalidProposal(#[from] ValidationError),
61}
62
63/// This is the builder for external commits. It allows you to build an external
64/// commit that can be used to join a group externally. Parameters such as
65/// optional SelfRemove proposals from other members, the ratchet tree, and the
66/// group join configuration can be set in the first builder stage.
67///
68/// [`Self::build_group`] verifies the group info and ratchet tree and returns
69/// a [`CommitBuilder`]. To inspect the verified group info and group context
70/// before building the external commit, use [`Self::verify_group`] instead,
71/// which returns a [`VerifiedExternalCommitBuilder`].
72///
73/// The [`CommitBuilder`] stage can be used to add one or more proposals by
74/// value to the external commit and specify [`LeafNodeParameters`]. Note that
75/// only proposal types that RFC 9420 and the MLS extensions draft allow in an
76/// external commit pass validation when the commit is built.
77#[derive(Default)]
78pub struct ExternalCommitBuilder {
79    proposals: Vec<PublicMessageIn>,
80    ratchet_tree: Option<RatchetTreeIn>,
81    config: MlsGroupJoinConfig,
82    validate_lifetimes: LeafNodeLifetimePolicy,
83    aad: Vec<u8>,
84    /// Whether to join the group as an emulation group of a virtual client. Set
85    /// by [`Self::emulation_group`].
86    #[cfg(feature = "virtual-clients-draft")]
87    emulation_group: bool,
88}
89
90impl MlsGroup {
91    /// Creates a new [`ExternalCommitBuilder`] to build an external commit.
92    pub fn external_commit_builder() -> ExternalCommitBuilder {
93        ExternalCommitBuilder::new()
94    }
95}
96
97impl ExternalCommitBuilder {
98    /// Creates a new [`ExternalCommitBuilder`] with default values.
99    pub fn new() -> Self {
100        Self::default()
101    }
102
103    /// Adds SelfRemove proposals to the external commit. Other proposals or
104    /// other types of messages are ignored.
105    pub fn with_proposals(mut self, proposals: Vec<PublicMessageIn>) -> Self {
106        self.proposals = proposals;
107        self
108    }
109
110    /// Specifies the ratchet tree to use for the external commit. This is only
111    /// used if the ratchet tree is not provided in the [`VerifiableGroupInfo`]
112    /// extensions. A ratchet tree must be provided, either in the
113    /// [`VerifiableGroupInfo`] extensions or via this method.
114    pub fn with_ratchet_tree(mut self, ratchet_tree: RatchetTreeIn) -> Self {
115        self.ratchet_tree = Some(ratchet_tree);
116        self
117    }
118
119    /// Specifies the configuration to use for the group built as part of the
120    /// external commit. Note that the external commit will always be a
121    /// `PublicMessage` regardless of the wire format policy set in the group
122    /// config.
123    pub fn with_config(mut self, config: MlsGroupJoinConfig) -> Self {
124        self.config = config;
125        self
126    }
127
128    /// Specifies additional authenticated data (AAD) to be included in the
129    /// external commit. It applies to the external commit only and is not
130    /// retained as the AAD of the group that is being joined.
131    pub fn with_aad(mut self, aad: Vec<u8>) -> Self {
132        self.aad = aad;
133        self
134    }
135
136    /// Join the group as an emulation group of a virtual client. See
137    /// [`MlsGroupCreateConfigBuilder::emulation_group`] for what an emulation
138    /// group is.
139    ///
140    /// Nothing on the wire marks a group as an emulation group, so an emulator
141    /// client resyncing into one has to set this itself. The external commit
142    /// changes membership, so merging it registers the epoch it creates as a
143    /// derivation epoch, converging with the members already in the group.
144    ///
145    /// [`MlsGroupCreateConfigBuilder::emulation_group`]: crate::group::MlsGroupCreateConfigBuilder::emulation_group
146    #[cfg(feature = "virtual-clients-draft")]
147    pub fn emulation_group(mut self, emulation_group: bool) -> Self {
148        self.emulation_group = emulation_group;
149        self
150    }
151
152    /// Skip the validation of lifetimes in leaf nodes in the ratchet tree.
153    /// Note that only the leaf nodes are checked that were never updated.
154    ///
155    /// By default they are validated.
156    pub fn skip_lifetime_validation(mut self) -> Self {
157        self.validate_lifetimes = LeafNodeLifetimePolicy::Skip;
158        self
159    }
160
161    /// Sets how the lifetimes of leaf nodes in the ratchet tree are checked.
162    /// [`Self::skip_lifetime_validation`] is the same as passing
163    /// [`LeafNodeLifetimePolicy::Skip`].
164    pub fn leaf_node_lifetime_policy(mut self, lifetime_policy: LeafNodeLifetimePolicy) -> Self {
165        self.validate_lifetimes = lifetime_policy;
166        self
167    }
168
169    /// Build the [`MlsGroup`] from the provided [`VerifiableGroupInfo`] and
170    /// [`CredentialWithKey`].
171    ///
172    /// This is equivalent to calling [`Self::verify_group`] followed by
173    /// [`VerifiedExternalCommitBuilder::build_group`].
174    ///
175    /// Returns a [`CommitBuilder`] that can be used to further configure the
176    /// external commit.
177    pub fn build_group<Provider: OpenMlsProvider>(
178        self,
179        provider: &Provider,
180        verifiable_group_info: VerifiableGroupInfo,
181        credential_with_key: CredentialWithKey,
182    ) -> Result<
183        CommitBuilder<'_, Initial, MlsGroup>,
184        ExternalCommitBuilderError<Provider::StorageError>,
185    > {
186        self.verify_group(provider, verifiable_group_info)?
187            .build_group(provider, credential_with_key)
188    }
189
190    /// Verify the provided [`VerifiableGroupInfo`] and the ratchet tree of the
191    /// group to be joined, without building an external commit. The ratchet
192    /// tree is taken from the [`VerifiableGroupInfo`] extensions if present,
193    /// and from [`Self::with_ratchet_tree`] otherwise.
194    ///
195    /// Returns a [`VerifiedExternalCommitBuilder`] that exposes the verified
196    /// [`GroupInfo`] and [`GroupContext`] and can build the external commit.
197    pub fn verify_group<Provider: OpenMlsProvider>(
198        self,
199        provider: &Provider,
200        verifiable_group_info: VerifiableGroupInfo,
201    ) -> Result<VerifiedExternalCommitBuilder, ExternalCommitBuilderError<Provider::StorageError>>
202    {
203        let ExternalCommitBuilder {
204            proposals,
205            ratchet_tree,
206            config,
207            aad,
208            validate_lifetimes,
209            #[cfg(feature = "virtual-clients-draft")]
210            emulation_group,
211        } = self;
212
213        let group_ciphersuite = verifiable_group_info.ciphersuite();
214        provider
215            .crypto()
216            .supports(group_ciphersuite)
217            .map_err(|_| ExternalCommitBuilderError::UnsupportedCiphersuite(group_ciphersuite))?;
218
219        // Set nodes either from the extension or from the `ratchet_tree`.
220        let ratchet_tree = match verifiable_group_info.extensions().ratchet_tree() {
221            Some(extension) => extension.ratchet_tree().clone(),
222            None => match ratchet_tree {
223                Some(ratchet_tree) => ratchet_tree,
224                None => return Err(ExternalCommitBuilderError::MissingRatchetTree),
225            },
226        };
227
228        let (public_group, group_info) = PublicGroup::from_ratchet_tree(
229            provider.crypto(),
230            ratchet_tree,
231            verifiable_group_info,
232            ProposalStore::new(),
233            validate_lifetimes,
234        )?;
235
236        Ok(VerifiedExternalCommitBuilder {
237            public_group,
238            group_info,
239            proposals,
240            config,
241            aad,
242            #[cfg(feature = "virtual-clients-draft")]
243            emulation_group,
244        })
245    }
246}
247
248/// The second stage of the [`ExternalCommitBuilder`], returned by
249/// [`ExternalCommitBuilder::verify_group`]. It holds the verified group info
250/// and ratchet tree of the group to be joined, so they can be inspected before
251/// the external commit is built.
252pub struct VerifiedExternalCommitBuilder {
253    public_group: PublicGroup,
254    group_info: GroupInfo,
255    proposals: Vec<PublicMessageIn>,
256    config: MlsGroupJoinConfig,
257    aad: Vec<u8>,
258    #[cfg(feature = "virtual-clients-draft")]
259    emulation_group: bool,
260}
261
262impl VerifiedExternalCommitBuilder {
263    /// Returns the verified [`GroupInfo`] of the group to be joined.
264    pub fn group_info(&self) -> &GroupInfo {
265        &self.group_info
266    }
267
268    /// Returns the [`GroupContext`] of the group to be joined.
269    pub fn group_context(&self) -> &GroupContext {
270        self.public_group.group_context()
271    }
272
273    /// Build the [`MlsGroup`] from the verified group and the provided
274    /// [`CredentialWithKey`].
275    ///
276    /// Returns a [`CommitBuilder`] that can be used to further configure the
277    /// external commit.
278    pub fn build_group<Provider: OpenMlsProvider>(
279        self,
280        provider: &Provider,
281        credential_with_key: CredentialWithKey,
282    ) -> Result<
283        CommitBuilder<'_, Initial, MlsGroup>,
284        ExternalCommitBuilderError<Provider::StorageError>,
285    > {
286        let VerifiedExternalCommitBuilder {
287            public_group,
288            group_info,
289            proposals,
290            mut config,
291            aad,
292            #[cfg(feature = "virtual-clients-draft")]
293            emulation_group,
294        } = self;
295
296        let group_context = public_group.group_context();
297
298        // Obtain external_pub from GroupInfo extensions.
299        let external_pub = group_info
300            .extensions()
301            .external_pub()
302            .ok_or(ExternalCommitBuilderError::MissingExternalPub)?
303            .external_pub();
304
305        let (init_secret, kem_output) = InitSecret::from_group_context(
306            provider.crypto(),
307            group_context,
308            external_pub.as_slice(),
309        )
310        .map_err(|_| {
311            ExternalCommitBuilderError::UnsupportedCiphersuite(group_context.ciphersuite())
312        })?;
313
314        // The `EpochSecrets` we create here are essentially zero, with the
315        // exception of the `InitSecret`, which is all we need here for the
316        // external commit.
317        let ciphersuite = group_context.ciphersuite();
318        let epoch_secrets =
319            EpochSecrets::with_init_secret(provider.crypto(), ciphersuite, init_secret)
320                .map_err(LibraryError::unexpected_crypto_error)?;
321        let (group_epoch_secrets, message_secrets) = epoch_secrets.split_secrets(
322            group_context
323                .tls_serialize_detached()
324                .map_err(LibraryError::missing_bound_check)?,
325            public_group.tree_size(),
326            // We use a fake own index of 0 here, as we're not going to use the
327            // tree for encryption until after the first commit. This issue is
328            // tracked in #767.
329            LeafNodeIndex::new(0u32),
330        );
331        let message_secrets_store = MessageSecretsStore::new_with_secret(
332            config.past_epoch_deletion_policy(),
333            message_secrets,
334        );
335
336        let external_init_proposal =
337            Proposal::external_init(ExternalInitProposal::from(kem_output));
338
339        // Authenticate the proposals as best as we can
340        let serialized_context = group_context
341            .tls_serialize_detached()
342            .map_err(LibraryError::missing_bound_check)?;
343        let mut queued_proposals = Vec::new();
344        for message in proposals {
345            if message.content_type() != ContentType::Proposal {
346                continue; // We only want proposals.
347            }
348            let decrypted_message = DecryptedMessage::from_inbound_public_message(
349                message,
350                None,
351                serialized_context.clone(),
352                provider.crypto(),
353                ciphersuite,
354            )?;
355            let unverified_message = public_group.parse_message(decrypted_message, None)?;
356            let verified = unverified_message.verify(
357                ciphersuite,
358                provider.crypto(),
359                ProtocolVersion::default(),
360                // The policy of the builder applies to the ratchet tree only.
361                LeafNodeLifetimePolicy::Verify,
362            )?;
363            let queued_proposal = QueuedProposal::from_authenticated_content(
364                ciphersuite,
365                provider.crypto(),
366                verified.content,
367                ProposalOrRefType::Reference,
368            )?;
369            // We ignore any proposal that is not a SelfRemove.
370            if queued_proposal.proposal().is_type(ProposalType::SelfRemove) {
371                queued_proposals.push(queued_proposal);
372            }
373        }
374
375        let inline_proposals = [external_init_proposal].into_iter();
376
377        // If there is a group member in the group with the same identity as us,
378        // commit a remove proposal.
379        let our_signature_key = credential_with_key.signature_key.as_slice();
380        let remove_proposal = public_group.members().find_map(|member| {
381            (member.signature_key == our_signature_key).then_some(Proposal::remove(
382                RemoveProposal {
383                    removed: member.index,
384                },
385            ))
386        });
387
388        let inline_proposals = inline_proposals
389            .chain(remove_proposal)
390            .map(|p| {
391                QueuedProposal::from_proposal_and_sender(
392                    ciphersuite,
393                    provider.crypto(),
394                    p,
395                    &Sender::NewMemberCommit,
396                )
397            })
398            .collect::<Result<Vec<_>, _>>()?;
399
400        queued_proposals.extend(inline_proposals);
401
402        let own_leaf_index = public_group.leftmost_free_index(queued_proposals.iter())?;
403
404        let original_wire_format_policy = config.wire_format_policy;
405
406        // We set this to PURE_PLAINTEXT_WIRE_FORMAT_POLICY so that the
407        // external commit can be sent as a PublicMessageIn. The wire format
408        // policy will be set to the original wire format policy after the
409        // external commit has been sent.
410        config.wire_format_policy = PURE_PLAINTEXT_WIRE_FORMAT_POLICY;
411
412        let mut mls_group = MlsGroup {
413            mls_group_config: config,
414            own_leaf_nodes: vec![],
415            aad: vec![],
416            #[cfg(feature = "extensions-draft")]
417            safe_aad: crate::framing::SafeAad::empty(),
418            group_state: MlsGroupState::Operational,
419            public_group,
420            group_epoch_secrets,
421            own_leaf_index,
422            message_secrets_store,
423            resumption_psk_store: ResumptionPskStore::new(32),
424            // This is set to `None` for now. It will be set once the external
425            // commit is merged.
426            #[cfg(feature = "extensions-draft")]
427            application_export_tree: None,
428            #[cfg(feature = "virtual-clients-draft")]
429            emulation_group,
430        };
431
432        // Add all proposals to the proposal store.
433        let proposal_store = mls_group.proposal_store_mut();
434        for queued_proposal in queued_proposals {
435            proposal_store.add(queued_proposal);
436        }
437
438        let mut commit_builder = CommitBuilder::<'_, Initial, MlsGroup>::new(mls_group);
439
440        commit_builder.stage.force_self_update = true;
441        commit_builder.stage.bare_aad = Some(aad);
442        commit_builder.stage.external_commit_info = Some(ExternalCommitInfo {
443            wire_format_policy: original_wire_format_policy,
444            credential: credential_with_key,
445        });
446
447        Ok(commit_builder)
448    }
449}
450
451// Impls that only apply to external commits.
452impl<'a> CommitBuilder<'a, Initial, MlsGroup> {
453    /// Adds a proposal to the proposals to be committed by value. To add
454    /// multiple proposals, use [`Self::add_proposals`].
455    ///
456    /// Only proposal types that are allowed by value in an external commit
457    /// (such as PreSharedKey, Remove, or AppEphemeral) pass validation when
458    /// the commit is built. Other types cause `build` to fail with
459    /// [`ExternalCommitValidationError::InvalidInlineProposals`].
460    ///
461    /// [`ExternalCommitValidationError::InvalidInlineProposals`]:
462    ///     crate::group::errors::ExternalCommitValidationError::InvalidInlineProposals
463    pub fn add_proposal(mut self, proposal: Proposal) -> Self {
464        self.stage.own_proposals.push(proposal);
465        self
466    }
467
468    /// Adds the proposals in the iterator to the proposals to be committed by
469    /// value. See [`Self::add_proposal`] for the proposal types allowed in an
470    /// external commit.
471    pub fn add_proposals(mut self, proposals: impl IntoIterator<Item = Proposal>) -> Self {
472        self.stage.own_proposals.extend(proposals);
473        self
474    }
475
476    /// Adds a [`PreSharedKeyProposal`] to the proposals to be committed.
477    pub fn add_psk_proposal(mut self, proposal: PreSharedKeyProposal) -> Self {
478        self.stage.own_proposals.push(Proposal::psk(proposal));
479        self
480    }
481
482    /// Adds the [`PreSharedKeyProposal`] in the iterator to the proposals to be
483    /// committed.
484    pub fn add_psk_proposals(
485        mut self,
486        proposals: impl IntoIterator<Item = PreSharedKeyProposal>,
487    ) -> Self {
488        self.stage
489            .own_proposals
490            .extend(proposals.into_iter().map(Proposal::psk));
491        self
492    }
493
494    /// Adds an AppDataUpdateProposal.
495    #[cfg(feature = "extensions-draft")]
496    pub fn add_app_data_update_proposal(
497        mut self,
498        proposal: crate::messages::proposals::AppDataUpdateProposal,
499    ) -> Self {
500        self.stage
501            .own_proposals
502            .push(Proposal::AppDataUpdate(Box::new(proposal)));
503        self
504    }
505}
506
507// Impls that apply only to external commits.
508impl CommitBuilder<'_, super::Complete, MlsGroup> {
509    /// Finalizes and returns the [`MlsGroup`], as well as the
510    /// [`CommitMessageBundle`].
511    ///
512    /// In contrast to the deprecated [`MlsGroup::join_by_external_commit`]
513    /// there is no need to merge the pending commit.
514    #[openmls_traits::maybe_async]
515    pub async fn finalize<Provider: OpenMlsProvider>(
516        self,
517        provider: &Provider,
518    ) -> Result<
519        (MlsGroup, super::CommitMessageBundle),
520        ExternalCommitBuilderFinalizeError<Provider::StorageError>,
521    > {
522        let Self {
523            mut group,
524            stage:
525                super::Complete {
526                    result: create_commit_result,
527                    original_wire_format_policy,
528                },
529            ..
530        } = self;
531
532        // Convert AuthenticatedContent messages to MLSMessage. An external
533        // commit is always framed as a PublicMessage, so it carries no
534        // handshake confirmation data.
535        let mls_message = group
536            .content_to_mls_message(create_commit_result.commit, provider)
537            .await?
538            .message;
539
540        group.reset_aad();
541
542        // Restore the original wire format policy.
543        if let Some(wire_format_policy) = original_wire_format_policy {
544            group.mls_group_config.wire_format_policy = wire_format_policy;
545        }
546
547        // Store the group in storage.
548        group
549            .store(provider.storage())
550            .await
551            .map_err(ExternalCommitBuilderFinalizeError::StorageError)?;
552
553        // Set the current group state to [`MlsGroupState::PendingCommit`],
554        // storing the current [`StagedCommit`] from the commit results
555        group.group_state = MlsGroupState::PendingCommit(Box::new(PendingCommitState::Member(
556            create_commit_result.staged_commit,
557        )));
558
559        group.merge_pending_commit(provider).await?;
560
561        let bundle = super::CommitMessageBundle {
562            version: group.version(),
563            commit: mls_message,
564            welcome: create_commit_result.welcome_option,
565            group_info: create_commit_result.group_info,
566            #[cfg(feature = "virtual-clients-draft")]
567            confirmation: None,
568        };
569
570        Ok((group, bundle))
571    }
572}