mls-rs 0.55.3

An implementation of Messaging Layer Security (RFC 9420)
Documentation
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// Copyright by contributors to this project.
// SPDX-License-Identifier: (Apache-2.0 OR MIT)

pub(crate) mod bundle;
mod filtering_common;

#[cfg(feature = "by_ref_proposal")]
mod filtering;
#[cfg(not(feature = "by_ref_proposal"))]
pub mod filtering_lite;
#[cfg(all(feature = "custom_proposal", not(feature = "by_ref_proposal")))]
use filtering_lite as filtering;

pub use bundle::{ProposalBundle, ProposalInfo, ProposalSource};

pub(crate) use filtering_common::{prepare_proposals_for_mls_rules, ProposalApplier};

#[cfg(all(feature = "by_ref_proposal", test))]
pub(crate) use filtering::proposer_can_propose;

use crate::{group::proposal_filter::bundle::Proposable, MlsRules};

//By default, the path field of a Commit MUST be populated. The path field MAY be omitted if
//(a) it covers at least one proposal and (b) none of the proposals covered by the Commit are
//of "path required" types. A proposal type requires a path if it cannot change the group
//membership in a way that requires the forward secrecy and post-compromise security guarantees
//that an UpdatePath provides. The only proposal types defined in this document that do not
//require a path are:

// add
// psk
// reinit
pub(crate) fn path_update_required<R: MlsRules>(
    proposals: &ProposalBundle,
    #[cfg(feature = "custom_proposal")] mls_rules: &R,
    #[cfg(not(feature = "custom_proposal"))] _mls_rules: &R,
) -> bool {
    #[cfg(feature = "custom_proposal")]
    // Check if update path is required due to custom proposals, according to custom rules.
    if proposals
        .custom_proposals
        .iter()
        .any(|p| mls_rules.custom_proposal_requires_update_path(p.proposal.proposal_type()))
    {
        return true;
    }

    // Check if update path is required according to MLS rules. Skip local proposals generated by
    // custom proposal filtering rules, since custom propsals were handled above.

    // Handle feature-flagged proposals
    #[cfg(feature = "by_ref_proposal")]
    if has_non_local_proposal(proposals.update_proposals()) {
        return true;
    }

    #[cfg(all(
        feature = "by_ref_proposal",
        feature = "custom_proposal",
        feature = "self_remove_proposal"
    ))]
    if has_non_local_proposal(&proposals.self_removes) {
        return true;
    }

    // Handle regular proposals
    proposals.length() == 0
        || has_non_local_proposal(proposals.external_init_proposals())
        || has_non_local_proposal(&proposals.group_context_extensions)
        || has_non_local_proposal(proposals.remove_proposals())
}

fn has_non_local_proposal<T: Proposable>(proposals: &[ProposalInfo<T>]) -> bool {
    proposals.iter().any(|p| p.source != ProposalSource::Local)
}

#[cfg(test)]
mod tests {
    use alloc::vec;

    use super::*;
    use crate::group::{
        framing::Sender,
        mls_rules::DefaultMlsRules,
        proposal::{ExternalInit, ReInitProposal, RemoveProposal},
        proposal_filter::bundle::ProposalSource,
    };
    use crate::tree_kem::node::LeafIndex;
    use mls_rs_core::group::ProposalType;

    #[cfg(feature = "custom_proposal")]
    use crate::group::proposal::CustomProposal;

    fn member_sender() -> Sender {
        Sender::Member(0)
    }

    fn dummy_leaf_node() -> crate::tree_kem::leaf_node::LeafNode {
        use crate::tree_kem::leaf_node::{LeafNode, LeafNodeSource};
        use mls_rs_core::identity::{BasicCredential, Credential, SigningIdentity};

        LeafNode {
            public_key: vec![].into(),
            signing_identity: SigningIdentity::new(
                Credential::Basic(BasicCredential::new(b"test".to_vec())),
                vec![].into(),
            ),
            capabilities: Default::default(),
            leaf_node_source: LeafNodeSource::Update,
            extensions: Default::default(),
            signature: vec![],
        }
    }

    #[test]
    fn empty_bundle_requires_path() {
        let bundle = ProposalBundle::default();
        assert!(path_update_required(&bundle, &DefaultMlsRules::new()));
    }

    #[test]
    fn remove_requires_path() {
        let mut bundle = ProposalBundle::default();
        bundle.removals.push(ProposalInfo {
            proposal: RemoveProposal {
                to_remove: LeafIndex::unchecked(1),
            },
            sender: member_sender(),
            source: ProposalSource::ByValue,
        });

        assert!(path_update_required(&bundle, &DefaultMlsRules::new()));
    }

    #[test]
    fn reinit_only_does_not_require_path() {
        let mut bundle = ProposalBundle::default();
        bundle.reinitializations.push(ProposalInfo {
            proposal: ReInitProposal {
                group_id: vec![],
                version: crate::ProtocolVersion::MLS_10,
                cipher_suite: crate::CipherSuite::CURVE25519_AES128,
                extensions: Default::default(),
            },
            sender: member_sender(),
            source: ProposalSource::ByValue,
        });

        assert!(!path_update_required(&bundle, &DefaultMlsRules::new()));
    }

    #[test]
    fn external_init_requires_path() {
        let mut bundle = ProposalBundle::default();
        bundle.external_initializations.push(ProposalInfo {
            proposal: ExternalInit { kem_output: vec![] },
            sender: member_sender(),
            source: ProposalSource::ByValue,
        });

        assert!(path_update_required(&bundle, &DefaultMlsRules::new()));
    }

    #[test]
    fn group_context_extensions_requires_path() {
        let mut bundle = ProposalBundle::default();
        bundle.group_context_extensions.push(ProposalInfo {
            proposal: Default::default(),
            sender: member_sender(),
            source: ProposalSource::ByValue,
        });

        assert!(path_update_required(&bundle, &DefaultMlsRules::new()));
    }

    #[test]
    fn local_remove_does_not_require_path_by_itself() {
        // A local proposal (injected by MlsRules) should not trigger path required
        let mut bundle = ProposalBundle::default();
        bundle.removals.push(ProposalInfo {
            proposal: RemoveProposal {
                to_remove: LeafIndex::unchecked(1),
            },
            sender: member_sender(),
            source: ProposalSource::Local,
        });

        // length > 0 and only local remove => not required
        assert!(!path_update_required(&bundle, &DefaultMlsRules::new()));
    }

    #[cfg(feature = "by_ref_proposal")]
    #[test]
    fn update_requires_path() {
        use crate::group::proposal::UpdateProposal;

        let mut bundle = ProposalBundle::default();
        bundle.updates.push(ProposalInfo {
            proposal: UpdateProposal {
                leaf_node: dummy_leaf_node(),
            },
            sender: member_sender(),
            source: ProposalSource::ByValue,
        });
        bundle.update_senders.push(LeafIndex::unchecked(0));

        assert!(path_update_required(&bundle, &DefaultMlsRules::new()));
    }

    #[cfg(all(
        feature = "by_ref_proposal",
        feature = "custom_proposal",
        feature = "self_remove_proposal"
    ))]
    #[test]
    fn self_remove_requires_path() {
        use crate::group::proposal::SelfRemoveProposal;

        let mut bundle = ProposalBundle::default();
        bundle.self_removes.push(ProposalInfo {
            proposal: SelfRemoveProposal {},
            sender: member_sender(),
            source: ProposalSource::ByValue,
        });

        assert!(path_update_required(&bundle, &DefaultMlsRules::new()));
    }

    #[cfg(feature = "custom_proposal")]
    #[test]
    fn custom_proposal_requires_path_when_rules_say_so() {
        let custom_type = ProposalType::from(42);

        let rules = DefaultMlsRules::new()
            .with_custom_proposals_that_require_update_path(vec![custom_type]);

        let mut bundle = ProposalBundle::default();
        bundle.custom_proposals.push(ProposalInfo {
            proposal: CustomProposal::new(custom_type, vec![]),
            sender: member_sender(),
            source: ProposalSource::ByValue,
        });

        assert!(path_update_required(&bundle, &rules));
    }

    #[cfg(feature = "custom_proposal")]
    #[test]
    fn custom_proposal_does_not_require_path_when_rules_say_no() {
        let custom_type = ProposalType::from(42);

        // DefaultMlsRules with empty list => custom_proposal_requires_update_path returns false
        let rules = DefaultMlsRules::new();

        let mut bundle = ProposalBundle::default();
        bundle.custom_proposals.push(ProposalInfo {
            proposal: CustomProposal::new(custom_type, vec![]),
            sender: member_sender(),
            source: ProposalSource::ByValue,
        });

        // A local proposal that requires path is overruled by custom proposal rules
        bundle.removals.push(ProposalInfo {
            proposal: RemoveProposal {
                to_remove: LeafIndex::unchecked(1),
            },
            sender: member_sender(),
            source: ProposalSource::Local,
        });

        assert!(!path_update_required(&bundle, &rules));
    }
}