Repository: awslabs/mls-rs
Pull request #263: API to validate `CustomProposal` from prior epochs
Author: login: emarteca
Created: 2025-03-15T23:05:12Z | Merged: 2025-03-28T20:54:45Z

This PR adds a new API, `validate_custom_proposal` , on `Group`.  This validates the membership tag and signature of a custom proposal message from the current or a saved `PriorEpoch`.
This new API function:
* takes an `MlsMessage` 
* validates its membership tag and signature using the existing function `verify_plaintext_authentication`
* asserts that the message must be a `CustomProposal`, and 
* returns the `authenticated_data` and `Sender` associated with the proposal. 

It uses a more general function we added as a helper, `validate_public_message` which validates any public message (and returns the `AuthenticatedContent`).

In order to allow `verify_plaintext_authentication` to validate messages from prior epochs, we added a `membership_tag` field to `PriorEpoch`, which stores the KeyScheduler's current membership tag when the epoch is stored. Then, we modified `verify_plaintext_authentication` to closely match the signature of `verify_auth_content_signature`, to allow information from prior epochs (group context, and signature public keys container) to be passed through (since `PriorEpoch` does not store the entire group state). 

The current functionality of `process_incoming_message` is unmodified. Only public messages from the current epoch can be applied to the group. 

In our new API, we add support for looking up prior epochs when validating the public messages, following the strategy in `decrypt_incoming_ciphertext` which adds support for `PriorEpoch` validation for private messages.

----

All of this functionality is gated behind a new feature flag `validate_public_message_prior_epoch`. 
This PR also adds tests for the new API and the helper function.




Diff:
diff --git a/mls-rs/Cargo.toml b/mls-rs/Cargo.toml
index 413819eb4..d33f9e2cf 100644
--- a/mls-rs/Cargo.toml
+++ b/mls-rs/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "mls-rs"
-version = "0.45.2"
+version = "0.45.3"
 edition = "2021"
 description = "An implementation of Messaging Layer Security (RFC 9420)"
 homepage = "https://github.com/awslabs/mls-rs"
@@ -31,6 +31,7 @@ prior_epoch = []
 by_ref_proposal = []
 psk = []
 non_domain_separated_hpke_encrypt_decrypt = []
+prior_epoch_membership_key = []
 x509 = ["mls-rs-core/x509", "dep:mls-rs-identity-x509"]
 rfc_compliant = ["private_message", "custom_proposal", "out_of_order", "psk", "x509", "prior_epoch", "by_ref_proposal", "mls-rs-core/rfc_compliant"]
 last_resort_key_package_ext = ["mls-rs-core/last_resort_key_package_ext"]
diff --git a/mls-rs/src/external_client/group.rs b/mls-rs/src/external_client/group.rs
index e7c9ecbb0..cede62aec 100644
--- a/mls-rs/src/external_client/group.rs
+++ b/mls-rs/src/external_client/group.rs
@@ -590,7 +590,10 @@ where
             &self.cipher_suite_provider,
             message,
             None,
-            &self.state,
+            &self.state.context,
+            crate::group::message_verifier::SignaturePublicKeysContainer::RatchetTree(
+                &self.state.public_tree,
+            ),
         )
         .await?;
 
diff --git a/mls-rs/src/group/epoch.rs b/mls-rs/src/group/epoch.rs
index 58352d62a..df4945db0 100644
--- a/mls-rs/src/group/epoch.rs
+++ b/mls-rs/src/group/epoch.rs
@@ -30,6 +30,8 @@ pub(crate) struct PriorEpoch {
     pub(crate) self_index: LeafIndex,
     pub(crate) secrets: EpochSecrets,
     pub(crate) signature_public_keys: Vec<Option<SignaturePublicKey>>,
+    #[cfg(feature = "prior_epoch_membership_key")]
+    pub(crate) membership_key: Vec<u8>,
 }
 
 #[cfg(feature = "prior_epoch")]
@@ -160,6 +162,8 @@ pub(crate) mod test_utils {
             self_index: LeafIndex(0),
             secrets: get_test_epoch_secrets(cipher_suite),
             signature_public_keys: Default::default(),
+            #[cfg(feature = "prior_epoch_membership_key")]
+            membership_key: Default::default(),
         }
     }
 }
diff --git a/mls-rs/src/group/external_commit.rs b/mls-rs/src/group/external_commit.rs
index c4b92fc1c..14e0b7774 100644
--- a/mls-rs/src/group/external_commit.rs
+++ b/mls-rs/src/group/external_commit.rs
@@ -27,7 +27,7 @@ use crate::group::{
     message_processor::{EventOrContent, MessageProcessor},
     message_signature::AuthenticatedContent,
     message_verifier::verify_plaintext_authentication,
-    CustomProposal,
+    CustomProposal, SignaturePublicKeysContainer,
 };
 
 use alloc::vec;
@@ -245,7 +245,14 @@ impl<C: ClientConfig> ExternalCommitBuilder<C> {
             };
 
             let auth_content = AuthenticatedContent::from(plaintext.clone());
-            verify_plaintext_authentication(&cipher_suite, plaintext, None, &group.state).await?;
+            verify_plaintext_authentication(
+                &cipher_suite,
+                plaintext,
+                None,
+                &group.state.context,
+                SignaturePublicKeysContainer::RatchetTree(&group.state.public_tree),
+            )
+            .await?;
 
             group
                 .process_event_or_content(EventOrContent::Content(auth_content), true, None)
diff --git a/mls-rs/src/group/key_schedule.rs b/mls-rs/src/group/key_schedule.rs
index 77c53352d..73f39f24a 100644
--- a/mls-rs/src/group/key_schedule.rs
+++ b/mls-rs/src/group/key_schedule.rs
@@ -40,7 +40,7 @@ pub struct KeySchedule {
     external_secret: Zeroizing<Vec<u8>>,
     #[mls_codec(with = "mls_rs_codec::byte_vec")]
     #[cfg_attr(feature = "serde", serde(with = "mls_rs_core::zeroizing_serde"))]
-    membership_key: Zeroizing<Vec<u8>>,
+    pub(crate) membership_key: Zeroizing<Vec<u8>>,
     init_secret: InitSecret,
 }
 
diff --git a/mls-rs/src/group/message_verifier.rs b/mls-rs/src/group/message_verifier.rs
index a29c0c98e..4dfcdcfd1 100644
--- a/mls-rs/src/group/message_verifier.rs
+++ b/mls-rs/src/group/message_verifier.rs
@@ -8,7 +8,7 @@ use alloc::{vec, vec::Vec};
 use crate::{
     client::MlsError,
     crypto::SignaturePublicKey,
-    group::{GroupContext, PublicMessage, Sender},
+    group::{GroupContext, MembershipTag, PublicMessage, Sender},
     signer::Signable,
     tree_kem::{node::LeafIndex, TreeKemPublic},
     CipherSuiteProvider,
@@ -17,11 +17,7 @@ use crate::{
 #[cfg(feature = "by_ref_proposal")]
 use crate::{extension::ExternalSendersExt, identity::SigningIdentity};
 
-use super::{
-    key_schedule::KeySchedule,
-    message_signature::{AuthenticatedContent, MessageSigningContext},
-    state::GroupState,
-};
+use super::message_signature::{AuthenticatedContent, MessageSigningContext};
 
 #[cfg(feature = "by_ref_proposal")]
 use super::proposal::Proposal;
@@ -37,29 +33,31 @@ pub(crate) enum SignaturePublicKeysContainer<'a> {
 pub(crate) async fn verify_plaintext_authentication<P: CipherSuiteProvider>(
     cipher_suite_provider: &P,
     plaintext: PublicMessage,
-    key_schedule: Option<&KeySchedule>,
-    state: &GroupState,
+    membership_key: Option<&[u8]>,
+    context: &GroupContext,
+    signature_keys_container: SignaturePublicKeysContainer<'_>,
 ) -> Result<AuthenticatedContent, MlsError> {
     let tag = plaintext.membership_tag.clone();
     let auth_content = AuthenticatedContent::from(plaintext);
-    let context = &state.context;
 
     #[cfg(feature = "by_ref_proposal")]
     let external_signers = external_signers(context);
 
-    let current_tree = &state.public_tree;
-
     // Verify the membership tag if needed
     match &auth_content.content.sender {
         Sender::Member(_) => {
-            if let Some(key_schedule) = key_schedule {
-                let expected_tag = &key_schedule
-                    .get_membership_tag(&auth_content, context, cipher_suite_provider)
-                    .await?;
+            if let Some(membership_key) = membership_key {
+                let expected_tag = MembershipTag::create(
+                    &auth_content,
+                    context,
+                    membership_key,
+                    cipher_suite_provider,
+                )
+                .await?;
 
                 let plaintext_tag = tag.as_ref().ok_or(MlsError::InvalidMembershipTag)?;
 
-                if expected_tag != plaintext_tag {
+                if &expected_tag != plaintext_tag {
                     return Err(MlsError::InvalidMembershipTag);
                 }
             }
@@ -75,7 +73,7 @@ pub(crate) async fn verify_plaintext_authentication<P: CipherSuiteProvider>(
     // from the credential stored at the leaf in the tree indicated by the sender field.
     verify_auth_content_signature(
         cipher_suite_provider,
-        SignaturePublicKeysContainer::RatchetTree(current_tree),
+        signature_keys_container,
         context,
         &auth_content,
         #[cfg(feature = "by_ref_proposal")]
@@ -325,8 +323,9 @@ mod tests {
         verify_plaintext_authentication(
             &env.bob.cipher_suite_provider,
             message,
-            Some(&env.bob.key_schedule),
-            &env.bob.state,
+            Some(&env.bob.key_schedule.membership_key),
+            &env.bob.state.context,
+            super::SignaturePublicKeysContainer::RatchetTree(&env.bob.state.public_tree),
         )
         .await
         .unwrap();
@@ -371,8 +370,9 @@ mod tests {
         let res = verify_plaintext_authentication(
             &env.bob.cipher_suite_provider,
             message,
-            Some(&env.bob.key_schedule),
-            &env.bob.state,
+            Some(&env.bob.key_schedule.membership_key),
+            &env.bob.state.context,
+            SignaturePublicKeysContainer::RatchetTree(&env.bob.state.public_tree),
         )
         .await;
 
@@ -388,8 +388,9 @@ mod tests {
         let res = verify_plaintext_authentication(
             &env.bob.cipher_suite_provider,
             message,
-            Some(&env.bob.key_schedule),
-            &env.bob.state,
+            Some(&env.bob.key_schedule.membership_key),
+            &env.bob.state.context,
+            SignaturePublicKeysContainer::RatchetTree(&env.bob.state.public_tree),
         )
         .await;
 
@@ -405,8 +406,9 @@ mod tests {
         let res = verify_plaintext_authentication(
             &env.bob.cipher_suite_provider,
             message,
-            Some(&env.bob.key_schedule),
-            &env.bob.state,
+            Some(&env.bob.key_schedule.membership_key),
+            &env.bob.state.context,
+            SignaturePublicKeysContainer::RatchetTree(&env.bob.state.public_tree),
         )
         .await;
 
@@ -468,8 +470,9 @@ mod tests {
         verify_plaintext_authentication(
             &test_group.cipher_suite_provider,
             message,
-            Some(&test_group.key_schedule),
-            &test_group.state,
+            Some(&test_group.key_schedule.membership_key),
+            &test_group.state.context,
+            SignaturePublicKeysContainer::RatchetTree(&test_group.state.public_tree),
         )
         .await
         .unwrap();
@@ -488,8 +491,9 @@ mod tests {
         let res = verify_plaintext_authentication(
             &test_group.cipher_suite_provider,
             message,
-            Some(&test_group.key_schedule),
-            &test_group.state,
+            Some(&test_group.key_schedule.membership_key),
+            &test_group.state.context,
+            SignaturePublicKeysContainer::RatchetTree(&test_group.state.public_tree),
         )
         .await;
 
@@ -513,8 +517,9 @@ mod tests {
         let res: Result<AuthenticatedContent, MlsError> = verify_plaintext_authentication(
             &test_group.cipher_suite_provider,
             message,
-            Some(&test_group.key_schedule),
-            &test_group.state,
+            Some(&test_group.key_schedule.membership_key),
+            &test_group.state.context,
+            SignaturePublicKeysContainer::RatchetTree(&test_group.state.public_tree),
         )
         .await;
 
@@ -536,8 +541,9 @@ mod tests {
         let res = verify_plaintext_authentication(
             &test_group.cipher_suite_provider,
             message,
-            Some(&test_group.key_schedule),
-            &test_group.state,
+            Some(&test_group.key_schedule.membership_key),
+            &test_group.state.context,
+            SignaturePublicKeysContainer::RatchetTree(&test_group.state.public_tree),
         )
         .await;
 
@@ -579,8 +585,9 @@ mod tests {
         verify_plaintext_authentication(
             &test_group.cipher_suite_provider,
             message,
-            Some(&test_group.key_schedule),
-            &test_group.state,
+            Some(&test_group.key_schedule.membership_key),
+            &test_group.state.context,
+            SignaturePublicKeysContainer::RatchetTree(&test_group.state.public_tree),
         )
         .await
         .unwrap();
@@ -602,8 +609,9 @@ mod tests {
         let res = verify_plaintext_authentication(
             &test_group.cipher_suite_provider,
             message,
-            Some(&test_group.key_schedule),
-            &test_group.state,
+            Some(&test_group.key_schedule.membership_key),
+            &test_group.state.context,
+            SignaturePublicKeysContainer::RatchetTree(&test_group.state.public_tree),
         )
         .await;
 
@@ -628,8 +636,9 @@ mod tests {
         let res = verify_plaintext_authentication(
             &test_group.cipher_suite_provider,
             message,
-            Some(&test_group.key_schedule),
-            &test_group.state,
+            Some(&test_group.key_schedule.membership_key),
+            &test_group.state.context,
+            SignaturePublicKeysContainer::RatchetTree(&test_group.state.public_tree),
         )
         .await;
 
diff --git a/mls-rs/src/group/mod.rs b/mls-rs/src/group/mod.rs
index ba60035c1..2af081c08 100644
--- a/mls-rs/src/group/mod.rs
+++ b/mls-rs/src/group/mod.rs
@@ -1201,6 +1201,102 @@ where
             .await
     }
 
+    /// Validate a custom proposal message, verifying its signature and membership
+    /// tag. Can be a proposal from the current or a prior epoch.
+    /// Can also pass in a ProposalType to check the custom proposal against.
+    /// Returns the `authenticated_data` and sender of the custom proposal, or an error if
+    /// it fails to validate or if the message is not a custom proposal.
+    ///
+    /// WARNING: This API is not recommend for general purpose usage as it reduces the guarantees of RFC9420.
+    /// Since prior epochs' membership keys are stored, an attacker that compromises a client's signature key
+    /// and membership key is able to send valid MLS PublicMessages appearing to originate from the compromised
+    /// client from prior epochs.
+    /// This API is used by the GSMA's Rich Communication Suite for certain types of messages. See section 7.6
+    /// for specifics https://www.gsma.com/solutions-and-impact/technologies/networks/wp-content/uploads/2025/03/RCC.16-v1.0.pdf.
+    #[cfg(all(
+        feature = "custom_proposal",
+        feature = "by_ref_proposal",
+        feature = "prior_epoch_membership_key"
+    ))]
+    #[cfg_attr(feature = "ffi", safer_ffi_gen::safer_ffi_gen_ignore)]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    pub async fn validate_custom_proposal(
+        &mut self,
+        msg: &MlsMessage,
+        proposal_type: Option<ProposalType>,
+    ) -> Result<(Vec<u8>, Sender), MlsError> {
+        let auth_content = self.validate_public_message(msg).await?;
+        let proposal = match auth_content.content.content {
+            Content::Proposal(p) => match *p {
+                Proposal::Custom(c) => c,
+                _ => return Err(MlsError::UnexpectedMessageType),
+            },
+            _ => return Err(MlsError::UnexpectedMessageType),
+        };
+        if proposal_type.is_some() && Some(proposal.proposal_type()) != proposal_type {
+            return Err(MlsError::UnsupportedCustomProposal(
+                proposal.proposal_type(),
+            ));
+        }
+        Ok((
+            auth_content.content.authenticated_data,
+            auth_content.content.sender,
+        ))
+    }
+
+    // Only used for custom proposals right now; consider removing this feature flag
+    // if there are future use cases.
+    #[cfg(all(feature = "custom_proposal", feature = "prior_epoch_membership_key"))]
+    #[cfg_attr(feature = "ffi", safer_ffi_gen::safer_ffi_gen_ignore)]
+    #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
+    async fn validate_public_message(
+        &mut self,
+        msg: &MlsMessage,
+    ) -> Result<AuthenticatedContent, MlsError> {
+        let plaintext = match msg.payload {
+            MlsMessagePayload::Plain(ref plaintext) => plaintext.clone(),
+            _ => return Err(MlsError::UnexpectedMessageType),
+        };
+        let epoch_id = msg.epoch().ok_or(MlsError::EpochNotFound)?;
+        let auth_content = if epoch_id == self.context().epoch {
+            let auth_content = verify_plaintext_authentication(
+                &self.cipher_suite_provider,
+                plaintext,
+                Some(&self.key_schedule.membership_key),
+                &self.state.context,
+                SignaturePublicKeysContainer::RatchetTree(&self.state.public_tree),
+            )
+            .await?;
+
+            Ok::<_, MlsError>(auth_content)
+        } else {
+            #[cfg(feature = "prior_epoch")]
+            {
+                let epoch = self
+                    .state_repo
+                    .get_epoch_mut(epoch_id)
+                    .await?
+                    .ok_or(MlsError::EpochNotFound)?;
+
+                let auth_content = verify_plaintext_authentication(
+                    &self.cipher_suite_provider,
+                    plaintext,
+                    Some(&epoch.membership_key),
+                    &epoch.context,
+                    SignaturePublicKeysContainer::List(&epoch.signature_public_keys),
+                )
+                .await?;
+
+                Ok(auth_content)
+            }
+
+            #[cfg(not(feature = "prior_epoch"))]
+            Err(MlsError::EpochNotFound)
+        }?;
+
+        Ok(auth_content)
+    }
+
     /// Delete all sent and received proposals cached for commit.
     #[cfg(feature = "by_ref_proposal")]
     pub fn clear_proposal_cache(&mut self) {
@@ -1900,6 +1996,8 @@ impl<C: ClientConfig> Group<C> {
             self_index: self.private_tree.self_index,
             secrets: self.epoch_secrets.clone(),
             signature_public_keys,
+            #[cfg(feature = "prior_epoch_membership_key")]
+            membership_key: self.key_schedule.membership_key.to_vec(),
         };
 
         self.state_repo.insert(past_epoch).await?;
@@ -1969,8 +2067,9 @@ where
         let auth_content = verify_plaintext_authentication(
             &self.cipher_suite_provider,
             message,
-            Some(&self.key_schedule),
-            &self.state,
+            Some(&self.key_schedule.membership_key),
+            &self.state.context,
+            SignaturePublicKeysContainer::RatchetTree(&self.state.public_tree),
         )
         .await?;
 
@@ -2669,6 +2768,150 @@ mod tests {
         assert_eq!(plaintext.to_vec(), hpke_decrypted);
     }
 
+    #[cfg(all(
+        feature = "prior_epoch",
+        feature = "custom_proposal",
+        feature = "by_ref_proposal",
+        feature = "prior_epoch_membership_key"
+    ))]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_can_validate_and_get_data_custom_proposal_from_past_epoch() {
+        let mut alice = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let (mut bob, _) = alice.join("bob").await;
+
+        let data = vec![1, 2, 3];
+        let custom_proposal = CustomProposal::new(TEST_CUSTOM_PROPOSAL_TYPE, vec![4, 5, 6]);
+        let proposal = alice
+            .propose_custom(custom_proposal.clone(), data.clone())
+            .await
+            .unwrap();
+
+        // add carol to the group
+        let (_carol, commit) = alice.join("carol").await;
+        bob.process_incoming_message(commit).await.unwrap();
+        let (validated_data, sender) = bob
+            .validate_custom_proposal(&proposal, Some(TEST_CUSTOM_PROPOSAL_TYPE))
+            .await
+            .unwrap();
+        assert_eq!(data, validated_data);
+        assert_eq!(sender, Sender::Member(0));
+    }
+
+    #[cfg(all(
+        feature = "prior_epoch",
+        feature = "custom_proposal",
+        feature = "by_ref_proposal",
+        feature = "prior_epoch_membership_key"
+    ))]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_can_validate_and_get_data_custom_proposal_from_current_epoch() {
+        let mut alice = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let (mut bob, _) = alice.join("bob").await;
+
+        let data = vec![1, 2, 3];
+        let custom_proposal = CustomProposal::new(TEST_CUSTOM_PROPOSAL_TYPE, vec![3, 4, 5]);
+        let proposal = alice
+            .propose_custom(custom_proposal.clone(), data.clone())
+            .await
+            .unwrap();
+
+        let (validated_data, sender) = bob.validate_custom_proposal(&proposal, None).await.unwrap();
+        assert_eq!(data, validated_data);
+        assert_eq!(sender, Sender::Member(0));
+    }
+
+    #[cfg(all(feature = "prior_epoch", feature = "prior_epoch_membership_key"))]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_can_validate_non_custom_proposal_from_past_epoch() {
+        let mut alice = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let (mut bob, _) = alice.join("bob").await;
+
+        let proposal = alice.propose_update(Vec::new()).await.unwrap();
+
+        // add carol to the group
+        let (_carol, commit) = alice.join("carol").await;
+        bob.process_incoming_message(commit).await.unwrap();
+
+        bob.validate_public_message(&proposal).await.unwrap();
+    }
+
+    #[cfg(all(
+        feature = "prior_epoch",
+        feature = "by_ref_proposal",
+        feature = "prior_epoch_membership_key"
+    ))]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_cannot_validate_custom_proposal_for_non_custom_proposal() {
+        let mut alice = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let (mut bob, _) = alice.join("bob").await;
+
+        let proposal = alice.propose_update(Vec::new()).await.unwrap();
+
+        // add carol to the group
+        let (_carol, commit) = alice.join("carol").await;
+        bob.process_incoming_message(commit).await.unwrap();
+
+        let data_err = bob.validate_custom_proposal(&proposal, None).await;
+        assert_matches!(data_err, Err(MlsError::UnexpectedMessageType));
+    }
+
+    #[cfg(all(feature = "prior_epoch", feature = "prior_epoch_membership_key"))]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_can_validate_commit_from_past_epoch() {
+        let mut alice = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let (mut bob, _) = alice.join("bob").await;
+
+        let old_commit_output = bob
+            .commit_builder()
+            .remove_member(0)
+            .unwrap()
+            .build()
+            .await
+            .unwrap();
+
+        // add carol to the group
+        let (_carol, commit) = alice.join("carol").await;
+        bob.process_incoming_message(commit).await.unwrap();
+
+        bob.validate_public_message(&old_commit_output.commit_message)
+            .await
+            .unwrap();
+    }
+
+    #[cfg(all(
+        feature = "prior_epoch",
+        feature = "custom_proposal",
+        feature = "prior_epoch_membership_key"
+    ))]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_can_validate_custom_proposal_from_current_epoch() {
+        let mut alice = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let (mut bob, _) = alice.join("bob").await;
+
+        let custom_proposal = CustomProposal::new(TEST_CUSTOM_PROPOSAL_TYPE, vec![0, 1, 2]);
+        let proposal = alice
+            .propose_custom(custom_proposal.clone(), vec![])
+            .await
+            .unwrap();
+
+        bob.validate_public_message(&proposal).await.unwrap();
+    }
+
+    #[cfg(all(feature = "prior_epoch", feature = "prior_epoch_membership_key"))]
+    #[maybe_async::test(not(mls_build_async), async(mls_build_async, crate::futures_test))]
+    async fn test_cannot_validate_non_public_message() {
+        let mut alice = test_group(TEST_PROTOCOL_VERSION, TEST_CIPHER_SUITE).await;
+        let (mut bob, _) = alice.join("bob").await;
+
+        let res = alice
+            .encrypt_application_message(b"test", vec![])
+            .await
+            .unwrap();
+
+        let auth_content = bob.validate_public_message(&res).await;
+        assert_matches!(auth_content, Err(MlsError::UnexpectedMessageType));
+    }
+
     #[cfg_attr(not(mls_build_async), maybe_async::must_be_sync)]
     async fn test_two_member_group(
         protocol_version: ProtocolVersion,
diff --git a/mls-rs/src/message.rs b/mls-rs/src/message.rs
index e69de29bb..8b1378917 100644
--- a/mls-rs/src/message.rs
+++ b/mls-rs/src/message.rs
@@ -0,0 +1 @@
+


Reviews:
user: login: tomleavy
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-22T01:45:53Z
user: login: mulmarta
body: Switching on this feature decreases MLS security because old membership keys are stored. I think there should be a large WARNING somewhere in a comment (ideally on the feature somehow) explaining security consequences of this.

It would also be nice to explain the possible use cases for this, since receiving old proposals seems not very useful for MLS - the only purpose of proposal is to be committed to. Possibly a link to some protocol using this?

Maybe this example will help understand the security implications. So the following attack is prevented by MLS but not by MLS with this feature:
* There's a group with members A, B
* A is in epoch 2, B is in epoch 1 (he's behind because he's offline)
* Adversary corrupts A, injects to B a proposal adding C, B commits to the proposal
It is not possible in MLS because A in epoch 2 deleted the membership key in epoch 1. It is possible now because adversary leaked A's signing key and membership key.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-23T18:15:30Z
user: login: emarteca
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T18:52:16Z
user: login: tomleavy
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-28T20:00:12Z
user: login: tomleavy
state: APPROVED
author_association: NONE
submitted_at: 2025-03-28T20:00:38Z
user: login: mulmarta
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T20:53:45Z

Review comments:
diff_hunk: @@ -30,6 +30,8 @@ pub(crate) struct PriorEpoch {
     pub(crate) self_index: LeafIndex,
     pub(crate) secrets: EpochSecrets,
     pub(crate) signature_public_keys: Vec<Option<SignaturePublicKey>>,
+    #[cfg(feature = "validate_public_message_prior_epoch")]
path: mls-rs/src/group/epoch.rs
user: login: tomleavy
body: I'm concerned about the format of stored data being different when the feature is on vs off. I can discuss with @mulmarta, but one suggestion is that we can add a feature to the mls encoding macro to have fields at the end of a struct be optional and automatically filled in with the Default value for that type. 

The end result is something like  this:

```
#[mls(default)]
membership_key: Option<Vec<u8>>
```



created_at: 2025-03-22T01:45:47Z
author_association: NONE
diff_hunk: @@ -30,6 +30,8 @@ pub(crate) struct PriorEpoch {
     pub(crate) self_index: LeafIndex,
     pub(crate) secrets: EpochSecrets,
     pub(crate) signature_public_keys: Vec<Option<SignaturePublicKey>>,
+    #[cfg(feature = "validate_public_message_prior_epoch")]
path: mls-rs/src/group/epoch.rs
user: login: mulmarta
body: Yeah, I had the same thoughts. MLS encoding is not great for these stored values. It would be better to have something more flexible.
created_at: 2025-03-23T18:07:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -30,6 +30,8 @@ pub(crate) struct PriorEpoch {
     pub(crate) self_index: LeafIndex,
     pub(crate) secrets: EpochSecrets,
     pub(crate) signature_public_keys: Vec<Option<SignaturePublicKey>>,
+    #[cfg(feature = "validate_public_message_prior_epoch")]
path: mls-rs/src/group/epoch.rs
user: login: emarteca
body:  This makes sense. Is it something that we could do in a followup PR, since it sounds like some changes would be required to the MlsEncode trait? This feature-flagged field in a `#[derive(MlsEncode)]` struct seems to be used in a few places, for example [in the `EpochSecrets`](https://docs.rs/mls-rs/0.45.2/src/mls_rs/group/epoch.rs.html#70)
created_at: 2025-03-28T18:52:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -30,6 +30,8 @@ pub(crate) struct PriorEpoch {
     pub(crate) self_index: LeafIndex,
     pub(crate) secrets: EpochSecrets,
     pub(crate) signature_public_keys: Vec<Option<SignaturePublicKey>>,
+    #[cfg(feature = "validate_public_message_prior_epoch")]
path: mls-rs/src/group/epoch.rs
user: login: tomleavy
body: Good point, we do have to clean that up overall, can target for v 1.0
created_at: 2025-03-28T20:00:12Z
author_association: NONE

PR comments:
user: login: cindylindeed
created_at: 2025-03-25T21:13:50Z
body: > Switching on this feature decreases MLS security because old membership keys are stored. I think there should be a large WARNING somewhere in a comment (ideally on the feature somehow) explaining security consequences of this.
> 
> It would also be nice to explain the possible use cases for this, since receiving old proposals seems not very useful for MLS - the only purpose of proposal is to be committed to. Possibly a link to some protocol using this?

@emarteca - Please add the following as a comment:

WARNING: This API is not recommend for general purpose usage as it reduces the guarantees of RFC9420. Since prior epochs' membership keys are stored, an attacker that compromises a client's signature key and membership key is able to send valid MLS PublicMessages appearing to originate from the compromised client from prior epochs.

This API is used by the GSMA's Rich Communication Suite for certain types of messages. See section 7.6 for specifics https://www.gsma.com/solutions-and-impact/technologies/networks/wp-content/uploads/2025/03/RCC.16-v1.0.pdf.
author_association: CONTRIBUTOR
user: login: emarteca
created_at: 2025-03-28T19:00:02Z
body: Thanks for your comments! I've updated the changes to include the warning @cindylindeed specified; I put it in the docs comment on the public API `validate_custom_proposal` . I also renamed the feature flag to `"prior_epoch_membership_key"` to make it a bit more clear. And force-pushed to rebase onto main head. 
Let me know if you'd like me to update this anywhere else!

I think your comment about the MLS encoding makes sense too. Would it make sense to do that change in a different PR? It could be used in other places in the mls-rs codebase that also include feature-flagged out fields in structs that derive `MlsEncode`.
author_association: CONTRIBUTOR