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