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, LeafNodeLifetimePolicy, MlsGroup, MlsGroupJoinConfig,
20        MlsGroupState, PendingCommitState, ProposalStore, PublicGroup, QueuedProposal,
21        ValidationError, PURE_PLAINTEXT_WIRE_FORMAT_POLICY,
22    },
23    messages::{
24        group_info::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/// The second stage of this builder is a [`CommitBuilder`] that can be used to
69/// add one or more proposals by value to the external commit and specify
70/// [`LeafNodeParameters`]. Note that only proposal types that RFC 9420 and the
71/// MLS extensions draft allow in an external commit pass validation when the
72/// commit is built.
73#[derive(Default)]
74pub struct ExternalCommitBuilder {
75    proposals: Vec<PublicMessageIn>,
76    ratchet_tree: Option<RatchetTreeIn>,
77    config: MlsGroupJoinConfig,
78    validate_lifetimes: LeafNodeLifetimePolicy,
79    aad: Vec<u8>,
80    /// Whether to join the group as an emulation group of a virtual client. Set
81    /// by [`Self::emulation_group`].
82    #[cfg(feature = "virtual-clients-draft")]
83    emulation_group: bool,
84}
85
86impl MlsGroup {
87    /// Creates a new [`ExternalCommitBuilder`] to build an external commit.
88    pub fn external_commit_builder() -> ExternalCommitBuilder {
89        ExternalCommitBuilder::new()
90    }
91}
92
93impl ExternalCommitBuilder {
94    /// Creates a new [`ExternalCommitBuilder`] with default values.
95    pub fn new() -> Self {
96        Self::default()
97    }
98
99    /// Adds SelfRemove proposals to the external commit. Other proposals or
100    /// other types of messages are ignored.
101    pub fn with_proposals(mut self, proposals: Vec<PublicMessageIn>) -> Self {
102        self.proposals = proposals;
103        self
104    }
105
106    /// Specifies the ratchet tree to use for the external commit. This is only
107    /// used if the ratchet tree is not provided in the [`VerifiableGroupInfo`]
108    /// extensions. A ratchet tree must be provided, either in the
109    /// [`VerifiableGroupInfo`] extensions or via this method.
110    pub fn with_ratchet_tree(mut self, ratchet_tree: RatchetTreeIn) -> Self {
111        self.ratchet_tree = Some(ratchet_tree);
112        self
113    }
114
115    /// Specifies the configuration to use for the group built as part of the
116    /// external commit. Note that the external commit will always be a
117    /// `PublicMessage` regardless of the wire format policy set in the group
118    /// config.
119    pub fn with_config(mut self, config: MlsGroupJoinConfig) -> Self {
120        self.config = config;
121        self
122    }
123
124    /// Specifies additional authenticated data (AAD) to be included in the
125    /// external commit.
126    pub fn with_aad(mut self, aad: Vec<u8>) -> Self {
127        self.aad = aad;
128        self
129    }
130
131    /// Join the group as an emulation group of a virtual client. See
132    /// [`MlsGroupCreateConfigBuilder::emulation_group`] for what an emulation
133    /// group is.
134    ///
135    /// Nothing on the wire marks a group as an emulation group, so an emulator
136    /// client resyncing into one has to set this itself. The external commit
137    /// changes membership, so merging it registers the epoch it creates as a
138    /// derivation epoch, converging with the members already in the group.
139    ///
140    /// [`MlsGroupCreateConfigBuilder::emulation_group`]: crate::group::MlsGroupCreateConfigBuilder::emulation_group
141    #[cfg(feature = "virtual-clients-draft")]
142    pub fn emulation_group(mut self, emulation_group: bool) -> Self {
143        self.emulation_group = emulation_group;
144        self
145    }
146
147    /// Skip the validation of lifetimes in leaf nodes in the ratchet tree.
148    /// Note that only the leaf nodes are checked that were never updated.
149    ///
150    /// By default they are validated.
151    pub fn skip_lifetime_validation(mut self) -> Self {
152        self.validate_lifetimes = LeafNodeLifetimePolicy::Skip;
153        self
154    }
155
156    /// Build the [`MlsGroup`] from the provided [`VerifiableGroupInfo`] and
157    /// [`CredentialWithKey`].
158    ///
159    /// Returns a [`CommitBuilder`] that can be used to further configure the
160    /// external commit.
161    pub fn build_group<Provider: OpenMlsProvider>(
162        self,
163        provider: &Provider,
164        verifiable_group_info: VerifiableGroupInfo,
165        credential_with_key: CredentialWithKey,
166    ) -> Result<
167        CommitBuilder<'_, Initial, MlsGroup>,
168        ExternalCommitBuilderError<Provider::StorageError>,
169    > {
170        let ExternalCommitBuilder {
171            proposals,
172            ratchet_tree,
173            mut config,
174            aad,
175            validate_lifetimes,
176            #[cfg(feature = "virtual-clients-draft")]
177            emulation_group,
178        } = self;
179
180        let group_ciphersuite = verifiable_group_info.ciphersuite();
181        provider
182            .crypto()
183            .supports(group_ciphersuite)
184            .map_err(|_| ExternalCommitBuilderError::UnsupportedCiphersuite(group_ciphersuite))?;
185
186        // Build the ratchet tree
187
188        // Set nodes either from the extension or from the `ratchet_tree`.
189        let ratchet_tree = match verifiable_group_info.extensions().ratchet_tree() {
190            Some(extension) => extension.ratchet_tree().clone(),
191            None => match ratchet_tree {
192                Some(ratchet_tree) => ratchet_tree,
193                None => return Err(ExternalCommitBuilderError::MissingRatchetTree),
194            },
195        };
196
197        let (public_group, group_info) = PublicGroup::from_ratchet_tree(
198            provider.crypto(),
199            ratchet_tree,
200            verifiable_group_info,
201            ProposalStore::new(),
202            validate_lifetimes,
203        )?;
204        let group_context = public_group.group_context();
205
206        // Obtain external_pub from GroupInfo extensions.
207        let external_pub = group_info
208            .extensions()
209            .external_pub()
210            .ok_or(ExternalCommitBuilderError::MissingExternalPub)?
211            .external_pub();
212
213        let (init_secret, kem_output) = InitSecret::from_group_context(
214            provider.crypto(),
215            group_context,
216            external_pub.as_slice(),
217        )
218        .map_err(|_| {
219            ExternalCommitBuilderError::UnsupportedCiphersuite(group_context.ciphersuite())
220        })?;
221
222        // The `EpochSecrets` we create here are essentially zero, with the
223        // exception of the `InitSecret`, which is all we need here for the
224        // external commit.
225        let ciphersuite = group_context.ciphersuite();
226        let epoch_secrets =
227            EpochSecrets::with_init_secret(provider.crypto(), ciphersuite, init_secret)
228                .map_err(LibraryError::unexpected_crypto_error)?;
229        let (group_epoch_secrets, message_secrets) = epoch_secrets.split_secrets(
230            group_context
231                .tls_serialize_detached()
232                .map_err(LibraryError::missing_bound_check)?,
233            public_group.tree_size(),
234            // We use a fake own index of 0 here, as we're not going to use the
235            // tree for encryption until after the first commit. This issue is
236            // tracked in #767.
237            LeafNodeIndex::new(0u32),
238        );
239        let message_secrets_store = MessageSecretsStore::new_with_secret(
240            config.past_epoch_deletion_policy(),
241            message_secrets,
242        );
243
244        let external_init_proposal =
245            Proposal::external_init(ExternalInitProposal::from(kem_output));
246
247        // Authenticate the proposals as best as we can
248        let serialized_context = group_context
249            .tls_serialize_detached()
250            .map_err(LibraryError::missing_bound_check)?;
251        let mut queued_proposals = Vec::new();
252        for message in proposals {
253            if message.content_type() != ContentType::Proposal {
254                continue; // We only want proposals.
255            }
256            let decrypted_message = DecryptedMessage::from_inbound_public_message(
257                message,
258                None,
259                serialized_context.clone(),
260                provider.crypto(),
261                ciphersuite,
262            )?;
263            let unverified_message = public_group.parse_message(decrypted_message, None)?;
264            let verified = unverified_message.verify(
265                ciphersuite,
266                provider.crypto(),
267                ProtocolVersion::default(),
268            )?;
269            let queued_proposal = QueuedProposal::from_authenticated_content(
270                ciphersuite,
271                provider.crypto(),
272                verified.content,
273                ProposalOrRefType::Reference,
274            )?;
275            // We ignore any proposal that is not a SelfRemove.
276            if queued_proposal.proposal().is_type(ProposalType::SelfRemove) {
277                queued_proposals.push(queued_proposal);
278            }
279        }
280
281        let inline_proposals = [external_init_proposal].into_iter();
282
283        // If there is a group member in the group with the same identity as us,
284        // commit a remove proposal.
285        let our_signature_key = credential_with_key.signature_key.as_slice();
286        let remove_proposal = public_group.members().find_map(|member| {
287            (member.signature_key == our_signature_key).then_some(Proposal::remove(
288                RemoveProposal {
289                    removed: member.index,
290                },
291            ))
292        });
293
294        let inline_proposals = inline_proposals
295            .chain(remove_proposal)
296            .map(|p| {
297                QueuedProposal::from_proposal_and_sender(
298                    ciphersuite,
299                    provider.crypto(),
300                    p,
301                    &Sender::NewMemberCommit,
302                )
303            })
304            .collect::<Result<Vec<_>, _>>()?;
305
306        queued_proposals.extend(inline_proposals);
307
308        let own_leaf_index = public_group.leftmost_free_index(queued_proposals.iter())?;
309
310        let original_wire_format_policy = config.wire_format_policy;
311
312        // We set this to PURE_PLAINTEXT_WIRE_FORMAT_POLICY so that the
313        // external commit can be sent as a PublicMessageIn. The wire format
314        // policy will be set to the original wire format policy after the
315        // external commit has been sent.
316        config.wire_format_policy = PURE_PLAINTEXT_WIRE_FORMAT_POLICY;
317
318        let mut mls_group = MlsGroup {
319            mls_group_config: config,
320            own_leaf_nodes: vec![],
321            aad: vec![],
322            #[cfg(feature = "extensions-draft")]
323            safe_aad: crate::framing::SafeAad::empty(),
324            group_state: MlsGroupState::Operational,
325            public_group,
326            group_epoch_secrets,
327            own_leaf_index,
328            message_secrets_store,
329            resumption_psk_store: ResumptionPskStore::new(32),
330            // This is set to `None` for now. It will be set once the external
331            // commit is merged.
332            #[cfg(feature = "extensions-draft")]
333            application_export_tree: None,
334            #[cfg(feature = "virtual-clients-draft")]
335            emulation_group,
336        };
337
338        // Add all proposals to the proposal store.
339        let proposal_store = mls_group.proposal_store_mut();
340        for queued_proposal in queued_proposals {
341            proposal_store.add(queued_proposal);
342        }
343
344        let mut commit_builder = CommitBuilder::<'_, Initial, MlsGroup>::new(mls_group);
345
346        commit_builder.stage.force_self_update = true;
347        commit_builder.stage.external_commit_info = Some(ExternalCommitInfo {
348            wire_format_policy: original_wire_format_policy,
349            credential: credential_with_key,
350            aad,
351        });
352
353        Ok(commit_builder)
354    }
355}
356
357// Impls that only apply to external commits.
358impl<'a> CommitBuilder<'a, Initial, MlsGroup> {
359    /// Adds a proposal to the proposals to be committed by value. To add
360    /// multiple proposals, use [`Self::add_proposals`].
361    ///
362    /// Only proposal types that are allowed by value in an external commit
363    /// (such as PreSharedKey, Remove, or AppEphemeral) pass validation when
364    /// the commit is built. Other types cause `build` to fail with
365    /// [`ExternalCommitValidationError::InvalidInlineProposals`].
366    ///
367    /// [`ExternalCommitValidationError::InvalidInlineProposals`]:
368    ///     crate::group::errors::ExternalCommitValidationError::InvalidInlineProposals
369    pub fn add_proposal(mut self, proposal: Proposal) -> Self {
370        self.stage.own_proposals.push(proposal);
371        self
372    }
373
374    /// Adds the proposals in the iterator to the proposals to be committed by
375    /// value. See [`Self::add_proposal`] for the proposal types allowed in an
376    /// external commit.
377    pub fn add_proposals(mut self, proposals: impl IntoIterator<Item = Proposal>) -> Self {
378        self.stage.own_proposals.extend(proposals);
379        self
380    }
381
382    /// Adds a [`PreSharedKeyProposal`] to the proposals to be committed.
383    pub fn add_psk_proposal(mut self, proposal: PreSharedKeyProposal) -> Self {
384        self.stage.own_proposals.push(Proposal::psk(proposal));
385        self
386    }
387
388    /// Adds the [`PreSharedKeyProposal`] in the iterator to the proposals to be
389    /// committed.
390    pub fn add_psk_proposals(
391        mut self,
392        proposals: impl IntoIterator<Item = PreSharedKeyProposal>,
393    ) -> Self {
394        self.stage
395            .own_proposals
396            .extend(proposals.into_iter().map(Proposal::psk));
397        self
398    }
399
400    /// Adds an AppDataUpdateProposal.
401    #[cfg(feature = "extensions-draft")]
402    pub fn add_app_data_update_proposal(
403        mut self,
404        proposal: crate::messages::proposals::AppDataUpdateProposal,
405    ) -> Self {
406        self.stage
407            .own_proposals
408            .push(Proposal::AppDataUpdate(Box::new(proposal)));
409        self
410    }
411}
412
413// Impls that apply only to external commits.
414impl CommitBuilder<'_, super::Complete, MlsGroup> {
415    /// Finalizes and returns the [`MlsGroup`], as well as the
416    /// [`CommitMessageBundle`].
417    ///
418    /// In contrast to the deprecated [`MlsGroup::join_by_external_commit`]
419    /// there is no need to merge the pending commit.
420    pub fn finalize<Provider: OpenMlsProvider>(
421        self,
422        provider: &Provider,
423    ) -> Result<
424        (MlsGroup, super::CommitMessageBundle),
425        ExternalCommitBuilderFinalizeError<Provider::StorageError>,
426    > {
427        let Self {
428            mut group,
429            stage:
430                super::Complete {
431                    result: create_commit_result,
432                    original_wire_format_policy,
433                },
434            ..
435        } = self;
436
437        // Convert AuthenticatedContent messages to MLSMessage. An external
438        // commit is always framed as a PublicMessage, so it carries no
439        // handshake confirmation data.
440        let mls_message = group
441            .content_to_mls_message(create_commit_result.commit, provider)?
442            .message;
443
444        group.reset_aad();
445
446        // Restore the original wire format policy.
447        if let Some(wire_format_policy) = original_wire_format_policy {
448            group.mls_group_config.wire_format_policy = wire_format_policy;
449        }
450
451        // Store the group in storage.
452        group
453            .store(provider.storage())
454            .map_err(ExternalCommitBuilderFinalizeError::StorageError)?;
455
456        // Set the current group state to [`MlsGroupState::PendingCommit`],
457        // storing the current [`StagedCommit`] from the commit results
458        group.group_state = MlsGroupState::PendingCommit(Box::new(PendingCommitState::Member(
459            create_commit_result.staged_commit,
460        )));
461
462        group.merge_pending_commit(provider)?;
463
464        let bundle = super::CommitMessageBundle {
465            version: group.version(),
466            commit: mls_message,
467            welcome: create_commit_result.welcome_option,
468            group_info: create_commit_result.group_info,
469            #[cfg(feature = "virtual-clients-draft")]
470            confirmation: None,
471        };
472
473        Ok((group, bundle))
474    }
475}