Skip to main content

openmls/group/public_group/
mod.rs

1//! # Public Groups
2//!
3//! There are a few use-cases that require the tracking of an MLS group based on
4//! [`PublicMessage`]s, e.g. for group membership tracking by a delivery
5//! service.
6//!
7//! This module and its submodules contain the [`PublicGroup`] struct, as well
8//! as associated helper structs the goal of which is to enable this
9//! functionality.
10//!
11//! To avoid duplication of code and functionality, [`MlsGroup`] internally
12//! relies on a [`PublicGroup`] as well.
13
14use std::collections::HashSet;
15
16use openmls_traits::{crypto::OpenMlsCrypto, types::Ciphersuite};
17use serde::{Deserialize, Serialize};
18
19use self::{
20    diff::{PublicGroupDiff, StagedPublicGroupDiff},
21    errors::CreationFromExternalError,
22};
23use super::{
24    proposal_store::{ProposalStore, QueuedProposal},
25    GroupContext, GroupId, Member, StagedCommit,
26};
27#[cfg(test)]
28use crate::treesync::{node::parent_node::PlainUpdatePathNode, treekem::UpdatePathNode};
29use crate::{
30    binary_tree::{
31        array_representation::{direct_path, TreeSize},
32        LeafNodeIndex,
33    },
34    ciphersuite::{hash_ref::ProposalRef, signable::Verifiable},
35    error::LibraryError,
36    extensions::RequiredCapabilitiesExtension,
37    framing::{InterimTranscriptHashInput, Sender},
38    group::mls_group::creation::LeafNodeLifetimePolicy,
39    messages::{
40        group_info::{GroupInfo, VerifiableGroupInfo},
41        proposals::Proposal,
42        ConfirmationTag, PathSecret,
43    },
44    schedule::CommitSecret,
45    storage::PublicStorageProvider,
46    treesync::{
47        errors::{DerivePathError, TreeSyncFromNodesError},
48        node::{
49            encryption_keys::{EncryptionKey, EncryptionKeyPair},
50            leaf_node::LeafNode,
51        },
52        RatchetTree, RatchetTreeIn, TreeSync,
53    },
54    versions::ProtocolVersion,
55};
56#[cfg(doc)]
57use crate::{framing::PublicMessage, group::MlsGroup};
58
59pub(crate) mod builder;
60pub(crate) mod diff;
61pub mod errors;
62pub mod process;
63pub(crate) mod staged_commit;
64#[cfg(test)]
65mod tests;
66mod validation;
67
68#[cfg(feature = "migration-import")]
69mod migration_import;
70
71/// This struct holds all public values of an MLS group.
72#[derive(Debug)]
73#[cfg_attr(feature = "migration-import", derive(serde::Deserialize))]
74#[cfg_attr(
75    all(feature = "test-utils", feature = "migration-import"),
76    derive(serde::Serialize)
77)]
78#[cfg_attr(any(test, feature = "test-utils"), derive(PartialEq, Clone))]
79pub struct PublicGroup {
80    treesync: TreeSync,
81    proposal_store: ProposalStore,
82    group_context: GroupContext,
83    interim_transcript_hash: Vec<u8>,
84    // Most recent confirmation tag. Kept here for verification purposes.
85    confirmation_tag: ConfirmationTag,
86}
87
88/// This is a wrapper type, because we can't implement the storage traits on `Vec<u8>`.
89#[derive(Debug, Serialize, Deserialize)]
90pub struct InterimTranscriptHash(pub Vec<u8>);
91
92impl PublicGroup {
93    /// Create a new PublicGroup from a [`TreeSync`] instance and a
94    /// [`GroupInfo`].
95    pub(crate) fn new(
96        crypto: &impl OpenMlsCrypto,
97        treesync: TreeSync,
98        group_context: GroupContext,
99        initial_confirmation_tag: ConfirmationTag,
100    ) -> Result<Self, LibraryError> {
101        let interim_transcript_hash = {
102            let input = InterimTranscriptHashInput::from(&initial_confirmation_tag);
103
104            input.calculate_interim_transcript_hash(
105                crypto,
106                group_context.ciphersuite(),
107                group_context.confirmed_transcript_hash(),
108            )?
109        };
110
111        Ok(PublicGroup {
112            treesync,
113            proposal_store: ProposalStore::new(),
114            group_context,
115            interim_transcript_hash,
116            confirmation_tag: initial_confirmation_tag,
117        })
118    }
119
120    /// Create a [`PublicGroup`] instance to start tracking an existing MLS group.
121    ///
122    /// This function performs basic validation checks and returns an error if
123    /// one of the checks fails. See [`CreationFromExternalError`] for more
124    /// details.
125    #[openmls_traits::maybe_async]
126    pub async fn from_external<StorageProvider, StorageError>(
127        crypto: &impl OpenMlsCrypto,
128        storage: &StorageProvider,
129        ratchet_tree: RatchetTreeIn,
130        verifiable_group_info: VerifiableGroupInfo,
131        proposal_store: ProposalStore,
132    ) -> Result<(Self, GroupInfo), CreationFromExternalError<StorageError>>
133    where
134        StorageProvider: PublicStorageProvider<Error = StorageError>,
135    {
136        Self::from_external_with_lifetime_policy(
137            crypto,
138            storage,
139            ratchet_tree,
140            verifiable_group_info,
141            proposal_store,
142            LeafNodeLifetimePolicy::Verify,
143        )
144        .await
145    }
146
147    /// Like [`Self::from_external`], but checks the lifetimes of the leaf nodes
148    /// in the ratchet tree under `lifetime_policy`.
149    #[openmls_traits::maybe_async]
150    pub async fn from_external_with_lifetime_policy<StorageProvider, StorageError>(
151        crypto: &impl OpenMlsCrypto,
152        storage: &StorageProvider,
153        ratchet_tree: RatchetTreeIn,
154        verifiable_group_info: VerifiableGroupInfo,
155        proposal_store: ProposalStore,
156        lifetime_policy: LeafNodeLifetimePolicy,
157    ) -> Result<(Self, GroupInfo), CreationFromExternalError<StorageError>>
158    where
159        StorageProvider: PublicStorageProvider<Error = StorageError>,
160    {
161        let (public_group, group_info) = PublicGroup::from_ratchet_tree(
162            crypto,
163            ratchet_tree,
164            verifiable_group_info,
165            proposal_store,
166            lifetime_policy,
167        )?;
168
169        public_group
170            .store(storage)
171            .await
172            .map_err(CreationFromExternalError::WriteToStorageError)?;
173
174        Ok((public_group, group_info))
175    }
176
177    pub(crate) fn from_ratchet_tree<StorageError>(
178        crypto: &impl OpenMlsCrypto,
179        ratchet_tree: RatchetTreeIn,
180        verifiable_group_info: VerifiableGroupInfo,
181        proposal_store: ProposalStore,
182        validate_lifetimes: LeafNodeLifetimePolicy,
183    ) -> Result<(Self, GroupInfo), CreationFromExternalError<StorageError>> {
184        let ciphersuite = verifiable_group_info.ciphersuite();
185        crypto
186            .supports(ciphersuite)
187            .map_err(|_| CreationFromExternalError::UnsupportedCiphersuite(ciphersuite))?;
188
189        let group_id = verifiable_group_info.group_id();
190        let ratchet_tree = ratchet_tree
191            .into_verified(ciphersuite, crypto, group_id)
192            .map_err(|e| {
193                CreationFromExternalError::TreeSyncError(TreeSyncFromNodesError::RatchetTreeError(
194                    e,
195                ))
196            })?;
197
198        // Create a RatchetTree from the given nodes. We have to do this before
199        // verifying the group info, since we need to find the Credential to verify the
200        // signature against.
201        let treesync = TreeSync::from_ratchet_tree(crypto, ciphersuite, ratchet_tree)?;
202
203        let mut encryption_keys = HashSet::new();
204        let mut signature_keys = HashSet::new();
205
206        // Perform basic checks that the leaf nodes in the ratchet tree are valid
207        // These checks only do those that don't need group context. We do the full
208        // checks later, but do these here to fail early in case of funny business
209        // https://validation.openmls.tech/#valn1407
210        treesync.full_leaves().try_for_each(|(_, leaf_node)| {
211            leaf_node.validate_locally()?;
212
213            // Check that no two nodes share a signature key.
214            // https://validation.openmls.tech/#valn0111
215            if !signature_keys.insert(leaf_node.signature_key()) {
216                return Err(CreationFromExternalError::DuplicateSignatureKey);
217            }
218
219            // Check that no two nodes share an encryption key.
220            // https://validation.openmls.tech/#valn0112
221            if !encryption_keys.insert(leaf_node.encryption_key()) {
222                return Err(CreationFromExternalError::DuplicateEncryptionKey);
223            }
224
225            Ok(())
226        })?;
227
228        // For each non-empty parent node and each entry in the node's unmerged_leaves field:
229        treesync
230            .full_parents()
231            .try_for_each(|(parent_index, parent_node)| {
232                // Check that no two nodes share an encryption key.
233                // This is a bit stronger than what the spec requires: It requires that the encryption keys
234                // in parent nodes and unmerged leaves must be unique. Here, we check that all encryption
235                // keys (all leaf nodes, incl. unmerged and all parent nodes) are unique.
236                //
237                // https://validation.openmls.tech/#valn1410
238                if !encryption_keys.insert(parent_node.encryption_key()) {
239                    return Err(CreationFromExternalError::DuplicateEncryptionKey);
240                }
241
242                parent_node
243                    .unmerged_leaves()
244                    .iter()
245                    .try_for_each(|leaf_index| {
246                        let path = direct_path(*leaf_index, treesync.tree_size());
247
248                        // https://validation.openmls.tech/#valn1408
249                        // Verify that the entry represents a non-blank leaf node that is a descendant of the
250                        // parent node.
251                        let this_parent_offset = path
252                            .iter()
253                            .position(|x| x == &parent_index)
254                            .ok_or(
255                            CreationFromExternalError::<StorageError>::UnmergedLeafNotADescendant,
256                        )?;
257                        let path_leaf_to_this = &path[..this_parent_offset];
258
259
260                        // https://validation.openmls.tech/#valn1409
261                        // Verify that every non-blank intermediate node between the leaf node and the parent
262                        // node also has an entry for the leaf node in its unmerged_leaves.
263                        path_leaf_to_this
264                            .iter()
265                            .try_for_each(|intermediate_index| {
266                                // None would be blank, and we don't care about those
267                                if let Some(intermediate_node) = treesync
268                                    .parent(*intermediate_index) {
269                                    // The list of unmerged leaves is sorted,
270                                    // which UnmergedLeaves enforces when it is
271                                    // deserialized from the wire.
272                                    if intermediate_node
273                                        .unmerged_leaves()
274                                        .binary_search(leaf_index)
275                                        .is_err()
276                                    {
277                                        return Err(CreationFromExternalError::<StorageError>::IntermediateNodeMissingUnmergedLeaf);
278                                    }
279                                }
280
281                                Ok(())
282                            })
283                    })
284            })?;
285
286        // https://validation.openmls.tech/#valn1402
287        let group_info: GroupInfo = {
288            let signer_signature_key = treesync
289                .leaf(verifiable_group_info.signer())
290                .ok_or(CreationFromExternalError::UnknownSender)?
291                .signature_key()
292                .clone()
293                .into_signature_public_key_enriched(ciphersuite.signature_algorithm());
294
295            verifiable_group_info
296                .verify(crypto, &signer_signature_key)
297                .map_err(|_| CreationFromExternalError::InvalidGroupInfoSignature)?
298        };
299
300        // https://validation.openmls.tech/#valn1405
301        if treesync.tree_hash() != group_info.group_context().tree_hash() {
302            return Err(CreationFromExternalError::TreeHashMismatch);
303        }
304
305        if group_info.group_context().protocol_version() != ProtocolVersion::Mls10 {
306            return Err(CreationFromExternalError::UnsupportedMlsVersion);
307        }
308
309        let group_context = group_info.group_context().clone();
310
311        let interim_transcript_hash = {
312            let input = InterimTranscriptHashInput::from(group_info.confirmation_tag());
313
314            input.calculate_interim_transcript_hash(
315                crypto,
316                group_context.ciphersuite(),
317                group_context.confirmed_transcript_hash(),
318            )?
319        };
320
321        let public_group = Self {
322            treesync,
323            group_context,
324            interim_transcript_hash,
325            confirmation_tag: group_info.confirmation_tag().clone(),
326            proposal_store,
327        };
328
329        // Fully check that the leaf nodes in the ratchet tree are valid
330        // https://validation.openmls.tech/#valn1407
331        public_group
332            .treesync
333            .full_leaves()
334            .try_for_each(|(_, leaf_node)| {
335                public_group.validate_leaf_node(leaf_node, validate_lifetimes)
336            })?;
337
338        Ok((public_group, group_info))
339    }
340
341    /// Returns the index of the sender of a staged, external commit.
342    pub fn ext_commit_sender_index(
343        &self,
344        commit: &StagedCommit,
345    ) -> Result<LeafNodeIndex, LibraryError> {
346        self.leftmost_free_index(commit.queued_proposals())
347    }
348
349    /// Returns the leftmost free leaf index.
350    ///
351    /// For External Commits of the "resync" type, this returns the index
352    /// of the sender.
353    ///
354    /// The proposals must be validated before calling this function.
355    pub(crate) fn leftmost_free_index<'a>(
356        &self,
357        queued_proposals: impl Iterator<Item = &'a QueuedProposal>,
358    ) -> Result<LeafNodeIndex, LibraryError> {
359        // Leftmost free leaf in the tree
360        let free_leaf_index = self.treesync().free_leaf_index();
361        // Indices that are freed due to queued self-remove proposals or remove
362        // proposals.
363        let removed_indices = queued_proposals.filter_map(|proposal| {
364            match (proposal.proposal(), proposal.sender()) {
365                (Proposal::Remove(r), _) => Some(r.removed),
366                (Proposal::SelfRemove, Sender::Member(sender)) => Some(*sender),
367                _ => None, // SelfRemove proposals must come from group members
368            }
369        });
370        // Find the leftmost free leaf index, which is either the free leaf index
371        // or the leftmost index of a self-remove proposal or remove proposal.
372        removed_indices
373            .into_iter()
374            .chain(std::iter::once(free_leaf_index))
375            .min()
376            .ok_or_else(|| LibraryError::custom("No free leaf index found"))
377    }
378
379    /// Create an empty  [`PublicGroupDiff`] based on this [`PublicGroup`].
380    pub(crate) fn empty_diff(&self) -> PublicGroupDiff<'_> {
381        PublicGroupDiff::new(self)
382    }
383
384    /// Merge the changes performed on the [`PublicGroupDiff`] into this
385    /// [`PublicGroup`].
386    ///
387    /// **NOTE:** The caller must ensure that the group context in the `diff` is
388    ///           updated before calling this function with `update_group_context`.
389    pub(crate) fn merge_diff(&mut self, diff: StagedPublicGroupDiff) {
390        self.treesync.merge_diff(diff.staged_diff);
391        self.group_context = diff.group_context;
392        self.interim_transcript_hash = diff.interim_transcript_hash;
393        self.confirmation_tag = diff.confirmation_tag;
394    }
395
396    /// Derives [`EncryptionKeyPair`]s for the nodes in the shared direct path
397    /// of the leaves with index `leaf_index` and `sender_index`.  This function
398    /// also checks that the derived public keys match the existing public keys.
399    ///
400    /// Returns the [`CommitSecret`] derived from the path secret of the root
401    /// node, as well as the derived [`EncryptionKeyPair`]s. Returns an error if
402    /// the target leaf is outside of the tree.
403    ///
404    /// Returns [`DerivePathError::PublicKeyMismatch`] if the derived keys don't
405    /// match with the existing ones.
406    ///
407    /// Returns [`DerivePathError::LibraryError`] if the sender_index is not
408    /// in the tree.
409    pub(crate) fn derive_path_secrets(
410        &self,
411        crypto: &impl OpenMlsCrypto,
412        ciphersuite: Ciphersuite,
413        path_secret: PathSecret,
414        sender_index: LeafNodeIndex,
415        leaf_index: LeafNodeIndex,
416    ) -> Result<(Vec<EncryptionKeyPair>, CommitSecret), DerivePathError> {
417        self.treesync.derive_path_secrets(
418            crypto,
419            ciphersuite,
420            path_secret,
421            sender_index,
422            leaf_index,
423        )
424    }
425
426    /// Get an iterator over all [`Member`]s of this [`PublicGroup`].
427    pub fn members(&self) -> impl Iterator<Item = Member> + '_ {
428        self.treesync().full_leaf_members()
429    }
430
431    /// Export the nodes of the public tree.
432    pub fn export_ratchet_tree(&self) -> RatchetTree {
433        self.treesync().export_ratchet_tree()
434    }
435
436    /// Add the [`QueuedProposal`] to the [`PublicGroup`]s internal [`ProposalStore`].
437    #[openmls_traits::maybe_async]
438    pub async fn add_proposal<Storage: PublicStorageProvider>(
439        &mut self,
440        storage: &Storage,
441        proposal: QueuedProposal,
442    ) -> Result<(), Storage::Error> {
443        storage
444            .queue_proposal(self.group_id(), &proposal.proposal_reference(), &proposal)
445            .await?;
446        self.proposal_store.add(proposal);
447        Ok(())
448    }
449
450    /// Remove the Proposal with the given [`ProposalRef`] from the [`PublicGroup`]s internal [`ProposalStore`].
451    #[openmls_traits::maybe_async]
452    pub async fn remove_proposal<Storage: PublicStorageProvider>(
453        &mut self,
454        storage: &Storage,
455        proposal_ref: &ProposalRef,
456    ) -> Result<(), Storage::Error> {
457        storage
458            .remove_proposal(self.group_id(), proposal_ref)
459            .await?;
460        self.proposal_store.remove(proposal_ref);
461        Ok(())
462    }
463
464    /// Return all queued proposals
465    #[openmls_traits::maybe_async]
466    pub async fn queued_proposals<Storage: PublicStorageProvider>(
467        &self,
468        storage: &Storage,
469    ) -> Result<Vec<(ProposalRef, QueuedProposal)>, Storage::Error> {
470        storage.queued_proposals(self.group_id()).await
471    }
472}
473
474// Getters
475impl PublicGroup {
476    /// Get the ciphersuite.
477    pub fn ciphersuite(&self) -> Ciphersuite {
478        self.group_context.ciphersuite()
479    }
480
481    /// Get the version.
482    pub fn version(&self) -> ProtocolVersion {
483        self.group_context.protocol_version()
484    }
485
486    /// Get the group id.
487    pub fn group_id(&self) -> &GroupId {
488        self.group_context.group_id()
489    }
490
491    /// Get the group context.
492    pub fn group_context(&self) -> &GroupContext {
493        &self.group_context
494    }
495
496    /// Get the required capabilities.
497    pub fn required_capabilities(&self) -> Option<&RequiredCapabilitiesExtension> {
498        self.group_context.required_capabilities()
499    }
500
501    /// Returns the current tree state of the group, in the form of a [`TreeSync`].
502    pub fn treesync(&self) -> &TreeSync {
503        &self.treesync
504    }
505
506    /// Get confirmation tag.
507    pub fn confirmation_tag(&self) -> &ConfirmationTag {
508        &self.confirmation_tag
509    }
510
511    /// Return a reference to the leaf at the given `LeafNodeIndex` or `None` if the
512    /// leaf is blank.
513    pub fn leaf(&self, leaf_index: LeafNodeIndex) -> Option<&LeafNode> {
514        self.treesync().leaf(leaf_index)
515    }
516
517    /// Returns the tree size
518    pub(crate) fn tree_size(&self) -> TreeSize {
519        self.treesync().tree_size()
520    }
521
522    fn interim_transcript_hash(&self) -> &[u8] {
523        &self.interim_transcript_hash
524    }
525
526    /// Return a vector containing all [`EncryptionKey`]s for which the owner of
527    /// the given `leaf_index` should have private key material.
528    pub(crate) fn owned_encryption_keys(&self, leaf_index: LeafNodeIndex) -> Vec<EncryptionKey> {
529        self.treesync().owned_encryption_keys(leaf_index)
530    }
531
532    /// Stores the [`PublicGroup`] to storage. Called from methods creating a new group and mutating an
533    /// existing group, both inside [`PublicGroup`] and in [`MlsGroup`].
534    ///
535    /// [`MlsGroup`]: crate::group::MlsGroup
536    #[openmls_traits::maybe_async]
537    pub(crate) async fn store<Storage: PublicStorageProvider>(
538        &self,
539        storage: &Storage,
540    ) -> Result<(), Storage::Error> {
541        let group_id = self.group_context.group_id();
542        storage.write_tree(group_id, self.treesync()).await?;
543        storage
544            .write_confirmation_tag(group_id, self.confirmation_tag())
545            .await?;
546        storage
547            .write_context(group_id, self.group_context())
548            .await?;
549        storage
550            .write_interim_transcript_hash(
551                group_id,
552                &InterimTranscriptHash(self.interim_transcript_hash.clone()),
553            )
554            .await?;
555        Ok(())
556    }
557
558    /// Deletes the [`PublicGroup`] from storage.
559    #[openmls_traits::maybe_async]
560    pub async fn delete<Storage: PublicStorageProvider>(
561        storage: &Storage,
562        group_id: &GroupId,
563    ) -> Result<(), Storage::Error> {
564        storage.delete_tree(group_id).await?;
565        storage.delete_confirmation_tag(group_id).await?;
566        storage.delete_context(group_id).await?;
567        storage.delete_interim_transcript_hash(group_id).await?;
568
569        Ok(())
570    }
571
572    /// Loads the [`PublicGroup`] corresponding to a [`GroupId`] from storage.
573    #[openmls_traits::maybe_async]
574    pub async fn load<Storage: PublicStorageProvider>(
575        storage: &Storage,
576        group_id: &GroupId,
577    ) -> Result<Option<Self>, Storage::Error> {
578        let treesync = storage.tree(group_id).await?;
579        let proposals: Vec<(ProposalRef, QueuedProposal)> =
580            storage.queued_proposals(group_id).await?;
581        let group_context = storage.group_context(group_id).await?;
582        let interim_transcript_hash: Option<InterimTranscriptHash> =
583            storage.interim_transcript_hash(group_id).await?;
584        let confirmation_tag = storage.confirmation_tag(group_id).await?;
585        let mut proposal_store = ProposalStore::new();
586
587        for (_ref, proposal) in proposals {
588            proposal_store.add(proposal);
589        }
590
591        let build = || -> Option<Self> {
592            Some(Self {
593                treesync: treesync?,
594                proposal_store,
595                group_context: group_context?,
596                interim_transcript_hash: interim_transcript_hash?.0,
597                confirmation_tag: confirmation_tag?,
598            })
599        };
600
601        Ok(build())
602    }
603
604    /// Returns a reference to the [`ProposalStore`].
605    pub(crate) fn proposal_store(&self) -> &ProposalStore {
606        &self.proposal_store
607    }
608
609    /// Returns a mutable reference to the [`ProposalStore`].
610    pub(crate) fn proposal_store_mut(&mut self) -> &mut ProposalStore {
611        &mut self.proposal_store
612    }
613}
614
615// Test functions
616#[cfg(any(feature = "test-utils", test))]
617impl PublicGroup {
618    pub(crate) fn context_mut(&mut self) -> &mut GroupContext {
619        &mut self.group_context
620    }
621
622    #[cfg(test)]
623    pub(crate) fn set_group_context(&mut self, group_context: GroupContext) {
624        self.group_context = group_context;
625    }
626
627    #[cfg(test)]
628    pub(crate) fn encrypt_path(
629        &self,
630        provider: &impl crate::storage::OpenMlsProvider,
631        ciphersuite: Ciphersuite,
632        path: &[PlainUpdatePathNode],
633        group_context: &[u8],
634        exclusion_list: &HashSet<&LeafNodeIndex>,
635        own_leaf_index: LeafNodeIndex,
636    ) -> Result<Vec<UpdatePathNode>, LibraryError> {
637        self.treesync().empty_diff().encrypt_path(
638            provider.crypto(),
639            ciphersuite,
640            path,
641            group_context,
642            exclusion_list,
643            own_leaf_index,
644        )
645    }
646}